1use 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#[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#[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 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 #[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}