diff --git a/crates/qbz-jellyfin/src/lib.rs b/crates/qbz-jellyfin/src/lib.rs index bdd635cfb..fac58d273 100644 --- a/crates/qbz-jellyfin/src/lib.rs +++ b/crates/qbz-jellyfin/src/lib.rs @@ -182,6 +182,9 @@ pub struct JellyfinTrack { /// (21 %). Its absence does not mean the album has no image: Jellyfin's /// primary-image endpoint remains addressable by `album_id` without a tag. pub album_image_tag: Option, + /// MusicBrainz recording id from `ProviderIds.MusicBrainzTrack`, when the + /// server has one. Cross-source identity for the listen log. + pub recording_mbid: Option, /// This audio item's own primary-image tag. Jellyfin/Navidrome can expose /// a different embedded or folder cover for each disc while still grouping /// every row under one `MusicAlbum`; this must outrank the collection art. @@ -279,6 +282,11 @@ struct AudioDto { image_tags: HashMap, #[serde(default)] media_sources: Vec, + /// `ProviderIds` — Jellyfin's own name for external ids; for an audio + /// item `MusicBrainzTrack` is the RECORDING id (its scanner writes the + /// file's MUSICBRAINZ_TRACKID there). Requested via `Fields=ProviderIds`. + #[serde(default)] + provider_ids: HashMap, } #[derive(Deserialize)] @@ -399,6 +407,12 @@ impl AudioDto { album_image_tag: self.album_primary_image_tag, item_image_tag, server_path: self.path, + recording_mbid: self + .provider_ids + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("MusicBrainzTrack")) + .map(|(_, value)| value.trim().to_string()) + .filter(|value| !value.is_empty()), } } } @@ -726,13 +740,15 @@ fn tracks_page_path( let delta = min_date_last_saved .map(|date| format!("&minDateLastSaved={date}")) .unwrap_or_default(); + // ProviderIds on both: a handful of bytes per item, and it is the only + // cross-source identity a media server can hand us. let fields = if include_quality { - "Path,MediaSources,ParentId,ProductionYear,Genres" + "Path,MediaSources,ParentId,ProductionYear,Genres,ProviderIds" } else { // A server-side filesystem path is neither useful nor safe catalog // metadata on a client. The compatibility path keeps exposing it, but // the cheap persistent-library pass does not request or store it. - "ParentId,ProductionYear,Genres" + "ParentId,ProductionYear,Genres,ProviderIds" }; format!( "/Items?userId={user_id}&IncludeItemTypes=Audio&Recursive=true\ diff --git a/crates/qbz-media-cache/src/lib.rs b/crates/qbz-media-cache/src/lib.rs index 490bdca03..25cc8dea9 100644 --- a/crates/qbz-media-cache/src/lib.rs +++ b/crates/qbz-media-cache/src/lib.rs @@ -177,6 +177,11 @@ pub struct CachedTrack { pub sample_rate_hz: Option, pub channels: Option, pub bitrate_kbps: Option, + /// Cross-source identity when the server exposes it (Jellyfin + /// `ProviderIds.MusicBrainzTrack`, OpenSubsonic `musicBrainzId` / + /// `isrc`). `None` otherwise; never synthesised. + pub isrc: Option, + pub recording_mbid: Option, /// The RAW artwork token as the server issued it — a Jellyfin image tag, a /// Subsonic `coverArt` id. Never a URL: a URL embeds credentials and a /// size, and both change while the token does not. @@ -334,6 +339,10 @@ pub fn init_schema(conn: &Connection) -> Result<()> { "quality_hydrated INTEGER NOT NULL DEFAULT 0", "quality_retry_at INTEGER NOT NULL DEFAULT 0", "collection_artwork_token TEXT", + // Identity (2026-08-28): the server's MusicBrainz recording id and + // ISRC when it has them. NULL until the next sync re-reads the rows. + "isrc TEXT", + "recording_mbid TEXT", ] { let statement = format!("ALTER TABLE remote_cache_tracks ADD COLUMN {column}"); let _ = conn.execute(&statement, []); @@ -398,6 +407,10 @@ fn row_to_track(row: &rusqlite::Row<'_>) -> rusqlite::Result { .map(|v| v as u32), channels: row.get::<_, Option>("channels")?.map(|v| v as u32), bitrate_kbps: row.get::<_, Option>("bitrate_kbps")?.map(|v| v as u32), + isrc: row.get::<_, Option>("isrc")?.filter(|v| !v.is_empty()), + recording_mbid: row + .get::<_, Option>("recording_mbid")? + .filter(|v| !v.is_empty()), artwork_token: row.get("artwork_token")?, collection_artwork_token: row.get("collection_artwork_token")?, size_bytes: row.get::<_, Option>("size_bytes")?.map(|v| v as u64), @@ -407,7 +420,7 @@ fn row_to_track(row: &rusqlite::Row<'_>) -> rusqlite::Result { const SELECT: &str = "SELECT id, source, item_id, server_id, library_id, title, artist, \ album_artist, album, album_id, track_number, disc_number, duration_ms, year, genre, genres_json, \ container, codec, bit_depth, sample_rate_hz, channels, bitrate_kbps, artwork_token, \ - collection_artwork_token, size_bytes FROM remote_cache_tracks"; + collection_artwork_token, size_bytes, isrc, recording_mbid FROM remote_cache_tracks"; fn genres_json(track: &CachedTrack) -> String { let mut genres = Vec::::new(); @@ -449,9 +462,12 @@ pub fn save_tracks( (source, item_id, server_id, library_id, title, artist, album_artist, album, album_id, track_number, disc_number, duration_ms, year, genre, genres_json, container, codec, bit_depth, sample_rate_hz, channels, bitrate_kbps, artwork_token, - collection_artwork_token, size_bytes, quality_hydrated, quality_retry_at, updated_at) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,1,0,?25) + collection_artwork_token, size_bytes, quality_hydrated, quality_retry_at, updated_at, + isrc, recording_mbid) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,1,0,?25,?26,?27) ON CONFLICT(source, item_id) DO UPDATE SET + isrc=COALESCE(excluded.isrc, remote_cache_tracks.isrc), + recording_mbid=COALESCE(excluded.recording_mbid, remote_cache_tracks.recording_mbid), server_id=excluded.server_id, library_id=excluded.library_id, title=excluded.title, artist=excluded.artist, album_artist=excluded.album_artist, album=excluded.album, @@ -495,6 +511,8 @@ pub fn save_tracks( t.collection_artwork_token, t.size_bytes.map(|v| v as i64), ts, + t.isrc, + t.recording_mbid, ]) .map_err(map_err("insert track"))?; } @@ -617,10 +635,13 @@ fn save_source_generation_page( (source,item_id,server_id,library_id,title,artist,album_artist,album, album_id,track_number,disc_number,duration_ms,year,genre,genres_json,container, codec,bit_depth,sample_rate_hz,channels,bitrate_kbps,artwork_token, - collection_artwork_token,size_bytes,observed_generation,quality_hydrated,quality_retry_at,updated_at) + collection_artwork_token,size_bytes,observed_generation,quality_hydrated,quality_retry_at,updated_at, + isrc,recording_mbid) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15, - ?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,0,?27) + ?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,0,?27,?28,?29) ON CONFLICT(source,item_id) DO UPDATE SET + isrc=COALESCE(excluded.isrc,remote_cache_tracks.isrc), + recording_mbid=COALESCE(excluded.recording_mbid,remote_cache_tracks.recording_mbid), server_id=excluded.server_id,library_id=excluded.library_id, title=excluded.title,artist=excluded.artist, album_artist=excluded.album_artist,album=excluded.album, @@ -682,6 +703,8 @@ fn save_source_generation_page( generation, i64::from(complete_quality), revision, + track.isrc, + track.recording_mbid, ]) .map_err(map_err("write essential track"))?; } diff --git a/crates/qbz-qt/src/media_sync_qt.rs b/crates/qbz-qt/src/media_sync_qt.rs index 6041677b7..6d60dfdf3 100644 --- a/crates/qbz-qt/src/media_sync_qt.rs +++ b/crates/qbz-qt/src/media_sync_qt.rs @@ -451,6 +451,8 @@ fn jellyfin_row(t: &qbz_jellyfin::JellyfinTrack, server_id: &str, library_id: &s bitrate_kbps: t.bitrate_bps.map(|b| b / 1000), artwork_token, collection_artwork_token, + isrc: None, + recording_mbid: t.recording_mbid.clone(), size_bytes: None, } } @@ -983,6 +985,8 @@ fn subsonic_row( // The OPAQUE coverArt id, verbatim. Never parsed, never built. artwork_token: t.cover_art.clone(), collection_artwork_token: collection_artwork_token.map(str::to_string), + isrc: t.isrc.clone(), + recording_mbid: t.recording_mbid.clone(), size_bytes: t.size, } } @@ -1240,6 +1244,7 @@ mod tests { bitrate_bps: None, album_image_tag: Some("tag".into()), item_image_tag: None, + recording_mbid: None, server_path: None, }; let row = jellyfin_row(&t, "srv", "lib"); @@ -1288,6 +1293,7 @@ mod tests { bitrate_bps: Some(3_120_281), album_image_tag: None, item_image_tag: None, + recording_mbid: None, server_path: None, }; assert_eq!(jellyfin_row(&jf, "s", "l").bitrate_kbps, Some(3120)); diff --git a/crates/qbz-source/src/sources/remote.rs b/crates/qbz-source/src/sources/remote.rs index da704861c..4107a0d5e 100644 --- a/crates/qbz-source/src/sources/remote.rs +++ b/crates/qbz-source/src/sources/remote.rs @@ -174,6 +174,9 @@ pub(crate) fn cached_to_queue_track(t: &CachedTrack, source: SourceId) -> QueueT source_item_id_hint: Some(t.item_id.clone()), context_kind: None, context_id: None, + // Whatever the server exposed (Jellyfin ProviderIds / OpenSubsonic). + isrc: t.isrc.clone(), + recording_mbid: t.recording_mbid.clone(), } } diff --git a/crates/qbz-source/tests/live_remote.rs b/crates/qbz-source/tests/live_remote.rs index 6c767c76a..4fd2fdf51 100644 --- a/crates/qbz-source/tests/live_remote.rs +++ b/crates/qbz-source/tests/live_remote.rs @@ -67,6 +67,8 @@ fn queue_track(id: u64, hint: &str, source: &str) -> qbz_models::QueueTrack { source_item_id_hint: (!hint.is_empty()).then(|| hint.to_string()), context_kind: None, context_id: None, + isrc: None, + recording_mbid: None, } } @@ -182,6 +184,8 @@ async fn jellyfin_walks_from_a_sweep_to_real_audio() { t.album_image_tag.as_deref().unwrap_or_default() ) }), + isrc: None, + recording_mbid: None, size_bytes: None, }) .collect(); @@ -349,6 +353,8 @@ async fn subsonic_walks_from_a_sweep_to_real_audio() { // The OPAQUE coverArt id, verbatim. artwork_token: t.cover_art.clone(), collection_artwork_token: None, + isrc: None, + recording_mbid: None, size_bytes: t.size, }) .collect(); diff --git a/crates/qbz-subsonic/src/lib.rs b/crates/qbz-subsonic/src/lib.rs index 1790af968..222d9785b 100644 --- a/crates/qbz-subsonic/src/lib.rs +++ b/crates/qbz-subsonic/src/lib.rs @@ -379,6 +379,10 @@ pub struct SubsonicTrack { /// File size in bytes, as reported. Useful as a sanity check against a /// stream's `Content-Length`. pub size: Option, + /// OpenSubsonic `musicBrainzId` (recording) and the first `isrc`, when + /// the server exposes them. Cross-source identity for the listen log. + pub recording_mbid: Option, + pub isrc: Option, } #[derive(Deserialize)] @@ -421,6 +425,12 @@ struct SongDto { cover_art: Option, #[serde(default)] size: Option, + /// OpenSubsonic: the MusicBrainz RECORDING id of the song. + #[serde(default, rename = "musicBrainzId")] + music_brainz_id: Option, + /// OpenSubsonic: ISRCs, a list (a recording can carry several). + #[serde(default)] + isrc: Vec, } impl From for SubsonicTrack { @@ -454,6 +464,15 @@ impl From for SubsonicTrack { channels: s.channel_count.filter(|c| *c > 0), bitrate_kbps: s.bit_rate.filter(|b| *b > 0), cover_art: s.cover_art.filter(|c| !c.is_empty()), + recording_mbid: s + .music_brainz_id + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()), + isrc: s + .isrc + .into_iter() + .map(|v| v.trim().to_string()) + .find(|v| !v.is_empty()), size: s.size, } }