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#[derive(Debug, Clone, PartialEq)]
21pub enum ArtistImageRef {
22 Local(PathBuf),
24 Remote(String),
26}
27
28#[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 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 pub fn local_path(&self) -> Option<&Path> {
52 match self {
53 TrackId::Local(p) => Some(p),
54 TrackId::Server { .. } => None,
55 }
56 }
57
58 pub fn service(&self) -> Option<config::MusicService> {
60 match self {
61 TrackId::Server { service, .. } => Some(*service),
62 TrackId::Local(_) => None,
63 }
64 }
65
66 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 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 #[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#[derive(Debug, Clone, Default, PartialEq)]
141pub enum CoverChange {
142 #[default]
144 Keep,
145 Remove,
147 Set(Vec<u8>),
149}
150
151#[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 #[serde(default)]
185 pub last_yt_sync_at: Option<u64>,
186 #[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 #[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#[derive(Debug, Clone, PartialEq)]
284pub struct Playlist {
285 pub id: String,
286 pub name: String,
287 pub tracks: Vec<String>,
288 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#[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}