termusicplayback/
playlist.rs

1use std::error::Error;
2use std::fmt::{Display, Write as _};
3use std::fs::File;
4use std::io::{BufRead, BufReader, BufWriter, Write};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use anyhow::{Context, Result, bail};
9use parking_lot::RwLock;
10use pathdiff::diff_paths;
11use rand::Rng;
12use rand::seq::SliceRandom;
13use termusiclib::config::SharedServerSettings;
14use termusiclib::config::v2::server::LoopMode;
15use termusiclib::player::PlaylistLoopModeInfo;
16use termusiclib::player::PlaylistShuffledInfo;
17use termusiclib::player::PlaylistSwapInfo;
18use termusiclib::player::PlaylistTracks;
19use termusiclib::player::UpdateEvents;
20use termusiclib::player::UpdatePlaylistEvents;
21use termusiclib::player::playlist_helpers::PlaylistPlaySpecific;
22use termusiclib::player::playlist_helpers::PlaylistSwapTrack;
23use termusiclib::player::playlist_helpers::PlaylistTrackSource;
24use termusiclib::player::playlist_helpers::{PlaylistAddTrack, PlaylistRemoveTrackIndexed};
25use termusiclib::player::{self, RunningStatus};
26use termusiclib::player::{PlaylistAddTrackInfo, PlaylistRemoveTrackInfo};
27use termusiclib::podcast::{db::Database as DBPod, episode::Episode};
28use termusiclib::track::{MediaTypes, Track, TrackData};
29use termusiclib::utils::{filetype_supported, get_app_config_path, get_parent_folder};
30
31use crate::SharedPlaylist;
32use crate::StreamTX;
33
34#[derive(Debug)]
35pub struct Playlist {
36    /// All tracks in the playlist
37    tracks: Vec<Track>,
38    /// Index into `tracks` of which the current playing track is
39    current_track_index: usize,
40    /// Index into `tracks` for the next track to play after the current.
41    ///
42    /// Practically only used for pre-enqueue / pre-fetch / gapless.
43    next_track_index: Option<usize>,
44    /// The currently playing [`Track`]. Does not need to be in `tracks`
45    current_track: Option<Track>,
46    /// The current playing running status of the playlist
47    status: RunningStatus,
48    /// The loop-/play-mode for the playlist
49    loop_mode: LoopMode,
50    /// Indexes into `tracks` that have been previously been played (for `previous`)
51    played_index: Vec<usize>,
52    /// Indicator if the playlist should advance the `current_*` and `next_*` values
53    need_proceed_to_next: bool,
54    stream_tx: StreamTX,
55
56    /// Indicator if we need to save the playlist for interval saving
57    is_modified: bool,
58}
59
60impl Playlist {
61    /// Create a new playlist instance with 0 tracks
62    pub fn new(config: &SharedServerSettings, stream_tx: StreamTX) -> Self {
63        // TODO: shouldn't "loop_mode" be combined with the config ones?
64        let loop_mode = config.read().settings.player.loop_mode;
65        let current_track = None;
66
67        Self {
68            tracks: Vec::new(),
69            status: RunningStatus::Stopped,
70            loop_mode,
71            current_track_index: 0,
72            current_track,
73            played_index: Vec::new(),
74            next_track_index: None,
75            need_proceed_to_next: false,
76            stream_tx,
77            is_modified: false,
78        }
79    }
80
81    /// Create a new Playlist instance that is directly shared
82    ///
83    /// # Errors
84    ///
85    /// see [`load`](Self::load)
86    pub fn new_shared(
87        config: &SharedServerSettings,
88        stream_tx: StreamTX,
89    ) -> Result<SharedPlaylist> {
90        let mut playlist = Self::new(config, stream_tx);
91        playlist.load_apply()?;
92
93        Ok(Arc::new(RwLock::new(playlist)))
94    }
95
96    /// Advance the playlist to the next track.
97    pub fn proceed(&mut self) {
98        debug!("need to proceed to next: {}", self.need_proceed_to_next);
99        self.is_modified = true;
100        if self.need_proceed_to_next {
101            self.next();
102        } else {
103            self.need_proceed_to_next = true;
104        }
105    }
106
107    /// Set `need_proceed_to_next` to `false`
108    pub fn proceed_false(&mut self) {
109        self.need_proceed_to_next = false;
110    }
111
112    /// Load the playlist from the file.
113    ///
114    /// Path in `$config$/playlist.log`.
115    ///
116    /// Returns `(Position, Tracks[])`.
117    ///
118    /// # Errors
119    /// - When the playlist path is not write-able
120    /// - When podcasts cannot be loaded
121    pub fn load() -> Result<(usize, Vec<Track>)> {
122        let path = get_playlist_path()?;
123
124        let Ok(file) = File::open(&path) else {
125            // new file, nothing to parse from it
126            File::create(&path)?;
127
128            return Ok((0, Vec::new()));
129        };
130
131        let reader = BufReader::new(file);
132        let mut lines = reader.lines();
133
134        let mut current_track_index = 0;
135        if let Some(line) = lines.next() {
136            let index_line = line?;
137            if let Ok(index) = index_line.trim().parse() {
138                current_track_index = index;
139            }
140        } else {
141            // empty file, nothing to parse from it
142            return Ok((0, Vec::new()));
143        }
144
145        let mut playlist_items = Vec::new();
146        let db_path = get_app_config_path()?;
147        let db_podcast = DBPod::new(&db_path)?;
148        let podcasts = db_podcast
149            .get_podcasts()
150            .with_context(|| "failed to get podcasts from db.")?;
151        for line in lines {
152            let line = line?;
153
154            let trimmed_line = line.trim();
155
156            // skip empty lines without trying to process them
157            // skip lines that are comments (m3u-like)
158            if trimmed_line.is_empty() || trimmed_line.starts_with('#') {
159                continue;
160            }
161
162            if line.starts_with("http") {
163                let mut is_podcast = false;
164                'outer: for pod in &podcasts {
165                    for ep in &pod.episodes {
166                        if ep.url == line.as_str() {
167                            is_podcast = true;
168                            let track = Track::from_podcast_episode(ep);
169                            playlist_items.push(track);
170                            break 'outer;
171                        }
172                    }
173                }
174                if !is_podcast {
175                    let track = Track::new_radio(&line);
176                    playlist_items.push(track);
177                }
178                continue;
179            }
180            if let Ok(track) = Track::read_track_from_path(&line) {
181                playlist_items.push(track);
182            }
183        }
184
185        // protect against the listed index in the playlist file not matching the elements in the playlist
186        // for example lets say it has "100", but there are only 2 elements in the playlist
187        let current_track_index = current_track_index.min(playlist_items.len().saturating_sub(1));
188
189        Ok((current_track_index, playlist_items))
190    }
191
192    /// Run [`load`](Self::load), but also apply the values directly to the current instance.
193    ///
194    /// # Errors
195    ///
196    /// See [`load`](Self::load)
197    pub fn load_apply(&mut self) -> Result<()> {
198        let (current_track_index, tracks) = Self::load()?;
199        self.current_track_index = current_track_index;
200        self.tracks = tracks;
201        self.is_modified = false;
202
203        Ok(())
204    }
205
206    /// Load Tracks from a GRPC response.
207    ///
208    /// Returns `(Position, Tracks[])`.
209    ///
210    /// # Errors
211    ///
212    /// - when converting from u64 grpc values to usize fails
213    /// - when there is no track-id
214    /// - when reading a Track from path or podcast database fails
215    pub fn load_from_grpc(&mut self, info: PlaylistTracks, podcast_db: &DBPod) -> Result<()> {
216        let current_track_index = usize::try_from(info.current_track_index)
217            .context("convert current_track_index(u64) to usize")?;
218        let mut playlist_items = Vec::with_capacity(info.tracks.len());
219
220        for (idx, track) in info.tracks.into_iter().enumerate() {
221            let at_index_usize =
222                usize::try_from(track.at_index).context("convert at_index(u64) to usize")?;
223            // assume / require that the tracks are ordered correctly, if not just log a error for now
224            if idx != at_index_usize {
225                error!("Non-matching \"index\" and \"at_index\"!");
226            }
227
228            // this case should never happen with "termusic-server", but grpc marks them as "optional"
229            let Some(id) = track.id else {
230                bail!("Track does not have a id, which is required to load!");
231            };
232
233            let track = match PlaylistTrackSource::try_from(id)? {
234                PlaylistTrackSource::Path(v) => Track::read_track_from_path(v)?,
235                PlaylistTrackSource::Url(v) => Track::new_radio(&v),
236                PlaylistTrackSource::PodcastUrl(v) => {
237                    let episode = podcast_db.get_episode_by_url(&v)?;
238                    Track::from_podcast_episode(&episode)
239                }
240            };
241
242            playlist_items.push(track);
243        }
244
245        self.current_track_index = current_track_index;
246        self.tracks = playlist_items;
247        self.is_modified = true;
248
249        Ok(())
250    }
251
252    /// Reload the current playlist from the file. This function does not save beforehand.
253    ///
254    /// This is currently 1:1 the same as [`Self::load_apply`],
255    /// but has some slight different semantic meaning in that [`Self::load_apply`] is meant for a new Playlist instance.
256    ///
257    /// # Errors
258    ///
259    /// See [`Self::load`]
260    pub fn reload_tracks(&mut self) -> Result<()> {
261        let (current_track_index, tracks) = Self::load()?;
262        self.tracks = tracks;
263        self.current_track_index = current_track_index;
264        self.is_modified = false;
265
266        Ok(())
267    }
268
269    /// Save the current playlist and playing index to the playlist log
270    ///
271    /// Path in `$config$/playlist.log`
272    ///
273    /// # Errors
274    ///
275    /// Errors could happen when writing files
276    pub fn save(&mut self) -> Result<()> {
277        let path = get_playlist_path()?;
278
279        let file = File::create(&path)?;
280
281        // If the playlist is empty, truncate the file, but dont write anything else (like a index number)
282        if self.is_empty() {
283            self.is_modified = false;
284            return Ok(());
285        }
286
287        let mut writer = BufWriter::new(file);
288        writer.write_all(self.current_track_index.to_string().as_bytes())?;
289        writer.write_all(b"\n")?;
290        for track in &self.tracks {
291            let id = match track.inner() {
292                MediaTypes::Track(track_data) => track_data.path().to_string_lossy(),
293                MediaTypes::Radio(radio_track_data) => radio_track_data.url().into(),
294                MediaTypes::Podcast(podcast_track_data) => podcast_track_data.url().into(),
295            };
296            writeln!(writer, "{id}")?;
297        }
298
299        writer.flush()?;
300        self.is_modified = false;
301
302        Ok(())
303    }
304
305    /// Run [`Self::save`] only if [`Self::is_modified`] is `true`.
306    ///
307    /// This is mainly used for saving in intervals and not writing if nothing changed.
308    ///
309    /// Returns `true` if saving was performed.
310    ///
311    /// # Errors
312    ///
313    /// See [`Self::save`]
314    pub fn save_if_modified(&mut self) -> Result<bool> {
315        if self.is_modified {
316            self.save()?;
317
318            return Ok(true);
319        }
320
321        Ok(false)
322    }
323
324    /// Change to the next track.
325    pub fn next(&mut self) {
326        self.played_index.push(self.current_track_index);
327        // Note: the next index is *not* taken here, as ".proceed/next" is called first,
328        // then "has_next_track" is later used to check if enqueuing has used.
329        if let Some(index) = self.next_track_index {
330            self.current_track_index = index;
331            return;
332        }
333        self.current_track_index = self.get_next_track_index();
334    }
335
336    /// Check that the given `info` track source matches the given `track_inner` types.
337    ///
338    /// # Errors
339    ///
340    /// if they dont match
341    fn check_same_source(
342        info: &PlaylistTrackSource,
343        track_inner: &MediaTypes,
344        at_index: usize,
345    ) -> Result<()> {
346        // Error style: "Error; expected INFO_TYPE; found PLAYLIST_TYPE"
347        match (info, track_inner) {
348            (PlaylistTrackSource::Path(file_url), MediaTypes::Track(id)) => {
349                if Path::new(&file_url) != id.path() {
350                    bail!(
351                        "Path mismatch, expected \"{file_url}\" at \"{at_index}\", found \"{}\"",
352                        id.path().display()
353                    );
354                }
355            }
356            (PlaylistTrackSource::Url(file_url), MediaTypes::Radio(id)) => {
357                if file_url != id.url() {
358                    bail!(
359                        "URI mismatch, expected \"{file_url}\" at \"{at_index}\", found \"{}\"",
360                        id.url()
361                    );
362                }
363            }
364            (PlaylistTrackSource::PodcastUrl(file_url), MediaTypes::Podcast(id)) => {
365                if file_url != id.url() {
366                    bail!(
367                        "URI mismatch, expected \"{file_url}\" at \"{at_index}\", found \"{}\"",
368                        id.url()
369                    );
370                }
371            }
372            (expected, got) => {
373                bail!(
374                    "Type mismatch, expected \"{expected:#?}\" at \"{at_index}\" found \"{got:#?}\""
375                );
376            }
377        }
378
379        Ok(())
380    }
381
382    /// Skip to a specific track in the playlist
383    ///
384    /// # Errors
385    ///
386    /// - if converting u64 to usize fails
387    /// - if the given info's tracks mismatch with the actual playlist
388    pub fn play_specific(&mut self, info: &PlaylistPlaySpecific) -> Result<()> {
389        let new_index =
390            usize::try_from(info.track_index).context("convert track_index(u64) to usize")?;
391
392        let Some(track_at_idx) = self.tracks.get(new_index) else {
393            bail!("Index {new_index} is out of bound {}", self.tracks.len())
394        };
395
396        Self::check_same_source(&info.id, track_at_idx.inner(), new_index)?;
397
398        self.played_index.push(self.current_track_index);
399        self.set_next_track(None);
400        self.set_current_track_index(new_index);
401        self.proceed_false();
402        self.is_modified = true;
403
404        Ok(())
405    }
406
407    /// Get the next track index based on the [`LoopMode`] used.
408    fn get_next_track_index(&self) -> usize {
409        let mut next_track_index = self.current_track_index;
410        match self.loop_mode {
411            LoopMode::Single => {}
412            LoopMode::Playlist => {
413                next_track_index += 1;
414                if next_track_index >= self.len() {
415                    next_track_index = 0;
416                }
417            }
418            LoopMode::Random => {
419                next_track_index = self.get_random_index();
420            }
421        }
422        next_track_index
423    }
424
425    /// Change to the previous track played.
426    ///
427    /// This uses `played_index` vec, if available, otherwise uses [`LoopMode`].
428    pub fn previous(&mut self) {
429        // unset next track as we now want a previous track instead of the next enqueued
430        self.set_next_track(None);
431
432        if !self.played_index.is_empty() {
433            if let Some(index) = self.played_index.pop() {
434                self.current_track_index = index;
435                self.is_modified = true;
436                return;
437            }
438        }
439        match self.loop_mode {
440            LoopMode::Single => {}
441            LoopMode::Playlist => {
442                if self.current_track_index == 0 {
443                    self.current_track_index = self.len() - 1;
444                } else {
445                    self.current_track_index -= 1;
446                }
447            }
448            LoopMode::Random => {
449                self.current_track_index = self.get_random_index();
450            }
451        }
452        self.is_modified = true;
453    }
454
455    #[must_use]
456    pub fn len(&self) -> usize {
457        self.tracks.len()
458    }
459
460    #[must_use]
461    pub fn is_empty(&self) -> bool {
462        self.tracks.is_empty()
463    }
464
465    /// Swap the `index` with the one below(+1) it, if there is one.
466    pub fn swap_down(&mut self, index: usize) {
467        if index < self.len().saturating_sub(1) {
468            self.tracks.swap(index, index + 1);
469            // handle index
470            if index == self.current_track_index {
471                self.current_track_index += 1;
472            } else if index == self.current_track_index - 1 {
473                self.current_track_index -= 1;
474            }
475            self.is_modified = true;
476        }
477    }
478
479    /// Swap the `index` with the one above(-1) it, if there is one.
480    pub fn swap_up(&mut self, index: usize) {
481        if index > 0 {
482            self.tracks.swap(index, index - 1);
483            // handle index
484            if index == self.current_track_index {
485                self.current_track_index -= 1;
486            } else if index == self.current_track_index + 1 {
487                self.current_track_index += 1;
488            }
489            self.is_modified = true;
490        }
491    }
492
493    /// Swap specific indexes, sends swap event.
494    ///
495    /// # Errors
496    ///
497    /// - if either index `a` or `b` are out-of-bounds
498    ///
499    /// # Panics
500    ///
501    /// If `usize` cannot be converted to `u64`
502    pub fn swap(&mut self, index_a: usize, index_b: usize) -> Result<()> {
503        // "swap" panics if a index is out-of-bounds
504        if index_a.max(index_b) >= self.tracks.len() {
505            bail!("Index {} not within tracks bounds", index_a.max(index_b));
506        }
507
508        self.tracks.swap(index_a, index_b);
509
510        let index_a = u64::try_from(index_a).unwrap();
511        let index_b = u64::try_from(index_b).unwrap();
512
513        self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistSwapTracks(PlaylistSwapInfo {
514            index_a,
515            index_b,
516        }));
517        self.is_modified = true;
518
519        Ok(())
520    }
521
522    /// Get the current track's Path/Url.
523    // TODO: refactor this function to likely return either a consistent URI format or a enum
524    // TODO: refactor to return a reference if possible
525    pub fn get_current_track(&mut self) -> Option<String> {
526        let mut result = None;
527        if let Some(track) = self.current_track() {
528            match track.inner() {
529                MediaTypes::Track(track_data) => {
530                    result = Some(track_data.path().to_string_lossy().to_string());
531                }
532                MediaTypes::Radio(radio_track_data) => {
533                    result = Some(radio_track_data.url().to_string());
534                }
535                MediaTypes::Podcast(podcast_track_data) => {
536                    result = Some(podcast_track_data.url().to_string());
537                }
538            }
539        }
540        result
541    }
542
543    /// Get the next track index and return a reference to it.
544    pub fn fetch_next_track(&mut self) -> Option<&Track> {
545        let next_index = self.get_next_track_index();
546        self.next_track_index = Some(next_index);
547        self.tracks.get(next_index)
548    }
549
550    /// Set the [`RunningStatus`] of the playlist, also sends a stream event.
551    pub fn set_status(&mut self, status: RunningStatus) {
552        self.status = status;
553        self.send_stream_ev(UpdateEvents::PlayStateChanged {
554            playing: status.as_u32(),
555        });
556    }
557
558    #[must_use]
559    pub fn is_stopped(&self) -> bool {
560        self.status == RunningStatus::Stopped
561    }
562
563    #[must_use]
564    pub fn is_paused(&self) -> bool {
565        self.status == RunningStatus::Paused
566    }
567
568    #[must_use]
569    pub fn status(&self) -> RunningStatus {
570        self.status
571    }
572
573    /// Cycle through the loop modes and return the new mode.
574    ///
575    /// order:
576    /// [Random](LoopMode::Random) -> [Playlist](LoopMode::Playlist)
577    /// [Playlist](LoopMode::Playlist) -> [Single](LoopMode::Single)
578    /// [Single](LoopMode::Single) -> [Random](LoopMode::Random)
579    pub fn cycle_loop_mode(&mut self) -> LoopMode {
580        let new_mode = match self.loop_mode {
581            LoopMode::Random => LoopMode::Playlist,
582            LoopMode::Playlist => LoopMode::Single,
583            LoopMode::Single => LoopMode::Random,
584        };
585
586        self.set_loop_mode(new_mode);
587
588        self.loop_mode
589    }
590
591    /// Set a specific [`LoopMode`], also sends a event that the mode changed.
592    /// Only sets & sends a event if the new mode is not the same as the old one.
593    pub fn set_loop_mode(&mut self, new_mode: LoopMode) {
594        // dont set and dont send a event if the mode is the same
595        if new_mode == self.loop_mode {
596            return;
597        }
598
599        self.loop_mode = new_mode;
600
601        self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistLoopMode(
602            PlaylistLoopModeInfo::from(self.loop_mode),
603        ));
604    }
605
606    /// Export the current playlist to a `.m3u` playlist file.
607    ///
608    /// Might be confused with [save](Self::save).
609    ///
610    /// # Errors
611    /// Error could happen when writing file to local disk.
612    pub fn save_m3u(&self, filename: &Path) -> Result<()> {
613        if self.tracks.is_empty() {
614            bail!("Unable to save since the playlist is empty.");
615        }
616
617        let parent_folder = get_parent_folder(filename);
618
619        let m3u = self.get_m3u_file(&parent_folder);
620
621        std::fs::write(filename, m3u)?;
622        Ok(())
623    }
624
625    /// Generate the m3u's file content.
626    ///
627    /// All Paths are relative to the `parent_folder` directory.
628    fn get_m3u_file(&self, parent_folder: &Path) -> String {
629        let mut m3u = String::from("#EXTM3U\n");
630        for track in &self.tracks {
631            let file = match track.inner() {
632                MediaTypes::Track(track_data) => {
633                    let path_relative = diff_paths(track_data.path(), parent_folder);
634
635                    path_relative.map_or_else(
636                        || track_data.path().to_string_lossy(),
637                        |v| v.to_string_lossy().to_string().into(),
638                    )
639                }
640                MediaTypes::Radio(radio_track_data) => radio_track_data.url().into(),
641                MediaTypes::Podcast(podcast_track_data) => podcast_track_data.url().into(),
642            };
643
644            let _ = writeln!(m3u, "{file}");
645        }
646        m3u
647    }
648
649    /// Add a podcast episode to the playlist.
650    ///
651    /// # Panics
652    ///
653    /// This should never happen as a podcast url has already been a string, so conversion should not fail
654    pub fn add_episode(&mut self, ep: &Episode) {
655        let track = Track::from_podcast_episode(ep);
656
657        let url = track.as_podcast().unwrap().url();
658
659        self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistAddTrack(
660            PlaylistAddTrackInfo {
661                at_index: u64::try_from(self.tracks.len()).unwrap(),
662                title: track.title().map(ToOwned::to_owned),
663                duration: track.duration().unwrap_or_default(),
664                // Note: Safe unwrap, as a podcast uri is always a uri, not a path (which has been a string before)
665                trackid: PlaylistTrackSource::PodcastUrl(url.to_owned()),
666            },
667        ));
668
669        self.tracks.push(track);
670        self.is_modified = true;
671    }
672
673    /// Add many Paths/Urls to the playlist.
674    ///
675    /// # Errors
676    /// - When invalid inputs are given
677    /// - When the file(s) cannot be read correctly
678    pub fn add_playlist<T: AsRef<str>>(&mut self, vec: &[T]) -> Result<(), PlaylistAddErrorVec> {
679        let mut errors = PlaylistAddErrorVec::default();
680        for item in vec {
681            let Err(err) = self.add_track(item) else {
682                continue;
683            };
684            errors.push(err);
685        }
686
687        if !errors.is_empty() {
688            return Err(errors);
689        }
690
691        Ok(())
692    }
693
694    /// Add a single Path/Url to the playlist
695    ///
696    /// # Errors
697    /// - When invalid inputs are given (non-existing path, unsupported file types, etc)
698    ///
699    /// # Panics
700    ///
701    /// If `usize` cannot be converted to `u64`
702    pub fn add_track<T: AsRef<str>>(&mut self, track: &T) -> Result<(), PlaylistAddError> {
703        let track_str = track.as_ref();
704        if track_str.starts_with("http") {
705            let track = Self::track_from_uri(track_str);
706            self.tracks.push(track);
707            self.is_modified = true;
708            return Ok(());
709        }
710
711        let track = Self::track_from_path(track_str)?;
712
713        self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistAddTrack(
714            PlaylistAddTrackInfo {
715                at_index: u64::try_from(self.tracks.len()).unwrap(),
716                title: track.title().map(ToOwned::to_owned),
717                duration: track.duration().unwrap_or_default(),
718                trackid: PlaylistTrackSource::Path(track_str.to_string()),
719            },
720        ));
721
722        self.tracks.push(track);
723        self.is_modified = true;
724
725        Ok(())
726    }
727
728    /// Convert [`PlaylistTrackSource`] to [`Track`] by calling the correct functions.
729    ///
730    /// This mainly exist to de-duplicate this match and resulting error handling.
731    fn source_to_track(track_location: &PlaylistTrackSource, db_pod: &DBPod) -> Result<Track> {
732        let track = match track_location {
733            PlaylistTrackSource::Path(path) => Self::track_from_path(path)?,
734            PlaylistTrackSource::Url(uri) => Self::track_from_uri(uri),
735            PlaylistTrackSource::PodcastUrl(uri) => Self::track_from_podcasturi(uri, db_pod)?,
736        };
737
738        Ok(track)
739    }
740
741    /// Add Paths / Urls from the music service
742    ///
743    /// # Errors
744    ///
745    /// On error on a specific track, the error will be collected and the remaining tracks will be tried to be added.
746    ///
747    /// - if adding a track results in a error (path not found, unsupported file types, not enough permissions, etc)
748    ///
749    /// # Panics
750    ///
751    /// If `usize` cannot be converted to `u64`
752    pub fn add_tracks(
753        &mut self,
754        tracks: PlaylistAddTrack,
755        db_pod: &DBPod,
756    ) -> Result<(), PlaylistAddErrorCollection> {
757        self.tracks.reserve(tracks.tracks.len());
758        let at_index = usize::try_from(tracks.at_index).unwrap();
759        // collect non-fatal errors to continue adding the rest of the tracks
760        let mut errors: Vec<anyhow::Error> = Vec::new();
761
762        info!(
763            "Trying to add {} tracks to the playlist",
764            tracks.tracks.len()
765        );
766
767        let mut added_tracks = 0;
768
769        if at_index >= self.len() {
770            // insert tracks at the end
771            for track_location in tracks.tracks {
772                let track = match Self::source_to_track(&track_location, db_pod) {
773                    Ok(v) => v,
774                    Err(err) => {
775                        warn!("Error adding track: {err}");
776                        errors.push(err);
777                        continue;
778                    }
779                };
780
781                self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistAddTrack(
782                    PlaylistAddTrackInfo {
783                        at_index: u64::try_from(self.tracks.len()).unwrap(),
784                        title: track.title().map(ToOwned::to_owned),
785                        duration: track.duration().unwrap_or_default(),
786                        trackid: track_location,
787                    },
788                ));
789
790                self.tracks.push(track);
791                self.is_modified = true;
792                added_tracks += 1;
793            }
794        } else {
795            let mut at_index = at_index;
796            // insert tracks at position
797            for track_location in tracks.tracks {
798                let track = match Self::source_to_track(&track_location, db_pod) {
799                    Ok(v) => v,
800                    Err(err) => {
801                        warn!("Error adding track: {err}");
802                        errors.push(err);
803                        continue;
804                    }
805                };
806
807                self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistAddTrack(
808                    PlaylistAddTrackInfo {
809                        at_index: u64::try_from(at_index).unwrap(),
810                        title: track.title().map(ToOwned::to_owned),
811                        duration: track.duration().unwrap_or_default(),
812                        trackid: track_location,
813                    },
814                ));
815
816                self.tracks.insert(at_index, track);
817                self.is_modified = true;
818                at_index += 1;
819                added_tracks += 1;
820            }
821        }
822
823        info!("Added {} tracks with {} errors", added_tracks, errors.len());
824
825        if !errors.is_empty() {
826            return Err(PlaylistAddErrorCollection::from(errors));
827        }
828
829        Ok(())
830    }
831
832    /// Remove Tracks from the music service
833    ///
834    /// # Errors
835    ///
836    /// - if the `at_index` is not within `self.tracks` bounds
837    /// - if `at_index + tracks.len` is not within bounds
838    /// - if the tracks type and URI mismatch
839    ///
840    /// # Panics
841    ///
842    /// If `usize` cannot be converted to `u64`
843    pub fn remove_tracks(&mut self, tracks: PlaylistRemoveTrackIndexed) -> Result<()> {
844        let at_index = usize::try_from(tracks.at_index).unwrap();
845
846        if at_index >= self.tracks.len() {
847            bail!(
848                "at_index is higher than the length of the playlist! at_index is \"{at_index}\" and playlist length is \"{}\"",
849                self.tracks.len()
850            );
851        }
852
853        if at_index + tracks.tracks.len().saturating_sub(1) >= self.tracks.len() {
854            bail!(
855                "at_index + tracks to remove is higher than the length of the playlist! playlist length is \"{}\"",
856                self.tracks.len()
857            );
858        }
859
860        for input_track in tracks.tracks {
861            // verify that it is the track to be removed via id matching
862            let Some(track_at_idx) = self.tracks.get(at_index) else {
863                // this should not happen as it is verified before the loop, but just in case
864                bail!("Failed to get track at index \"{at_index}\"");
865            };
866
867            Self::check_same_source(&input_track, track_at_idx.inner(), at_index)?;
868
869            // verified that at index "at_index" the track is of the type and has the URI that was requested to be removed
870            self.handle_remove(at_index);
871
872            self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistRemoveTrack(
873                PlaylistRemoveTrackInfo {
874                    at_index: u64::try_from(at_index).unwrap(),
875                    trackid: input_track,
876                },
877            ));
878        }
879
880        Ok(())
881    }
882
883    /// Create a Track from a given Path
884    #[allow(clippy::unnecessary_debug_formatting)] // we want debug information about a path (especially have it escaped)
885    fn track_from_path(path_str: &str) -> Result<Track, PlaylistAddError> {
886        let path = Path::new(path_str);
887
888        if !filetype_supported(path) {
889            error!("unsupported filetype: {path:#?}");
890            let p = path.to_path_buf();
891            let ext = path.extension().map(|v| v.to_string_lossy().to_string());
892            return Err(PlaylistAddError::UnsupportedFileType(ext, p));
893        }
894
895        if !path.exists() {
896            return Err(PlaylistAddError::PathDoesNotExist(path.to_path_buf()));
897        }
898
899        let track = Track::read_track_from_path(path)
900            .map_err(|err| PlaylistAddError::ReadError(err, path.to_path_buf()))?;
901
902        Ok(track)
903    }
904
905    /// Create a Track from a given uri (radio only)
906    fn track_from_uri(uri: &str) -> Track {
907        Track::new_radio(uri)
908    }
909
910    /// Create a Track from a given podcast uri
911    fn track_from_podcasturi(uri: &str, db_pod: &DBPod) -> Result<Track> {
912        let ep = db_pod.get_episode_by_url(uri)?;
913        let track = Track::from_podcast_episode(&ep);
914
915        Ok(track)
916    }
917
918    /// Swap tracks based on [`PlaylistSwapTrack`]
919    ///
920    /// # Errors
921    ///
922    /// - if either the `a` or `b` indexes are not within bounds
923    /// - if the indexes cannot be converted to `usize`
924    ///
925    /// # Panics
926    ///
927    /// If `usize` cannot be converted to `u64`
928    pub fn swap_tracks(&mut self, info: &PlaylistSwapTrack) -> Result<()> {
929        let index_a =
930            usize::try_from(info.index_a).context("Failed to convert index_a to usize")?;
931        let index_b =
932            usize::try_from(info.index_b).context("Failed to convert index_b to usize")?;
933
934        self.swap(index_a, index_b)?;
935
936        Ok(())
937    }
938
939    #[must_use]
940    pub fn tracks(&self) -> &Vec<Track> {
941        &self.tracks
942    }
943
944    /// Remove the track at `index`. Does not modify `current_track`.
945    ///
946    /// # Panics
947    ///
948    /// if usize cannot be converted to u64
949    pub fn remove(&mut self, index: usize) {
950        let Some(track) = self.tracks.get(index) else {
951            error!("Index {index} out of bound {}", self.tracks.len());
952            return;
953        };
954
955        let track_source = track.as_track_source();
956
957        self.handle_remove(index);
958
959        self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistRemoveTrack(
960            PlaylistRemoveTrackInfo {
961                at_index: u64::try_from(index).unwrap(),
962                trackid: track_source,
963            },
964        ));
965    }
966
967    /// Internal common `remove` handling, does not send a event
968    fn handle_remove(&mut self, index: usize) {
969        self.tracks.remove(index);
970
971        // Handle index
972        if index <= self.current_track_index {
973            // nothing needs to be done if the index is already 0
974            if self.current_track_index != 0 {
975                self.current_track_index -= 1;
976            }
977        }
978    }
979
980    /// Clear the current playlist.
981    /// This does not stop the playlist or clear [`current_track`](Self::current_track).
982    pub fn clear(&mut self) {
983        self.tracks.clear();
984        self.played_index.clear();
985        self.next_track_index.take();
986        self.current_track_index = 0;
987        self.need_proceed_to_next = false;
988
989        self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistCleared);
990    }
991
992    /// Shuffle the playlist
993    ///
994    /// # Panics
995    ///
996    /// see [`as_grpc_playlist_tracks#Errors`](Self::as_grpc_playlist_tracks)
997    pub fn shuffle(&mut self) {
998        let current_track_file = self.get_current_track();
999
1000        self.tracks.shuffle(&mut rand::rng());
1001
1002        if let Some(current_track_file) = current_track_file {
1003            if let Some(index) = self.find_index_from_file(&current_track_file) {
1004                self.current_track_index = index;
1005            }
1006        }
1007
1008        self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistShuffled(
1009            PlaylistShuffledInfo {
1010                tracks: self.as_grpc_playlist_tracks().unwrap(),
1011            },
1012        ));
1013    }
1014
1015    /// Get the current tracks and state as a GRPC [`PlaylistTracks`] object.
1016    ///
1017    /// # Errors
1018    ///
1019    /// - if some track does not have a file-id
1020    /// - converting usize to u64 fails
1021    pub fn as_grpc_playlist_tracks(&self) -> Result<PlaylistTracks> {
1022        let tracks = self
1023            .tracks()
1024            .iter()
1025            .enumerate()
1026            .map(|(idx, track)| {
1027                let at_index = u64::try_from(idx).context("track index(usize) to u64")?;
1028                let track_source = track.as_track_source();
1029
1030                Ok(player::PlaylistAddTrack {
1031                    at_index,
1032                    duration: Some(track.duration().unwrap_or_default().into()),
1033                    id: Some(track_source.into()),
1034                    optional_title: None,
1035                })
1036            })
1037            .collect::<Result<_>>()?;
1038
1039        Ok(PlaylistTracks {
1040            current_track_index: u64::try_from(self.get_current_track_index())
1041                .context("current_track_index(usize) to u64")?,
1042            tracks,
1043        })
1044    }
1045
1046    /// Find the index in the playlist for `item`, if it exists there.
1047    fn find_index_from_file(&self, item: &str) -> Option<usize> {
1048        for (index, track) in self.tracks.iter().enumerate() {
1049            let file = match track.inner() {
1050                MediaTypes::Track(track_data) => track_data.path().to_string_lossy(),
1051                MediaTypes::Radio(radio_track_data) => radio_track_data.url().into(),
1052                MediaTypes::Podcast(podcast_track_data) => podcast_track_data.url().into(),
1053            };
1054            if file == item {
1055                return Some(index);
1056            }
1057        }
1058        None
1059    }
1060
1061    /// Get a random index in the playlist.
1062    fn get_random_index(&self) -> usize {
1063        let mut random_index = self.current_track_index;
1064
1065        if self.len() <= 1 {
1066            return 0;
1067        }
1068
1069        let mut rng = rand::rng();
1070        while self.current_track_index == random_index {
1071            random_index = rng.random_range(0..self.len());
1072        }
1073
1074        random_index
1075    }
1076
1077    /// Remove all tracks from the playlist that dont exist on the disk.
1078    ///
1079    /// # Panics
1080    ///
1081    /// if usize cannot be converted to u64
1082    pub fn remove_deleted_items(&mut self) {
1083        if let Some(current_track_file) = self.get_current_track() {
1084            let len = self.tracks.len();
1085            let old_tracks = std::mem::replace(&mut self.tracks, Vec::with_capacity(len));
1086
1087            for track in old_tracks {
1088                let Some(path) = track.as_track().map(TrackData::path) else {
1089                    continue;
1090                };
1091
1092                if path.exists() {
1093                    self.tracks.push(track);
1094                    continue;
1095                }
1096
1097                let track_source = track.as_track_source();
1098
1099                // the index of the playlist where this item is deleted
1100                // this must be the index after other indexes might have been already deleted
1101                // ie if 0 is deleted, then the next element is also index 0
1102                // also ".len" is safe to use here as it is always 1 higher than the max index of the retained elements
1103                let deleted_idx = self.tracks.len();
1104
1105                // NOTE: this function may send many events very quickly (for example on a folder delete), which could overwhelm the broadcast channel on a low capacity value
1106                self.send_stream_ev_pl(UpdatePlaylistEvents::PlaylistRemoveTrack(
1107                    PlaylistRemoveTrackInfo {
1108                        at_index: u64::try_from(deleted_idx).unwrap(),
1109                        trackid: track_source,
1110                    },
1111                ));
1112                self.is_modified = true;
1113            }
1114
1115            match self.find_index_from_file(&current_track_file) {
1116                Some(new_index) => self.current_track_index = new_index,
1117                None => self.current_track_index = 0,
1118            }
1119        }
1120    }
1121
1122    /// Stop the current playlist by setting [`RunningStatus::Stopped`], preventing going to the next track
1123    /// and finally, stop the currently playing track.
1124    pub fn stop(&mut self) {
1125        self.set_status(RunningStatus::Stopped);
1126        self.set_next_track(None);
1127        self.clear_current_track();
1128    }
1129
1130    #[must_use]
1131    pub fn current_track(&self) -> Option<&Track> {
1132        if self.current_track.is_some() {
1133            return self.current_track.as_ref();
1134        }
1135        self.tracks.get(self.current_track_index)
1136    }
1137
1138    pub fn current_track_as_mut(&mut self) -> Option<&mut Track> {
1139        self.tracks.get_mut(self.current_track_index)
1140    }
1141
1142    pub fn clear_current_track(&mut self) {
1143        self.current_track = None;
1144    }
1145
1146    #[must_use]
1147    pub fn get_current_track_index(&self) -> usize {
1148        self.current_track_index
1149    }
1150
1151    pub fn set_current_track_index(&mut self, index: usize) {
1152        self.current_track_index = index;
1153    }
1154
1155    #[must_use]
1156    pub fn next_track(&self) -> Option<&Track> {
1157        let index = self.next_track_index?;
1158        self.tracks.get(index)
1159    }
1160
1161    pub fn set_next_track(&mut self, track_idx: Option<usize>) {
1162        self.next_track_index = track_idx;
1163    }
1164
1165    #[must_use]
1166    pub fn has_next_track(&self) -> bool {
1167        self.next_track_index.is_some()
1168    }
1169
1170    /// Send Playlist stream events with consistent error handling
1171    fn send_stream_ev_pl(&self, ev: UpdatePlaylistEvents) {
1172        // there is only one error case: no receivers
1173        if self
1174            .stream_tx
1175            .send(UpdateEvents::PlaylistChanged(ev))
1176            .is_err()
1177        {
1178            debug!("Stream Event not send: No Receivers");
1179        }
1180    }
1181
1182    /// Send stream events with consistent error handling
1183    fn send_stream_ev(&self, ev: UpdateEvents) {
1184        // there is only one error case: no receivers
1185        if self.stream_tx.send(ev).is_err() {
1186            debug!("Stream Event not send: No Receivers");
1187        }
1188    }
1189}
1190
1191const PLAYLIST_SAVE_FILENAME: &str = "playlist.log";
1192
1193fn get_playlist_path() -> Result<PathBuf> {
1194    let mut path = get_app_config_path()?;
1195    path.push(PLAYLIST_SAVE_FILENAME);
1196
1197    Ok(path)
1198}
1199
1200/// Error collections for [`Playlist::add_tracks`].
1201#[derive(Debug)]
1202pub struct PlaylistAddErrorCollection {
1203    pub errors: Vec<anyhow::Error>,
1204}
1205
1206impl Display for PlaylistAddErrorCollection {
1207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1208        writeln!(
1209            f,
1210            "There are {} Errors adding tracks to the playlist: [",
1211            self.errors.len()
1212        )?;
1213
1214        for err in &self.errors {
1215            writeln!(f, "  {err},")?;
1216        }
1217
1218        write!(f, "]")
1219    }
1220}
1221
1222impl Error for PlaylistAddErrorCollection {}
1223
1224impl From<Vec<anyhow::Error>> for PlaylistAddErrorCollection {
1225    fn from(value: Vec<anyhow::Error>) -> Self {
1226        Self {
1227            errors: value.into_iter().map(|err| anyhow::anyhow!(err)).collect(),
1228        }
1229    }
1230}
1231
1232// NOTE: this is not "thiserror" due to custom "Display" impl (the "Option" handling)
1233/// Error for when [`Playlist::add_track`] fails
1234#[derive(Debug)]
1235pub enum PlaylistAddError {
1236    /// `(FileType, Path)`
1237    UnsupportedFileType(Option<String>, PathBuf),
1238    /// `(Path)`
1239    PathDoesNotExist(PathBuf),
1240    /// Generic Error for when reading the track fails
1241    /// `(OriginalError, Path)`
1242    ReadError(anyhow::Error, PathBuf),
1243}
1244
1245impl Display for PlaylistAddError {
1246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1247        write!(
1248            f,
1249            "Failed to add to playlist because of: {}",
1250            match self {
1251                Self::UnsupportedFileType(ext, path) => {
1252                    let ext = if let Some(ext) = ext {
1253                        format!("Some({ext})")
1254                    } else {
1255                        "None".into()
1256                    };
1257                    format!("Unsupported File type \"{ext}\" at \"{}\"", path.display())
1258                }
1259                Self::PathDoesNotExist(path) => {
1260                    format!("Path does not exist: \"{}\"", path.display())
1261                }
1262                Self::ReadError(err, path) => {
1263                    format!("{err} at \"{}\"", path.display())
1264                }
1265            }
1266        )
1267    }
1268}
1269
1270impl Error for PlaylistAddError {
1271    fn source(&self) -> Option<&(dyn Error + 'static)> {
1272        if let Self::ReadError(orig, _) = self {
1273            return Some(orig.as_ref());
1274        }
1275
1276        None
1277    }
1278}
1279
1280/// Error for when [`Playlist::add_playlist`] fails
1281#[derive(Debug, Default)]
1282pub struct PlaylistAddErrorVec(Vec<PlaylistAddError>);
1283
1284impl PlaylistAddErrorVec {
1285    pub fn push(&mut self, err: PlaylistAddError) {
1286        self.0.push(err);
1287    }
1288
1289    #[must_use]
1290    pub fn is_empty(&self) -> bool {
1291        self.0.is_empty()
1292    }
1293}
1294
1295impl Display for PlaylistAddErrorVec {
1296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1297        writeln!(f, "{} Error(s) happened:", self.0.len())?;
1298        for err in &self.0 {
1299            writeln!(f, "  - {err}")?;
1300        }
1301
1302        Ok(())
1303    }
1304}
1305
1306impl Error for PlaylistAddErrorVec {}
1307
1308#[cfg(test)]
1309mod tests {
1310    use std::path::PathBuf;
1311
1312    use termusiclib::{
1313        player::playlist_helpers::PlaylistTrackSource,
1314        track::{MediaTypes, PodcastTrackData, RadioTrackData, TrackData},
1315    };
1316
1317    use super::Playlist;
1318
1319    #[test]
1320    fn should_pass_check_info() {
1321        let path = "/somewhere/file.mp3".to_string();
1322        let path2 = PathBuf::from(&path);
1323        Playlist::check_same_source(
1324            &PlaylistTrackSource::Path(path),
1325            &MediaTypes::Track(TrackData::new(path2)),
1326            0,
1327        )
1328        .unwrap();
1329
1330        let uri = "http://some.radio.com/".to_string();
1331        let uri2 = uri.clone();
1332        Playlist::check_same_source(
1333            &PlaylistTrackSource::Url(uri),
1334            &MediaTypes::Radio(RadioTrackData::new(uri2)),
1335            0,
1336        )
1337        .unwrap();
1338
1339        let uri = "http://some.podcast.com/".to_string();
1340        let uri2 = uri.clone();
1341        Playlist::check_same_source(
1342            &PlaylistTrackSource::PodcastUrl(uri),
1343            &MediaTypes::Podcast(PodcastTrackData::new(uri2)),
1344            0,
1345        )
1346        .unwrap();
1347    }
1348
1349    #[test]
1350    fn should_err_on_type_mismatch() {
1351        let path = "/somewhere/file.mp3".to_string();
1352        let path2 = path.clone();
1353        Playlist::check_same_source(
1354            &PlaylistTrackSource::Path(path),
1355            &MediaTypes::Radio(RadioTrackData::new(path2)),
1356            0,
1357        )
1358        .unwrap_err();
1359    }
1360}