Skip to main content

reader/
models.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5pub struct Album {
6    pub id: String,
7    pub title: String,
8    pub artist: String,
9    pub genre: String,
10    pub year: u16,
11    pub cover_path: Option<PathBuf>,
12    #[serde(default)]
13    pub manual_cover: bool,
14}
15
16/// A source-agnostic artist photo reference: a local file path or a remote URL.
17/// Resolved to a `CoverUrl` by the cover seam (`server::cover::artist`), so the
18/// UI never branches on where the image lives. A custom user override is handled
19/// separately (it's a priority concern, not a source one).
20#[derive(Debug, Clone, PartialEq)]
21pub enum ArtistImageRef {
22    /// A local filesystem path (from the local scan).
23    Local(PathBuf),
24    /// A remote URL (from a server sync).
25    Remote(String),
26}
27
28/// Typed track identity — replaces the old `Track.path` synthetic-string hack.
29/// Local tracks are a filesystem path; server tracks are a service + item id.
30/// The cover reference is a separate `Track.cover` field, NOT part of identity.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
32pub enum TrackId {
33    Local(PathBuf),
34    Server {
35        service: config::MusicService,
36        item_id: String,
37    },
38}
39
40impl TrackId {
41    /// The bare key within its source — the file-path string (local) or the
42    /// item/video id (server). This is the DB `track_key`.
43    pub fn key(&self) -> std::borrow::Cow<'_, str> {
44        match self {
45            TrackId::Local(p) => p.to_string_lossy(),
46            TrackId::Server { item_id, .. } => std::borrow::Cow::Borrowed(item_id),
47        }
48    }
49
50    /// The filesystem path, if this is a local track.
51    pub fn local_path(&self) -> Option<&Path> {
52        match self {
53            TrackId::Local(p) => Some(p),
54            TrackId::Server { .. } => None,
55        }
56    }
57
58    /// The media service, if this is a server track.
59    pub fn service(&self) -> Option<config::MusicService> {
60        match self {
61            TrackId::Server { service, .. } => Some(*service),
62            TrackId::Local(_) => None,
63        }
64    }
65
66    /// A stable, source-qualified identity string (no cover): the file path for
67    /// local, or `"<service-prefix>:<item_id>"` for server. For logging /
68    /// cross-source string keys.
69    pub fn uid(&self) -> String {
70        match self {
71            TrackId::Local(p) => p.to_string_lossy().into_owned(),
72            TrackId::Server { service, item_id } => {
73                format!("{}:{}", service_prefix(*service), item_id)
74            }
75        }
76    }
77
78    /// Parse a legacy `Track.path` string (`"service:id[:cover]"` or a real
79    /// path). Used ONLY by the migration importer; the 3rd cover segment is
80    /// dropped here (the importer sets `Track.cover` separately).
81    pub fn from_legacy_path(s: &str) -> Self {
82        for (prefix, svc) in [
83            ("ytmusic", config::MusicService::YtMusic),
84            ("jellyfin", config::MusicService::Jellyfin),
85            ("subsonic", config::MusicService::Subsonic),
86            ("custom", config::MusicService::Custom),
87            ("soundcloud", config::MusicService::SoundCloud),
88        ] {
89            if let Some(rest) = s.strip_prefix(prefix).and_then(|r| r.strip_prefix(':')) {
90                let item_id = rest.split(':').next().unwrap_or("").to_string();
91                return TrackId::Server {
92                    service: svc,
93                    item_id,
94                };
95            }
96        }
97        TrackId::Local(PathBuf::from(s))
98    }
99}
100
101fn service_prefix(s: config::MusicService) -> &'static str {
102    match s {
103        config::MusicService::YtMusic => "ytmusic",
104        config::MusicService::Jellyfin => "jellyfin",
105        config::MusicService::Subsonic => "subsonic",
106        config::MusicService::Custom => "custom",
107        config::MusicService::SoundCloud => "soundcloud",
108    }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
112pub struct Track {
113    pub id: TrackId,
114    /// Cover art reference (URL for server, path for local) — out of identity.
115    #[serde(default)]
116    pub cover: Option<String>,
117    pub album_id: String,
118    pub title: String,
119    pub artist: String,
120    pub album: String,
121    pub duration: u64,
122    pub khz: u32,
123    #[serde(default)]
124    pub bitrate: u16,
125    pub track_number: Option<u32>,
126    pub disc_number: Option<u32>,
127    #[serde(default)]
128    pub musicbrainz_release_id: Option<String>,
129    #[serde(default)]
130    pub musicbrainz_recording_id: Option<String>,
131    #[serde(default)]
132    pub musicbrainz_track_id: Option<String>,
133    #[serde(default)]
134    pub playlist_item_id: Option<String>,
135    #[serde(default)]
136    pub artists: Vec<String>,
137}
138
139/// What to do with the track's embedded front-cover picture on save.
140#[derive(Debug, Clone, Default, PartialEq)]
141pub enum CoverChange {
142    /// Leave the existing picture untouched.
143    #[default]
144    Keep,
145    /// Strip the front-cover picture from the file.
146    Remove,
147    /// Replace the front cover with these image bytes (format auto-detected).
148    Set(Vec<u8>),
149}
150
151/// User-supplied edits to a track's tags. Empty strings / `None` mean
152/// "remove this tag from the file". Produced by the metadata editor UI and
153/// consumed by [`crate::metadata::write_tags`].
154#[derive(Debug, Clone, Default, PartialEq)]
155pub struct TrackEdits {
156    pub title: String,
157    pub artist: String,
158    pub album: String,
159    pub track_number: Option<u32>,
160    pub disc_number: Option<u32>,
161    pub cover: CoverChange,
162}
163
164#[derive(Debug, Serialize, Deserialize, Default, Clone)]
165pub struct Library {
166    #[serde(
167        default,
168        alias = "root_path",
169        deserialize_with = "deserialize_root_paths"
170    )]
171    pub root_paths: Vec<PathBuf>,
172    pub tracks: Vec<Track>,
173    pub albums: Vec<Album>,
174    #[serde(default)]
175    pub jellyfin_tracks: Vec<Track>,
176    #[serde(default)]
177    pub jellyfin_albums: Vec<Album>,
178    #[serde(default)]
179    pub jellyfin_genres: Vec<(String, String)>,
180    /// Unix timestamp (seconds) of the last successful YT library sync.
181    /// `None` means "never synced" → the Favorites page kicks off an
182    /// initial fetch on next mount. Cleared by the manual refresh
183    /// button to force a re-fetch.
184    #[serde(default)]
185    pub last_yt_sync_at: Option<u64>,
186    /// Companion to `last_yt_sync_at` for the YT playlists list.
187    /// Tracked separately because the favorites page and the playlists
188    /// page are independent — one synced doesn't imply the other.
189    #[serde(default)]
190    pub last_yt_playlists_sync_at: Option<u64>,
191    #[serde(default)]
192    pub server_artist_images: std::collections::HashMap<String, String>,
193    #[serde(default)]
194    pub local_artist_images: std::collections::HashMap<String, PathBuf>,
195    /// User-set custom artist photos, keyed by normalized (trim+lowercase) artist name.
196    /// Overrides both local_artist_images and server_artist_images when present.
197    #[serde(default)]
198    pub custom_artist_images: std::collections::HashMap<String, PathBuf>,
199}
200
201fn deserialize_root_paths<'de, D>(deserializer: D) -> Result<Vec<PathBuf>, D::Error>
202where
203    D: Deserializer<'de>,
204{
205    #[derive(Deserialize)]
206    #[serde(untagged)]
207    enum OneOrMany {
208        One(PathBuf),
209        Many(Vec<PathBuf>),
210    }
211    match OneOrMany::deserialize(deserializer)? {
212        OneOrMany::One(p) => Ok(vec![p]),
213        OneOrMany::Many(v) => Ok(v),
214    }
215}
216
217impl Library {
218    pub fn new(root_paths: Vec<PathBuf>) -> Self {
219        Self {
220            root_paths,
221            ..Default::default()
222        }
223    }
224
225    pub fn add_track(&mut self, track: Track) {
226        if let Some(index) = self.tracks.iter().position(|t| t.id == track.id) {
227            self.tracks[index] = track;
228        } else {
229            self.tracks.push(track);
230        }
231    }
232
233    pub fn add_album(&mut self, album: Album) {
234        if let Some(index) = self.albums.iter().position(|a| a.id == album.id) {
235            let mut new_album = album;
236            let existing = &self.albums[index];
237            if new_album.cover_path.is_none() || existing.manual_cover {
238                new_album.cover_path = existing.cover_path.clone();
239            }
240            if existing.manual_cover {
241                new_album.manual_cover = true;
242            }
243            self.albums[index] = new_album;
244        } else {
245            self.albums.push(album);
246        }
247    }
248
249    pub fn remove_track(&mut self, id: &TrackId) {
250        self.tracks.retain(|t| &t.id != id);
251    }
252
253    pub fn remove_album(&mut self, album_id: &str) {
254        self.albums.retain(|a| a.id != album_id);
255        self.tracks.retain(|t| t.album_id != album_id);
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::Library;
262    use std::path::PathBuf;
263
264    #[test]
265    fn library_deserializes_legacy_root_path() {
266        let json = r#"{
267            "root_path": "/music",
268            "tracks": [],
269            "albums": []
270        }"#;
271
272        let library: Library = serde_json::from_str(json).unwrap();
273
274        assert_eq!(library.root_paths, vec![PathBuf::from("/music")]);
275    }
276}
277
278/// One playlist. `tracks` are opaque refs — a filesystem path string for local
279/// playlists, an item/video id for a server. Which source these belong to is
280/// context (the active source the store was loaded for), not per-row state, so
281/// there's no source field and no local/server type split. The path↔file
282/// conversion happens only at the player's resolve boundary, not here.
283#[derive(Debug, Clone, PartialEq)]
284pub struct Playlist {
285    pub id: String,
286    pub name: String,
287    pub tracks: Vec<String>,
288    /// Server cover-version tag (server playlists only; `None` for local).
289    pub image_tag: Option<String>,
290    pub cover_path: Option<PathBuf>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
294pub struct PlaylistFolder {
295    pub id: String,
296    pub name: String,
297    pub playlist_ids: Vec<String>,
298}
299
300/// The in-memory playlist read model for the active source (built by the DB
301/// layer, never serialized). One uniform list — local vs server is the active
302/// source context, not a per-row split.
303#[derive(Debug, Clone, PartialEq, Default)]
304pub struct PlaylistStore {
305    pub playlists: Vec<Playlist>,
306    pub folders: Vec<PlaylistFolder>,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
310pub struct FavoritesStore {
311    #[serde(default)]
312    pub local_favorites: Vec<PathBuf>,
313    #[serde(default)]
314    pub jellyfin_favorites: Vec<String>,
315}
316
317impl FavoritesStore {
318    pub fn is_local_favorite(&self, path: &Path) -> bool {
319        self.local_favorites.iter().any(|p| p == path)
320    }
321
322    pub fn is_jellyfin_favorite(&self, id: &str) -> bool {
323        self.jellyfin_favorites.iter().any(|i| i == id)
324    }
325
326    pub fn toggle_local(&mut self, path: PathBuf) -> bool {
327        if let Some(pos) = self.local_favorites.iter().position(|p| p == &path) {
328            self.local_favorites.remove(pos);
329            false
330        } else {
331            self.local_favorites.push(path);
332            true
333        }
334    }
335
336    pub fn set_jellyfin(&mut self, id: String, is_fav: bool) {
337        if is_fav {
338            if !self.jellyfin_favorites.contains(&id) {
339                self.jellyfin_favorites.push(id);
340            }
341        } else {
342            self.jellyfin_favorites.retain(|i| i != &id);
343        }
344    }
345}