use async_trait::async_trait;
use config::{AppConfig, MusicService, Source};
use db::Db;
use crate::server_ops::ServerConn;
pub mod capabilities;
mod jellyfin;
mod local;
mod offline;
mod soundcloud;
mod subsonic;
mod types;
mod youtube_music;
use jellyfin::JellyfinSource;
use local::LocalSource;
use offline::OfflineServerSource;
use soundcloud::SoundcloudSource;
use subsonic::SubsonicSource;
pub use types::*;
use youtube_music::YtSource;
#[async_trait]
pub trait MediaSource: Send + Sync {
fn source(&self) -> &Source;
#[doc(hidden)]
fn db(&self) -> &Db;
fn capabilities(&self) -> Capabilities;
async fn add_to_playlist(
&self,
playlist_id: &str,
item_refs: &[String],
) -> Result<Vec<String>, SourceError>;
async fn create_playlist(
&self,
name: &str,
item_refs: &[String],
) -> Result<String, SourceError>;
async fn remove_from_playlist(
&self,
playlist_id: &str,
track: &reader::Track,
position: usize,
) -> Result<(), SourceError>;
async fn resolve_stream(&self, item_id: &str) -> Result<StreamInfo, SourceError>;
async fn validate(&self) -> AuthOutcome;
async fn fetch_favorites(&self) -> Result<Vec<String>, SourceError>;
async fn push_favorite(&self, item_id: &str, on: bool) -> Result<(), SourceError>;
async fn reorder_playlist(
&self,
_playlist_id: &str,
_ordered_refs: &[String],
_moved: &reader::Track,
_new_index: usize,
) -> Result<(), SourceError> {
Err(SourceError::unsupported("playlist reorder"))
}
async fn start_radio(&self, _seed_ref: &str) -> Result<Vec<reader::Track>, SourceError> {
Err(SourceError::unsupported("radio"))
}
fn web_url(&self, _track: &reader::Track) -> Option<String> {
None
}
async fn search(
&self,
query: &str,
) -> Result<(Vec<reader::Track>, Vec<reader::Album>), SourceError> {
let q = query.trim().to_lowercase();
if q.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
let tracks = self.db().search_corpus(self.source()).await?;
let albums = self.db().albums(self.source()).await?;
Ok(search_filter(&q, tracks, albums))
}
async fn discover_home(&self) -> Result<crate::ytmusic::discover::DiscoverHome, SourceError> {
Err(SourceError::unsupported("discover"))
}
async fn discover_continuation(
&self,
_token: &str,
) -> Result<crate::ytmusic::discover::DiscoverHome, SourceError> {
Err(SourceError::unsupported("discover"))
}
async fn fetch_album_tracks(
&self,
_browse_id: &str,
) -> Result<Vec<reader::Track>, SourceError> {
Err(SourceError::unsupported("album tracks"))
}
async fn fetch_album(&self, _browse_id: &str) -> Result<RemoteAlbum, SourceError> {
Err(SourceError::unsupported("album"))
}
async fn fetch_album_by_ref(&self, _id: &str) -> Result<Option<RemoteAlbum>, SourceError> {
Err(SourceError::unsupported("album by ref"))
}
async fn fetch_album_by_meta(
&self,
_title: &str,
_artist: &str,
) -> Result<Option<RemoteAlbum>, SourceError> {
Err(SourceError::unsupported("album by meta"))
}
async fn fetch_playlist_page(
&self,
_playlist_id: &str,
_cursor: Option<String>,
) -> Result<(Vec<reader::Track>, Option<String>), SourceError> {
Err(SourceError::unsupported("playlist paging"))
}
async fn resolve_artist_channel_id(&self, _query: &str) -> Result<Option<String>, SourceError> {
Err(SourceError::unsupported("artist channel"))
}
async fn resolve_album_browse_id(
&self,
_album: &str,
_artist: &str,
) -> Result<Option<String>, SourceError> {
Err(SourceError::unsupported("album browse id"))
}
async fn fetch_artist(
&self,
_channel_id: &str,
) -> Result<crate::ytmusic::discover::YtArtist, SourceError> {
Err(SourceError::unsupported("artist profile"))
}
async fn fetch_library(&self) -> Result<LibrarySnapshot, SourceError> {
Ok(LibrarySnapshot::default())
}
async fn fetch_playlist_entries(
&self,
_playlist_id: &str,
) -> Result<Vec<reader::Track>, SourceError> {
Ok(Vec::new())
}
async fn fetch_playlist_entries_page(
&self,
playlist_id: &str,
_cursor: Option<String>,
) -> Result<PlaylistPage, SourceError> {
Ok(PlaylistPage {
tracks: self.fetch_playlist_entries(playlist_id).await?,
next: None,
})
}
async fn fetch_playlists(&self) -> Result<Vec<PlaylistMeta>, SourceError> {
Ok(Vec::new())
}
async fn fetch_artist_images(&self) -> Result<Vec<(String, String)>, SourceError> {
Ok(Vec::new())
}
async fn fetch_artist_image(&self, _name: &str) -> Result<Option<String>, SourceError> {
Ok(None)
}
async fn fetch_favorites_page(
&self,
_cursor: Option<String>,
) -> Result<FavoritesPage, SourceError> {
Ok(FavoritesPage {
tracks: Vec::new(),
next: None,
})
}
async fn album_tracks(&self, album_id: &str) -> Result<Vec<reader::Track>, SourceError> {
self.db()
.album_tracks(self.source(), album_id)
.await
.map_err(SourceError::from)
}
async fn favorites(&self) -> Result<Vec<String>, SourceError> {
self.db()
.favorites(self.source().as_str())
.await
.map_err(SourceError::from)
}
async fn is_favorite(&self, ref_: &str) -> bool {
self.db()
.is_favorite(self.source().as_str(), ref_)
.await
.unwrap_or(false)
}
async fn set_favorite(&self, ref_: &str, on: bool) -> Result<(), SourceError> {
self.db()
.set_favorite(self.source().as_str(), ref_, on)
.await
.map_err(SourceError::from)
}
async fn scrobble_now_playing(&self, _item_id: &str) -> Result<(), SourceError> {
Ok(())
}
async fn scrobble(&self, _item_id: &str) -> Result<(), SourceError> {
Ok(())
}
async fn keepalive(&self) -> Result<(), SourceError> {
Ok(())
}
async fn report_playback_start(&self, _item_id: &str) -> Result<(), SourceError> {
Ok(())
}
async fn report_playback_stopped(
&self,
_item_id: &str,
_position_ticks: u64,
) -> Result<(), SourceError> {
Ok(())
}
async fn report_playback_progress(
&self,
_item_id: &str,
_position_ticks: u64,
_is_paused: bool,
) -> Result<(), SourceError> {
Ok(())
}
async fn set_playlist_tracks(
&self,
playlist_id: &str,
refs: &[String],
) -> Result<(), SourceError> {
self.db()
.set_playlist_tracks(self.source(), playlist_id, refs)
.await
.map_err(SourceError::from)
}
async fn remove_playlist_tracks(
&self,
playlist_id: &str,
refs: &[String],
) -> Result<(), SourceError> {
self.db()
.remove_playlist_tracks(self.source(), playlist_id, refs)
.await
.map_err(SourceError::from)
}
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), SourceError> {
self.db()
.delete_playlist(self.source(), playlist_id)
.await
.map_err(SourceError::from)
}
async fn set_playlist_cover(
&self,
playlist_id: &str,
name: &str,
image_path: &std::path::Path,
image_tag: Option<&str>,
) -> Result<(), SourceError> {
let cover = image_path.to_string_lossy();
self.db()
.upsert_playlist_meta(self.source(), playlist_id, name, Some(&cover), image_tag)
.await
.map_err(SourceError::from)
}
async fn upsert_tracks(&self, tracks: &[reader::Track]) -> Result<(), SourceError> {
self.db()
.upsert_tracks(self.source(), tracks)
.await
.map_err(SourceError::from)
}
async fn delete_tracks(&self, keys: &[String]) -> Result<u64, SourceError> {
self.db()
.delete_tracks(self.source(), keys)
.await
.map_err(SourceError::from)
}
async fn delete_album(&self, album_id: &str) -> Result<(), SourceError> {
self.db()
.delete_album(self.source(), album_id)
.await
.map_err(SourceError::from)
}
async fn upsert_albums(&self, albums: &[reader::Album]) -> Result<(), SourceError> {
self.db()
.upsert_albums(self.source(), albums)
.await
.map_err(SourceError::from)
}
async fn prune(
&self,
keep_track_keys: &[String],
keep_album_ids: &[String],
) -> Result<(), SourceError> {
self.db()
.prune_source(self.source(), keep_track_keys, keep_album_ids)
.await
.map_err(SourceError::from)
}
async fn set_artist_image(
&self,
artist_norm: &str,
kind: &str,
image_ref: Option<&str>,
) -> Result<(), SourceError> {
self.db()
.set_artist_image(artist_norm, kind, image_ref)
.await
.map_err(SourceError::from)
}
async fn set_offline_track(&self, id: &str, path: Option<&str>) -> Result<(), SourceError> {
self.db()
.set_offline_track(id, path)
.await
.map_err(SourceError::from)
}
async fn create_folder(&self, id: &str, name: &str) -> Result<(), SourceError> {
self.db()
.create_folder(id, name)
.await
.map_err(SourceError::from)
}
async fn rename_folder(&self, id: &str, name: &str) -> Result<(), SourceError> {
self.db()
.rename_folder(id, name)
.await
.map_err(SourceError::from)
}
async fn delete_folder(&self, id: &str) -> Result<(), SourceError> {
self.db().delete_folder(id).await.map_err(SourceError::from)
}
async fn set_playlist_folder(
&self,
playlist_ref: &str,
folder_id: Option<&str>,
) -> Result<(), SourceError> {
self.db()
.set_playlist_folder(playlist_ref, folder_id)
.await
.map_err(SourceError::from)
}
async fn update_album_cover(
&self,
album_id: &str,
cover_path: Option<&str>,
manual: bool,
) -> Result<(), SourceError> {
self.db()
.update_album_cover(self.source(), album_id, cover_path, manual)
.await
.map_err(SourceError::from)
}
async fn sweep_favorites(&self, epoch: i64) -> Result<(), SourceError> {
self.db()
.sweep_favorites(self.source().as_str(), epoch)
.await
.map_err(SourceError::from)
}
async fn replace_favorites_clean(&self, refs: &[String]) -> Result<(), SourceError> {
self.db()
.replace_favorites_clean(self.source().as_str(), refs)
.await
.map_err(SourceError::from)
}
async fn bump_listen_count(&self, track_uid: &str) -> Result<(), SourceError> {
self.db()
.bump_listen_count(track_uid)
.await
.map_err(SourceError::from)
}
async fn record_recent(&self, track_key: &str) -> Result<(), SourceError> {
self.db()
.push_recent(self.source(), track_key)
.await
.map_err(SourceError::from)
}
async fn set_meta(
&self,
cache_key: &str,
kind: &str,
payload: &str,
) -> Result<(), SourceError> {
self.db()
.meta_put(cache_key, kind, payload)
.await
.map_err(SourceError::from)
}
async fn upsert_favorites_page(
&self,
refs: &[String],
start_rank: i64,
epoch: i64,
) -> Result<(), SourceError> {
self.db()
.upsert_favorites_page(self.source().as_str(), refs, start_rank, epoch)
.await
.map_err(SourceError::from)
}
async fn upsert_playlist_tracks_page(
&self,
playlist_id: &str,
refs: &[String],
start_position: i64,
epoch: i64,
) -> Result<(), SourceError> {
self.db()
.upsert_playlist_tracks_page(self.source(), playlist_id, refs, start_position, epoch)
.await
.map_err(SourceError::from)
}
async fn sweep_playlist_tracks(
&self,
playlist_id: &str,
epoch: i64,
) -> Result<(), SourceError> {
self.db()
.sweep_playlist_tracks(self.source(), playlist_id, epoch)
.await
.map_err(SourceError::from)
}
async fn upsert_playlist_meta(
&self,
pl_id: &str,
name: &str,
cover_path: Option<&str>,
image_tag: Option<&str>,
) -> Result<(), SourceError> {
self.db()
.upsert_playlist_meta(self.source(), pl_id, name, cover_path, image_tag)
.await
.map_err(SourceError::from)
}
}
pub(super) async fn mirror_added(
db: &Db,
source: &Source,
pid: &str,
added: &[String],
) -> Result<(), SourceError> {
if !added.is_empty() {
db.add_playlist_tracks(source, pid, added).await?;
}
Ok(())
}
pub(super) fn encode_cover_url_tag(url: &str) -> String {
let mut hex = String::with_capacity(url.len() * 2);
for b in url.as_bytes() {
hex.push_str(&format!("{b:02x}"));
}
format!("urlhex_{hex}")
}
fn search_filter(
query: &str,
tracks: Vec<reader::Track>,
albums: Vec<reader::Album>,
) -> (Vec<reader::Track>, Vec<reader::Album>) {
let album_genre: std::collections::HashMap<&String, &str> =
albums.iter().map(|a| (&a.id, a.genre.as_str())).collect();
let result_tracks: Vec<reader::Track> = tracks
.iter()
.filter(|t| {
t.title.to_lowercase().contains(query)
|| t.artist.to_lowercase().contains(query)
|| t.album.to_lowercase().contains(query)
|| album_genre
.get(&t.album_id)
.map(|g| g.to_lowercase().contains(query))
.unwrap_or(false)
})
.take(100)
.cloned()
.collect();
let mut seen = std::collections::HashSet::new();
let result_albums: Vec<reader::Album> = albums
.iter()
.filter(|a| {
(a.title.to_lowercase().contains(query)
|| a.artist.to_lowercase().contains(query)
|| a.genre.to_lowercase().contains(query))
&& seen.insert(a.title.trim().to_lowercase())
})
.take(30)
.cloned()
.collect();
(result_tracks, result_albums)
}
pub(super) async fn mirror_created(
db: &Db,
source: &Source,
id: &str,
name: &str,
refs: &[String],
) -> Result<(), SourceError> {
db.upsert_playlist_meta(source, id, name, None, None)
.await?;
db.set_playlist_tracks(source, id, refs)
.await
.map_err(SourceError::from)
}
fn active_server_id(config: &AppConfig) -> Option<String> {
config
.active_source
.server_id()
.map(String::from)
.or_else(|| config.server.as_ref().and_then(|s| s.id.clone()))
}
fn remote_source(db: Db, source: Source, conn: &ServerConn) -> Box<dyn MediaSource> {
match conn.service {
MusicService::Jellyfin => Box::new(JellyfinSource::new(db, source, conn)),
MusicService::Subsonic | MusicService::Custom => {
Box::new(SubsonicSource::new(db, source, conn))
}
MusicService::YtMusic => Box::new(YtSource::new(db, source, conn)),
MusicService::SoundCloud => Box::new(SoundcloudSource::new(db, source, conn)),
}
}
pub type ActiveSource = std::sync::Arc<dyn MediaSource>;
#[async_trait]
pub trait TrackFavorite {
async fn is_favorite(&self, source: &ActiveSource) -> bool;
async fn set_favorite(&self, source: &ActiveSource, on: bool) -> Result<(), SourceError>;
}
#[async_trait]
impl TrackFavorite for reader::Track {
async fn is_favorite(&self, source: &ActiveSource) -> bool {
let key = self.id.key();
!key.trim().is_empty() && source.is_favorite(key.as_ref()).await
}
async fn set_favorite(&self, source: &ActiveSource, on: bool) -> Result<(), SourceError> {
let key = self.id.key();
if key.trim().is_empty() {
return Ok(());
}
source.set_favorite(key.as_ref(), on).await
}
}
pub fn configured_server(db: Db, config: &AppConfig) -> Option<Box<dyn MediaSource>> {
let conn = ServerConn::resolve(config)?;
let source = Source::Server(active_server_id(config).unwrap_or_default());
Some(remote_source(db, source, &conn))
}
pub fn local(db: Db) -> Box<dyn MediaSource> {
Box::new(LocalSource {
db,
source: Source::Local,
})
}
pub fn resolve(db: Db, config: &AppConfig, source: &Source) -> Box<dyn MediaSource> {
match source {
Source::Local => local(db),
Source::Server(id) => match ServerConn::resolve(config) {
Some(conn) => remote_source(db, Source::Server(id.clone()), &conn),
None => Box::new(OfflineServerSource {
db,
source: Source::Server(id.clone()),
}),
},
}
}
pub fn active(db: Db, config: &AppConfig) -> Box<dyn MediaSource> {
resolve(db, config, &config.active_source)
}