Skip to main content

plexus_mono/
playlist.rs

1//! PlaylistHub — persistent playlist management for plexus-mono
2//!
3//! Stores playlists as JSON files under `~/.plexus/monochrome/player/playlists/`.
4//! Registered as a child activation of MonoHub via ChildRouter.
5
6use async_stream::stream;
7use async_trait::async_trait;
8use futures::{self, Stream};
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use plexus_core::plexus::{ChildRouter, PlexusError, PlexusStream};
14use plexus_core::Activation;
15
16use crate::client::MonoClient;
17use crate::player::Player;
18use crate::types::{MonoEvent, QueuedTrack};
19
20/// On-disk playlist format
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PlaylistData {
23    pub name: String,
24    #[serde(default)]
25    pub description: String,
26    pub tracks: Vec<QueuedTrack>,
27    pub created_at: String,
28    pub updated_at: String,
29}
30
31/// Persistent playlist management activation
32#[derive(Clone)]
33pub struct PlaylistHub {
34    player: Arc<Player>,
35    client: Arc<MonoClient>,
36    data_dir: PathBuf,
37}
38
39impl PlaylistHub {
40    pub fn new(player: Arc<Player>, client: Arc<MonoClient>) -> Self {
41        let data_dir = dirs::home_dir()
42            .unwrap_or_else(|| PathBuf::from("."))
43            .join(".plexus/monochrome/player/playlists");
44        Self {
45            player,
46            client,
47            data_dir,
48        }
49    }
50
51    fn playlist_path(&self, name: &str) -> PathBuf {
52        self.data_dir.join(format!("{name}.json"))
53    }
54
55    fn ensure_dir(&self) -> Result<(), String> {
56        std::fs::create_dir_all(&self.data_dir)
57            .map_err(|e| format!("failed to create playlist dir: {e}"))
58    }
59
60    fn load(&self, name: &str) -> Result<PlaylistData, String> {
61        let path = self.playlist_path(name);
62        let data = std::fs::read_to_string(&path)
63            .map_err(|e| format!("playlist '{name}' not found: {e}"))?;
64        serde_json::from_str(&data)
65            .map_err(|e| format!("failed to parse playlist '{name}': {e}"))
66    }
67
68    fn write_playlist(&self, data: &PlaylistData) -> Result<(), String> {
69        self.ensure_dir()?;
70        let path = self.playlist_path(&data.name);
71        let json = serde_json::to_string_pretty(data)
72            .map_err(|e| format!("failed to serialize playlist: {e}"))?;
73        std::fs::write(&path, json)
74            .map_err(|e| format!("failed to write playlist: {e}"))
75    }
76
77    fn now_iso() -> String {
78        chrono::Utc::now().to_rfc3339()
79    }
80}
81
82#[plexus_macros::hub_methods(
83    namespace = "playlist",
84    version = "0.1.0",
85    description = "Persistent playlist management — save, load, and play named track lists",
86    crate_path = "plexus_core"
87)]
88impl PlaylistHub {
89    /// Create an empty or pre-populated playlist
90    #[plexus_macros::hub_method(
91        streaming,
92        description = "Create a new playlist. Pass track IDs to pre-populate, or omit for empty.",
93        params(
94            name = "Playlist name",
95            description = "Optional description of the playlist",
96            ids = "Optional list of Tidal track IDs to populate the playlist with",
97            quality = "Quality tier for track metadata (default LOSSLESS)"
98        )
99    )]
100    pub async fn create(
101        &self,
102        name: String,
103        description: Option<String>,
104        ids: Option<Vec<u64>>,
105        quality: Option<String>,
106    ) -> impl Stream<Item = MonoEvent> + Send + 'static {
107        let hub = self.clone();
108        let quality = quality.unwrap_or_else(|| "LOSSLESS".into());
109        stream! {
110            if hub.playlist_path(&name).exists() {
111                yield MonoEvent::Error { message: format!("playlist '{name}' already exists") };
112                return;
113            }
114            let tracks = if let Some(ids) = ids {
115                // Resolve all track metadata in parallel
116                let futs: Vec<_> = ids.iter().map(|&id| {
117                    let client = hub.client.clone();
118                    let q = quality.clone();
119                    async move {
120                        let info = client.track_info(id).await.ok();
121                        match info {
122                            Some(MonoEvent::Track { title, artist, album, duration_secs, cover_id, .. }) => {
123                                QueuedTrack { id, title, artist, album, duration_secs, quality: q, cover_id }
124                            }
125                            _ => QueuedTrack {
126                                id, title: format!("Track {id}"), artist: String::new(),
127                                album: String::new(), duration_secs: 0, quality: q, cover_id: None,
128                            },
129                        }
130                    }
131                }).collect();
132                futures::future::join_all(futs).await
133            } else {
134                vec![]
135            };
136            let count = tracks.len();
137            let description = description.unwrap_or_default();
138            let data = PlaylistData {
139                name: name.clone(),
140                description,
141                tracks,
142                created_at: Self::now_iso(),
143                updated_at: Self::now_iso(),
144            };
145            match hub.write_playlist(&data) {
146                Ok(()) => yield MonoEvent::PlayerAck {
147                    action: "playlist_create".into(),
148                    message: if count > 0 {
149                        format!("created playlist '{name}' with {count} tracks")
150                    } else {
151                        format!("created playlist '{name}'")
152                    },
153                },
154                Err(e) => yield MonoEvent::Error { message: e },
155            }
156        }
157    }
158
159    /// List all saved playlists
160    #[plexus_macros::hub_method(
161        streaming,
162        description = "List all saved playlists with summary info"
163    )]
164    pub async fn list(&self) -> impl Stream<Item = MonoEvent> + Send + 'static {
165        let hub = self.clone();
166        stream! {
167            if let Err(e) = hub.ensure_dir() {
168                yield MonoEvent::Error { message: e };
169                return;
170            }
171            let entries = match std::fs::read_dir(&hub.data_dir) {
172                Ok(e) => e,
173                Err(e) => {
174                    yield MonoEvent::Error { message: format!("failed to read playlist dir: {e}") };
175                    return;
176                }
177            };
178            let mut found = false;
179            for entry in entries.flatten() {
180                let path = entry.path();
181                if path.extension().is_some_and(|e| e == "json") {
182                    if let Ok(data) = std::fs::read_to_string(&path)
183                        .ok()
184                        .and_then(|s| serde_json::from_str::<PlaylistData>(&s).ok())
185                        .ok_or(())
186                    {
187                        found = true;
188                        yield MonoEvent::PlaylistInfo {
189                            name: data.name,
190                            description: data.description,
191                            track_count: data.tracks.len(),
192                            created_at: data.created_at,
193                            updated_at: data.updated_at,
194                        };
195                    }
196                }
197            }
198            if !found {
199                yield MonoEvent::PlayerAck {
200                    action: "playlist_list".into(),
201                    message: "no playlists found".into(),
202                };
203            }
204        }
205    }
206
207    /// Get full playlist info (metadata + tracks) — suitable for UI rendering
208    #[plexus_macros::hub_method(
209        streaming,
210        description = "Get full playlist details: name, description, track count, timestamps, then all tracks",
211        params(name = "Playlist name")
212    )]
213    pub async fn show(
214        &self,
215        name: String,
216    ) -> impl Stream<Item = MonoEvent> + Send + 'static {
217        let hub = self.clone();
218        stream! {
219            match hub.load(&name) {
220                Ok(data) => {
221                    yield MonoEvent::PlaylistInfo {
222                        name: data.name,
223                        description: data.description,
224                        track_count: data.tracks.len(),
225                        created_at: data.created_at,
226                        updated_at: data.updated_at,
227                    };
228                    yield MonoEvent::Queue {
229                        tracks: data.tracks,
230                        current_index: None,
231                    };
232                }
233                Err(e) => yield MonoEvent::Error { message: e },
234            }
235        }
236    }
237
238    /// Delete a playlist
239    #[plexus_macros::hub_method(
240        description = "Delete a saved playlist",
241        params(name = "Playlist name")
242    )]
243    pub async fn delete(
244        &self,
245        name: String,
246    ) -> impl Stream<Item = MonoEvent> + Send + 'static {
247        let hub = self.clone();
248        stream! {
249            let path = hub.playlist_path(&name);
250            match std::fs::remove_file(&path) {
251                Ok(()) => yield MonoEvent::PlayerAck {
252                    action: "playlist_delete".into(),
253                    message: format!("deleted playlist '{name}'"),
254                },
255                Err(e) => yield MonoEvent::Error {
256                    message: format!("failed to delete playlist '{name}': {e}"),
257                },
258            }
259        }
260    }
261
262    /// Rename a playlist
263    #[plexus_macros::hub_method(
264        description = "Rename a playlist",
265        params(
266            name = "Current playlist name",
267            new_name = "New playlist name"
268        )
269    )]
270    pub async fn rename(
271        &self,
272        name: String,
273        new_name: String,
274    ) -> impl Stream<Item = MonoEvent> + Send + 'static {
275        let hub = self.clone();
276        stream! {
277            match hub.load(&name) {
278                Ok(mut data) => {
279                    if hub.playlist_path(&new_name).exists() {
280                        yield MonoEvent::Error {
281                            message: format!("playlist '{new_name}' already exists"),
282                        };
283                        return;
284                    }
285                    // Remove old file
286                    let _ = std::fs::remove_file(hub.playlist_path(&name));
287                    data.name = new_name.clone();
288                    data.updated_at = Self::now_iso();
289                    match hub.write_playlist(&data) {
290                        Ok(()) => yield MonoEvent::PlayerAck {
291                            action: "playlist_rename".into(),
292                            message: format!("renamed '{name}' to '{new_name}'"),
293                        },
294                        Err(e) => yield MonoEvent::Error { message: e },
295                    }
296                }
297                Err(e) => yield MonoEvent::Error { message: e },
298            }
299        }
300    }
301
302    /// Add a track to a playlist by ID
303    #[plexus_macros::hub_method(
304        description = "Fetch track info and append to a playlist",
305        params(
306            name = "Playlist name",
307            id = "Tidal track ID",
308            quality = "Quality tier (default LOSSLESS)"
309        )
310    )]
311    pub async fn add(
312        &self,
313        name: String,
314        id: u64,
315        quality: Option<String>,
316    ) -> impl Stream<Item = MonoEvent> + Send + 'static {
317        let hub = self.clone();
318        let quality = quality.unwrap_or_else(|| "LOSSLESS".into());
319        stream! {
320            let mut data = match hub.load(&name) {
321                Ok(d) => d,
322                Err(e) => { yield MonoEvent::Error { message: e }; return; }
323            };
324            // Fetch track metadata
325            let track_info = hub.client.track_info(id).await.ok();
326            let queued = match track_info {
327                Some(MonoEvent::Track { title, artist, album, duration_secs, cover_id, .. }) => {
328                    QueuedTrack { id, title, artist, album, duration_secs, quality, cover_id }
329                }
330                _ => QueuedTrack {
331                    id,
332                    title: format!("Track {id}"),
333                    artist: String::new(),
334                    album: String::new(),
335                    duration_secs: 0,
336                    quality,
337                    cover_id: None,
338                },
339            };
340            let track_title = queued.title.clone();
341            data.tracks.push(queued);
342            data.updated_at = Self::now_iso();
343            match hub.write_playlist(&data) {
344                Ok(()) => yield MonoEvent::PlayerAck {
345                    action: "playlist_add".into(),
346                    message: format!("added '{track_title}' to playlist '{name}'"),
347                },
348                Err(e) => yield MonoEvent::Error { message: e },
349            }
350        }
351    }
352
353    /// Remove a track from a playlist by index
354    #[plexus_macros::hub_method(
355        description = "Remove a track at a given index from a playlist",
356        params(
357            name = "Playlist name",
358            index = "0-based index of the track to remove"
359        )
360    )]
361    pub async fn remove(
362        &self,
363        name: String,
364        index: u32,
365    ) -> impl Stream<Item = MonoEvent> + Send + 'static {
366        let hub = self.clone();
367        stream! {
368            let mut data = match hub.load(&name) {
369                Ok(d) => d,
370                Err(e) => { yield MonoEvent::Error { message: e }; return; }
371            };
372            let idx = index as usize;
373            if idx >= data.tracks.len() {
374                yield MonoEvent::Error {
375                    message: format!("index {index} out of bounds (playlist has {} tracks)", data.tracks.len()),
376                };
377                return;
378            }
379            let removed = data.tracks.remove(idx);
380            data.updated_at = Self::now_iso();
381            match hub.write_playlist(&data) {
382                Ok(()) => yield MonoEvent::PlayerAck {
383                    action: "playlist_remove".into(),
384                    message: format!("removed '{}' from playlist '{name}'", removed.title),
385                },
386                Err(e) => yield MonoEvent::Error { message: e },
387            }
388        }
389    }
390
391    /// Set or update a playlist's description
392    #[plexus_macros::hub_method(
393        description = "Set or update the description of a playlist",
394        params(
395            name = "Playlist name",
396            description = "New description text"
397        )
398    )]
399    pub async fn describe(
400        &self,
401        name: String,
402        description: String,
403    ) -> impl Stream<Item = MonoEvent> + Send + 'static {
404        let hub = self.clone();
405        stream! {
406            let mut data = match hub.load(&name) {
407                Ok(d) => d,
408                Err(e) => { yield MonoEvent::Error { message: e }; return; }
409            };
410            data.description = description;
411            data.updated_at = Self::now_iso();
412            match hub.write_playlist(&data) {
413                Ok(()) => yield MonoEvent::PlayerAck {
414                    action: "playlist_describe".into(),
415                    message: format!("updated description for playlist '{name}'"),
416                },
417                Err(e) => yield MonoEvent::Error { message: e },
418            }
419        }
420    }
421
422    /// Reorder a track within a playlist
423    #[plexus_macros::hub_method(
424        description = "Move a track within a playlist from one position to another",
425        params(
426            name = "Playlist name",
427            from = "Source index (0-based)",
428            to = "Destination index (0-based)"
429        )
430    )]
431    pub async fn reorder(
432        &self,
433        name: String,
434        from: u32,
435        to: u32,
436    ) -> impl Stream<Item = MonoEvent> + Send + 'static {
437        let hub = self.clone();
438        stream! {
439            let mut data = match hub.load(&name) {
440                Ok(d) => d,
441                Err(e) => { yield MonoEvent::Error { message: e }; return; }
442            };
443            let (f, t) = (from as usize, to as usize);
444            if f >= data.tracks.len() || t >= data.tracks.len() {
445                yield MonoEvent::Error {
446                    message: format!("index out of bounds (playlist has {} tracks)", data.tracks.len()),
447                };
448                return;
449            }
450            let track = data.tracks.remove(f);
451            let title = track.title.clone();
452            data.tracks.insert(t, track);
453            data.updated_at = Self::now_iso();
454            match hub.write_playlist(&data) {
455                Ok(()) => yield MonoEvent::PlayerAck {
456                    action: "playlist_reorder".into(),
457                    message: format!("moved '{title}' from position {from} to {to}"),
458                },
459                Err(e) => yield MonoEvent::Error { message: e },
460            }
461        }
462    }
463
464    /// Load a playlist into the queue and start playing
465    #[plexus_macros::hub_method(
466        description = "Load playlist tracks into the playback queue and start playing",
467        params(name = "Playlist name")
468    )]
469    pub async fn play(
470        &self,
471        name: String,
472    ) -> impl Stream<Item = MonoEvent> + Send + 'static {
473        let hub = self.clone();
474        stream! {
475            let data = match hub.load(&name) {
476                Ok(d) => d,
477                Err(e) => { yield MonoEvent::Error { message: e }; return; }
478            };
479            if data.tracks.is_empty() {
480                yield MonoEvent::Error {
481                    message: format!("playlist '{name}' is empty"),
482                };
483                return;
484            }
485            // Stop current playback and clear queue before loading playlist
486            hub.player.stop().await;
487            hub.player.queue_clear().await;
488            for track in &data.tracks {
489                match hub.player.queue_add(track.id, &track.quality).await {
490                    Ok(()) => {}
491                    Err(e) => {
492                        yield MonoEvent::Error { message: e };
493                        return;
494                    }
495                }
496            }
497            yield MonoEvent::PlayerAck {
498                action: "playlist_play".into(),
499                message: format!("playing playlist '{name}' ({} tracks)", data.tracks.len()),
500            };
501        }
502    }
503
504    /// Save the current queue as a playlist
505    #[plexus_macros::hub_method(
506        description = "Save the current playback queue as a named playlist (creates or overwrites)",
507        params(name = "Playlist name")
508    )]
509    pub async fn save(
510        &self,
511        name: String,
512    ) -> impl Stream<Item = MonoEvent> + Send + 'static {
513        let hub = self.clone();
514        stream! {
515            let (current, upcoming) = hub.player.queue_get().await;
516            let mut tracks = Vec::new();
517            if let Some(c) = current {
518                tracks.push(c);
519            }
520            tracks.extend(upcoming);
521            if tracks.is_empty() {
522                yield MonoEvent::Error {
523                    message: "queue is empty — nothing to save".into(),
524                };
525                return;
526            }
527            let count = tracks.len();
528            let now = Self::now_iso();
529            let data = PlaylistData {
530                name: name.clone(),
531                description: String::new(),
532                tracks,
533                created_at: now.clone(),
534                updated_at: now,
535            };
536            match hub.write_playlist(&data) {
537                Ok(()) => yield MonoEvent::PlayerAck {
538                    action: "playlist_save".into(),
539                    message: format!("saved {count} tracks as playlist '{name}'"),
540                },
541                Err(e) => yield MonoEvent::Error { message: e },
542            }
543        }
544    }
545}
546
547#[async_trait]
548impl ChildRouter for PlaylistHub {
549    fn router_namespace(&self) -> &str {
550        "playlist"
551    }
552
553    async fn router_call(
554        &self,
555        method: &str,
556        params: serde_json::Value,
557    ) -> Result<PlexusStream, PlexusError> {
558        self.call(method, params).await
559    }
560
561    async fn get_child(&self, _name: &str) -> Option<Box<dyn ChildRouter>> {
562        None
563    }
564}