use serde::{Deserialize, Serialize}; /// Authoritative origin of a projected row. Wire values are deliberately the /// same words the existing source/cache layers use. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SourceKind { Local, Offline, Plex, Jellyfin, Subsonic, } impl SourceKind { pub const ALL: [Self; 5] = [ Self::Local, Self::Offline, Self::Plex, Self::Jellyfin, Self::Subsonic, ]; pub const fn as_str(self) -> &'static str { match self { Self::Local => "local", Self::Offline => "offline", Self::Plex => "plex", Self::Jellyfin => "jellyfin", Self::Subsonic => "subsonic", } } pub fn from_str(value: &str) -> Option { match value { "local" => Some(Self::Local), "offline" => Some(Self::Offline), "plex" => Some(Self::Plex), "jellyfin" => Some(Self::Jellyfin), "subsonic" => Some(Self::Subsonic), _ => None, } } } /// Stable product identity. The catalog's integer rowid never leaves through /// this type; playback and user data continue to use source-native identity. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct TrackRef { pub source: SourceKind, pub source_instance: String, pub native_id: String, } #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct SourceKey { pub source: SourceKind, pub source_instance: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CreditRole { TrackArtist, AlbumArtist, Composer, Performer, Featured, } impl CreditRole { pub const fn as_str(self) -> &'static str { match self { Self::TrackArtist => "track_artist", Self::AlbumArtist => "album_artist", Self::Composer => "composer", Self::Performer => "performer", Self::Featured => "featured", } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ArtistCredit { pub display_name: String, pub role: CreditRole, pub ordinal: u32, } /// Source-neutral write shape. C/H adapters translate authoritative rows into /// this type; the catalog itself never calls a source API. #[derive(Debug, Clone)] pub struct ProjectedTrack { pub track_ref: TrackRef, /// Raw presentation provenance from the authoritative row. Local cache /// rows may be `qobuz_download`/`qobuz_purchase` while their stable /// ownership remains [`SourceKind::Local`]. pub source_raw: String, pub local_track_id: Option, pub local_path: Option, /// Stable album id from the same authoritative source when available. /// Text-only caches may leave it empty; bootstrap then records a weak, /// reversible per-source fallback association. pub native_album_id: Option, pub source_copy_id: Option, pub title: String, pub artist: String, pub album_artist: String, pub album: String, pub duration_ms: u64, pub year: Option, pub disc_number: Option, pub track_number: Option, pub format: String, pub bit_depth: Option, pub sample_rate_hz: Option, pub artwork_token: Option, pub isrc: Option, pub musicbrainz_recording_id: Option, pub added_at: i64, pub available: bool, pub observed_generation: i64, pub credits: Vec, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum TrackSort { #[default] Default, TitleAsc, TitleDesc, ArtistAsc, ArtistDesc, YearAsc, YearDesc, AddedDesc, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TrackGroup { #[default] Off, Album, Artist, Name, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum QuerySurface { Tracks, Albums, Artists, } /// Immutable query snapshot. Builders consume and return a new descriptor so /// an async request cannot observe later UI mutations through shared state. #[derive(Debug, Clone, PartialEq, Eq)] pub struct QueryDescriptor { surface: QuerySurface, search: String, sources: Vec, source_buckets: Vec, formats: Vec, other_formats: bool, quality_tiers: Vec, sort: TrackSort, group: TrackGroup, available_only: bool, } impl QueryDescriptor { pub fn tracks() -> Self { Self::for_surface(QuerySurface::Tracks) } pub fn albums() -> Self { Self::for_surface(QuerySurface::Albums) } pub fn artists() -> Self { Self::for_surface(QuerySurface::Artists) } pub fn for_surface(surface: QuerySurface) -> Self { Self { surface, search: String::new(), sources: Vec::new(), source_buckets: Vec::new(), formats: Vec::new(), other_formats: false, quality_tiers: Vec::new(), sort: TrackSort::Default, group: TrackGroup::Off, available_only: true, } } pub fn with_search(mut self, search: impl Into) -> Self { self.search = search.into().trim().to_string(); self } pub fn with_sources(mut self, sources: Vec) -> Self { self.sources = sources; self.sources.sort(); self.sources.dedup(); self } pub fn with_formats(mut self, formats: Vec) -> Self { self.formats = formats .into_iter() .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| !value.is_empty()) .collect(); self.formats.sort(); self.formats.dedup(); self } pub fn including_other_formats(mut self, include: bool) -> Self { self.other_formats = include; self } pub fn with_source_buckets(mut self, buckets: Vec) -> Self { self.source_buckets = buckets .into_iter() .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| !value.is_empty()) .collect(); self.source_buckets.sort(); self.source_buckets.dedup(); self } pub fn with_quality_tiers(mut self, tiers: Vec) -> Self { self.quality_tiers = tiers .into_iter() .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| !value.is_empty()) .collect(); self.quality_tiers.sort(); self.quality_tiers.dedup(); self } pub fn with_sort(mut self, sort: TrackSort) -> Self { self.sort = sort; self } pub fn with_group(mut self, group: TrackGroup) -> Self { self.group = group; self } pub fn including_unavailable(mut self) -> Self { self.available_only = false; self } pub fn surface(&self) -> QuerySurface { self.surface } pub fn search(&self) -> &str { &self.search } pub fn sources(&self) -> &[SourceKey] { &self.sources } pub fn source_buckets(&self) -> &[String] { &self.source_buckets } pub fn formats(&self) -> &[String] { &self.formats } pub fn other_formats(&self) -> bool { self.other_formats } pub fn quality_tiers(&self) -> &[String] { &self.quality_tiers } pub fn sort(&self) -> TrackSort { self.sort } pub fn group(&self) -> TrackGroup { self.group } pub fn available_only(&self) -> bool { self.available_only } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrackRecord { pub track_ref: TrackRef, pub source_raw: String, pub local_track_id: Option, pub local_path: Option, pub native_album_id: Option, pub title: String, pub artist: String, pub album_artist: String, pub album: String, pub duration_ms: u64, pub year: Option, pub disc_number: Option, pub track_number: Option, pub format: String, pub bit_depth: Option, pub sample_rate_hz: Option, pub artwork_token: Option, pub available: bool, } /// Opaque keyset cursor. It carries the exact normalized ORDER BY values plus /// the catalog rowid tie-breaker, but exposes none as product identity. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrackCursor { pub(crate) sort: TrackSort, pub(crate) descriptor_key: String, pub(crate) sort_title: String, pub(crate) sort_artist: String, pub(crate) sort_track_artist: String, pub(crate) sort_album: String, pub(crate) year_missing: i64, pub(crate) year_value: i64, pub(crate) disc_sort: i64, pub(crate) track_sort: i64, pub(crate) added_at: i64, pub(crate) row_id: i64, } impl TrackCursor { /// Normalized grouping key of the row immediately before a keyset page. /// This lets a paged frontend decide whether the first resident row starts /// a new global group without issuing an overlap query. pub fn group_key(&self, group: TrackGroup) -> String { match group { TrackGroup::Off => String::new(), TrackGroup::Album => self.sort_album.clone(), TrackGroup::Artist => self.sort_track_artist.clone(), TrackGroup::Name => self .sort_title .chars() .next() .unwrap_or('#') .to_uppercase() .collect(), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrackPage { pub rows: Vec, pub next_cursor: Option, pub has_more: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AlbumRecord { pub edition_id: i64, pub source: SourceKind, pub native_album_id: String, pub source_raw: String, pub title: String, pub artist: String, pub all_artists: String, pub year: Option, pub track_count: u32, pub total_duration_ms: u64, pub quality_tier: String, pub format: String, pub bit_depth: Option, pub sample_rate_hz: Option, pub artwork_source: String, pub artwork_token: String, pub directory_path: String, pub folder_count: u32, pub added_at: i64, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AlbumCursor { pub(crate) descriptor_key: String, pub(crate) sort_title: String, pub(crate) sort_artist: String, pub(crate) year_missing: i64, pub(crate) year_value: i64, pub(crate) added_at: i64, pub(crate) edition_id: i64, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AlbumPage { pub rows: Vec, pub next_cursor: Option, pub has_more: bool, }