termusiclib/
track.rs

1use std::{
2    borrow::Cow,
3    cell::RefCell,
4    fmt::Display,
5    fs::File,
6    io::BufReader,
7    num::NonZeroUsize,
8    path::{Path, PathBuf},
9    str::FromStr,
10    sync::Arc,
11    time::{Duration, SystemTime},
12};
13
14use anyhow::{Context, Result, anyhow, bail};
15use id3::frame::Lyrics as Id3Lyrics;
16use lofty::{
17    config::ParseOptions,
18    file::{AudioFile, FileType, TaggedFileExt},
19    picture::{Picture, PictureType},
20    probe::Probe,
21    tag::{Accessor, ItemKey, ItemValue, Tag as LoftyTag},
22};
23use lru::LruCache;
24
25use crate::{
26    player::playlist_helpers::PlaylistTrackSource, podcast::episode::Episode, songtag::lrc::Lyric,
27    utils::SplitArrayIter,
28};
29
30/// A simple no-value representation of [`MediaTypes`].
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum MediaTypesSimple {
33    Music,
34    Podcast,
35    LiveRadio,
36}
37
38#[derive(Debug, Clone)]
39pub struct PodcastTrackData {
40    /// The Podcast url, used as the sole identifier for equality
41    url: String,
42
43    localfile: Option<PathBuf>,
44    image_url: Option<String>,
45}
46
47impl PartialEq for PodcastTrackData {
48    fn eq(&self, other: &Self) -> bool {
49        self.url == other.url
50    }
51}
52
53impl PodcastTrackData {
54    /// Get the Podcast URL identifier
55    #[must_use]
56    pub fn url(&self) -> &str {
57        &self.url
58    }
59
60    /// Get the local file path for the downloaded podcast
61    #[must_use]
62    pub fn localfile(&self) -> Option<&Path> {
63        self.localfile.as_deref()
64    }
65
66    /// Check if this track has a localfile attached
67    #[must_use]
68    pub fn has_localfile(&self) -> bool {
69        self.localfile.is_some()
70    }
71
72    #[must_use]
73    pub fn image_url(&self) -> Option<&str> {
74        self.image_url.as_deref()
75    }
76
77    /// Create new [`PodcastTrackData`] with only the url.
78    ///
79    /// This should mainly be used for tests only.
80    #[must_use]
81    pub fn new(url: String) -> Self {
82        Self {
83            url,
84
85            localfile: None,
86            image_url: None,
87        }
88    }
89}
90
91#[derive(Debug, Clone, PartialEq)]
92pub struct RadioTrackData {
93    /// The Radio url, used as the sole identifier for equality
94    url: String,
95}
96
97impl RadioTrackData {
98    /// Get the url for for the radio
99    #[must_use]
100    pub fn url(&self) -> &str {
101        &self.url
102    }
103
104    /// Create new [`RadioTrackData`] with only the url.
105    ///
106    /// This should mainly be used for tests only.
107    #[must_use]
108    pub fn new(url: String) -> Self {
109        Self { url }
110    }
111}
112
113#[derive(Debug, Clone)]
114pub struct TrackData {
115    /// The Track file path, used as the sole identifier for equality
116    path: PathBuf,
117
118    album: Option<String>,
119
120    file_type: Option<FileType>,
121}
122
123impl PartialEq for TrackData {
124    fn eq(&self, other: &Self) -> bool {
125        self.path == other.path
126    }
127}
128
129impl TrackData {
130    /// Get the path the track is stored at
131    #[must_use]
132    pub fn path(&self) -> &Path {
133        &self.path
134    }
135
136    #[must_use]
137    pub fn album(&self) -> Option<&str> {
138        self.album.as_deref()
139    }
140
141    /// The lofty File-Type; may not exist if lofty could not parse the file.
142    ///
143    /// Note that if lofty cannot parse the file, that **does not** mean that symphonia cannot play it.
144    #[must_use]
145    pub fn file_type(&self) -> Option<FileType> {
146        self.file_type
147    }
148
149    /// Create new [`TrackData`] with only the path.
150    ///
151    /// This should mainly be used for tests only.
152    #[must_use]
153    pub fn new(path: PathBuf) -> Self {
154        Self {
155            path,
156            album: None,
157            file_type: None,
158        }
159    }
160}
161
162#[derive(Debug, Clone, PartialEq)]
163pub enum MediaTypes {
164    Track(TrackData),
165    Radio(RadioTrackData),
166    Podcast(PodcastTrackData),
167}
168
169#[derive(Debug, Clone, PartialEq)]
170pub struct LyricData {
171    pub raw_lyrics: Vec<Id3Lyrics>,
172    pub parsed_lyrics: Option<Lyric>,
173}
174
175type PictureCache = LruCache<PathBuf, Arc<Picture>>;
176type LyricCache = LruCache<PathBuf, Arc<LyricData>>;
177
178// NOTE: thread_locals are like "LazyLock"s, they only get initialized on first access.
179std::thread_local! {
180    static PICTURE_CACHE: RefCell<PictureCache> = RefCell::new(PictureCache::new(NonZeroUsize::new(5).unwrap()));
181    static LYRIC_CACHE: RefCell<LyricCache> = RefCell::new(LyricCache::new(NonZeroUsize::new(5).unwrap()));
182}
183
184#[derive(Debug, Clone)]
185pub struct Track {
186    inner: MediaTypes,
187
188    duration: Option<Duration>,
189    title: Option<String>,
190    artist: Option<String>,
191}
192
193impl PartialEq for Track {
194    fn eq(&self, other: &Self) -> bool {
195        self.inner == other.inner
196    }
197}
198
199impl Track {
200    /// Create a new Track instance from a Podcast Episode from the database
201    #[must_use]
202    pub fn from_podcast_episode(ep: &Episode) -> Self {
203        let localfile = ep.path.as_ref().take_if(|v| v.exists()).cloned();
204
205        let podcast_data = PodcastTrackData {
206            url: ep.url.clone(),
207            localfile,
208            image_url: ep.image_url.clone(),
209        };
210
211        let duration = ep
212            .duration
213            .map(u64::try_from)
214            .transpose()
215            .ok()
216            .flatten()
217            .map(Duration::from_secs);
218
219        Self {
220            inner: MediaTypes::Podcast(podcast_data),
221            duration,
222            title: Some(ep.title.clone()),
223            artist: None,
224        }
225    }
226
227    /// Create a new Track from a radio url
228    #[must_use]
229    pub fn new_radio<U: Into<String>>(url: U) -> Self {
230        let radio_data = RadioTrackData { url: url.into() };
231
232        Self {
233            inner: MediaTypes::Radio(radio_data),
234            duration: None,
235            // will be fetched later, maybe consider storing a cache in the database?
236            title: None,
237            artist: None,
238        }
239    }
240
241    /// Create a new Track from a local file, populated with the most important tags
242    pub fn read_track_from_path<P: Into<PathBuf>>(path: P) -> Result<Self> {
243        let path: PathBuf = path.into();
244
245        // for the case that we somehow get a path that is just ""(empty)
246        if path.as_os_str().is_empty() {
247            bail!("Given path is empty!");
248        }
249
250        let metadata = match parse_metadata_from_file(
251            &path,
252            MetadataOptions {
253                album: true,
254                artist: true,
255                title: true,
256                duration: true,
257                ..Default::default()
258            },
259        ) {
260            Ok(v) => v,
261            Err(err) => {
262                // not being able to read metadata is not fatal, we will just have less information about it
263                warn!(
264                    "Failed to read metadata from \"{}\": {}",
265                    path.display(),
266                    err
267                );
268                TrackMetadata::default()
269            }
270        };
271
272        let track_data = TrackData {
273            path,
274            album: metadata.album,
275            file_type: metadata.file_type,
276        };
277
278        Ok(Self {
279            inner: MediaTypes::Track(track_data),
280            duration: metadata.duration,
281            title: metadata.title,
282            artist: metadata.artist,
283        })
284    }
285
286    #[must_use]
287    pub fn artist(&self) -> Option<&str> {
288        self.artist.as_deref()
289    }
290
291    #[must_use]
292    pub fn title(&self) -> Option<&str> {
293        self.title.as_deref()
294    }
295
296    #[must_use]
297    pub fn duration(&self) -> Option<Duration> {
298        self.duration
299    }
300
301    /// Format the Track's duration to a short-form.
302    ///
303    /// see [`DurationFmtShort`] for formatting.
304    #[must_use]
305    pub fn duration_str_short(&self) -> Option<DurationFmtShort> {
306        let dur = self.duration?;
307
308        Some(DurationFmtShort(dur))
309    }
310
311    /// Get the main URL-identifier of the current track, if it is a type that has one.
312    ///
313    /// Only [`MediaTypes::Track`] does not have a URL at the moment.
314    #[must_use]
315    pub fn url(&self) -> Option<&str> {
316        match &self.inner {
317            MediaTypes::Track(_track_data) => None,
318            MediaTypes::Radio(radio_track_data) => Some(radio_track_data.url()),
319            MediaTypes::Podcast(podcast_track_data) => Some(podcast_track_data.url()),
320        }
321    }
322
323    /// Get the main Path-identifier of the current track, if it is a type that has one.
324    ///
325    /// Only [`MediaTypes::Track`] currently has a main Path-identifier.
326    #[must_use]
327    pub fn path(&self) -> Option<&Path> {
328        if let MediaTypes::Track(track_data) = &self.inner {
329            Some(track_data.path())
330        } else {
331            None
332        }
333    }
334
335    #[must_use]
336    pub fn as_track(&self) -> Option<&TrackData> {
337        if let MediaTypes::Track(track_data) = &self.inner {
338            Some(track_data)
339        } else {
340            None
341        }
342    }
343
344    #[must_use]
345    pub fn as_radio(&self) -> Option<&RadioTrackData> {
346        if let MediaTypes::Radio(radio_data) = &self.inner {
347            Some(radio_data)
348        } else {
349            None
350        }
351    }
352
353    #[must_use]
354    pub fn as_podcast(&self) -> Option<&PodcastTrackData> {
355        if let MediaTypes::Podcast(podcast_data) = &self.inner {
356            Some(podcast_data)
357        } else {
358            None
359        }
360    }
361
362    #[must_use]
363    pub fn inner(&self) -> &MediaTypes {
364        &self.inner
365    }
366
367    /// Get a Enum without values to check against types.
368    ///
369    /// Mainly for not having to change too many functions yet.
370    #[must_use]
371    pub fn media_type(&self) -> MediaTypesSimple {
372        match &self.inner {
373            MediaTypes::Track(_) => MediaTypesSimple::Music,
374            MediaTypes::Radio(_) => MediaTypesSimple::LiveRadio,
375            MediaTypes::Podcast(_) => MediaTypesSimple::Podcast,
376        }
377    }
378
379    /// Create a [`PlaylistTrackSource`] from the current track identifier for GRPC.
380    #[must_use]
381    pub fn as_track_source(&self) -> PlaylistTrackSource {
382        match &self.inner {
383            MediaTypes::Track(track_data) => {
384                PlaylistTrackSource::Path(track_data.path.to_string_lossy().to_string())
385            }
386            MediaTypes::Radio(radio_track_data) => {
387                PlaylistTrackSource::Url(radio_track_data.url.clone())
388            }
389            MediaTypes::Podcast(podcast_track_data) => {
390                PlaylistTrackSource::PodcastUrl(podcast_track_data.url.clone())
391            }
392        }
393    }
394
395    /// Get a cover / picture for the current track.
396    ///
397    /// Returns `Ok(None)` if there was no error, but also no picture could be found.
398    ///
399    /// This is currently **only** implemented for Music Tracks.
400    ///
401    /// # Errors
402    ///
403    /// - if reading the file fails
404    /// - if parsing the file fails
405    /// - if there is no parent in the given path
406    /// - reading the directory fails
407    /// - reading the file fails
408    /// - parsing the file as a picture fails
409    pub fn get_picture(&self) -> Result<Option<Arc<Picture>>> {
410        match &self.inner {
411            MediaTypes::Track(track_data) => {
412                let path_key = track_data.path().to_owned();
413
414                // TODO: option to disable getting with folder cover for tag editor?
415                let res = PICTURE_CACHE.with_borrow_mut(|cache| {
416                    cache
417                        .try_get_or_insert(path_key, || {
418                            let picture =
419                                get_picture_for_music_track(track_data.path()).map_err(Some)?;
420
421                            let Some(picture) = picture else {
422                                return Err(None);
423                            };
424
425                            Ok(Arc::new(picture))
426                        })
427                        .cloned()
428                });
429
430                // this has to be done as LruCache::try_get_or_insert enforces that the Ok result is the value itself, no mapping can be done.
431                match res {
432                    Ok(v) => return Ok(Some(v)),
433                    Err(None) => return Ok(None),
434                    Err(Some(err)) => return Err(err),
435                }
436            }
437            MediaTypes::Radio(_radio_track_data) => trace!("Unimplemented: radio picture"),
438            MediaTypes::Podcast(_podcast_track_data) => trace!("Unimplemented: podcast picture"),
439        }
440
441        Ok(None)
442    }
443
444    /// Get a display-able identifier
445    ///
446    /// # Panics
447    ///
448    /// If somehow a [`MediaTypes::Track`] does not have a `file_name`.
449    #[must_use]
450    pub fn id_str(&self) -> Cow<'_, str> {
451        match &self.inner {
452            // A music track will always have a file_name (and not terminate in "..")
453            MediaTypes::Track(track_data) => track_data
454                .path()
455                .file_name()
456                .map(|v| v.to_string_lossy())
457                .unwrap(),
458            MediaTypes::Radio(radio_track_data) => radio_track_data.url().into(),
459            MediaTypes::Podcast(podcast_track_data) => podcast_track_data.url().into(),
460        }
461    }
462
463    /// Get the lyrics data for the current Track.
464    ///
465    /// Only works for Music Tracks.
466    pub fn get_lyrics(&self) -> Result<Option<Arc<LyricData>>> {
467        let Some(track_data) = self.as_track() else {
468            bail!("Track is not a Music Track!");
469        };
470
471        let path_key = track_data.path().to_owned();
472
473        let res = LYRIC_CACHE.with_borrow_mut(|cache| {
474            cache
475                .try_get_or_insert(path_key, || {
476                    let result = parse_metadata_from_file(
477                        track_data.path(),
478                        MetadataOptions {
479                            lyrics: true,
480                            ..Default::default()
481                        },
482                    )?;
483                    let lyric_frames = result.lyric_frames.unwrap_or_default();
484
485                    let parsed_lyric = lyric_frames
486                        .first()
487                        .and_then(|frame| Lyric::from_str(&frame.text).ok());
488
489                    Ok(Arc::new(LyricData {
490                        raw_lyrics: lyric_frames,
491                        parsed_lyrics: parsed_lyric,
492                    }))
493                })
494                .cloned()
495        });
496
497        // this has to be done as LruCache::try_get_or_insert enforces that the Ok result is the value itself, no mapping can be done.
498        match res {
499            Ok(v) => Ok(Some(v)),
500            Err(None) => Ok(None),
501            Err(Some(err)) => Err(err),
502        }
503    }
504}
505
506impl PartialEq<PlaylistTrackSource> for &Track {
507    fn eq(&self, other: &PlaylistTrackSource) -> bool {
508        match other {
509            PlaylistTrackSource::Path(path) => self
510                .as_track()
511                .is_some_and(|v| v.path().to_string_lossy() == path.as_str()),
512            PlaylistTrackSource::Url(url) => self.as_radio().is_some_and(|v| v.url() == url),
513            PlaylistTrackSource::PodcastUrl(url) => {
514                self.as_podcast().is_some_and(|v| v.url() == url)
515            }
516        }
517    }
518}
519
520/// Try to get a [`Picture`] for a given music track.
521///
522/// # Errors
523///
524/// - if reading the file fails
525/// - if parsing the file fails
526/// - also see [`find_folder_picture`]
527fn get_picture_for_music_track(track_path: &Path) -> Result<Option<Picture>> {
528    let result = parse_metadata_from_file(
529        track_path,
530        MetadataOptions {
531            cover: true,
532            ..Default::default()
533        },
534    )?;
535
536    let Some(picture) = result.cover else {
537        let maybe_dir_pic = find_folder_picture(track_path)?;
538        return Ok(maybe_dir_pic);
539    };
540
541    Ok(Some(picture))
542}
543
544/// All extension we support to find in a parent folder of a given track to use as a cover image.
545///
546/// The matching is done via lowercase EQ.
547const SUPPORTED_IMG_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png"];
548
549/// Find a picture file and parse it in the parent directory of the given path.
550///
551/// # Errors
552///
553/// - if there is no parent in the given path
554/// - reading the directory fails
555/// - reading the file fails
556/// - parsing the file as a picture fails
557fn find_folder_picture(track_path: &Path) -> Result<Option<Picture>> {
558    let Some(parent_folder) = track_path.parent() else {
559        return Err(anyhow!("Track does not have a parent directory")
560            .context(track_path.display().to_string()));
561    };
562
563    let files = std::fs::read_dir(parent_folder).context(parent_folder.display().to_string())?;
564
565    for entry in files.flatten() {
566        let path = entry.path();
567
568        let Some(ext) = path.extension() else {
569            continue;
570        };
571
572        let Some(name) = path.file_stem() else {
573            continue;
574        };
575
576        // only take some picture files we can handle and are common
577        if !SUPPORTED_IMG_EXTENSIONS
578            .iter()
579            .any(|v| ext.eq_ignore_ascii_case(v))
580        {
581            continue;
582        }
583
584        // skip "artist.EXT" files; those may exist for standalone tracks which are in the same directory as the artist info
585        // for example this might exist when using jellyfin
586        // and the artist cover is unlikely we want as a track picture
587        if name.eq_ignore_ascii_case("artist") {
588            continue;
589        }
590
591        let mut reader = BufReader::new(File::open(path)?);
592
593        let picture = Picture::from_reader(&mut reader)?;
594
595        return Ok(Some(picture));
596    }
597
598    Ok(None)
599}
600
601/// Format the given Duration in the following way via a `Display` impl:
602///
603/// ```txt
604/// # if Hours > 0
605/// 10:01:01
606/// # if Hour == 0
607/// 01:01
608/// ```
609#[derive(Debug, Clone, Copy, PartialEq, Eq)]
610pub struct DurationFmtShort(pub Duration);
611
612impl Display for DurationFmtShort {
613    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
614        let d = self.0;
615        let duration_hour = d.as_secs() / 3600;
616        let duration_min = (d.as_secs() % 3600) / 60;
617        let duration_secs = d.as_secs() % 60;
618
619        if duration_hour == 0 {
620            write!(f, "{duration_min:0>2}:{duration_secs:0>2}")
621        } else {
622            write!(f, "{duration_hour}:{duration_min:0>2}:{duration_secs:0>2}")
623        }
624    }
625}
626
627impl DurationFmtShort {
628    /// Get the value to display if no numbers are available.
629    #[must_use]
630    pub const fn fmt_empty() -> &'static str {
631        "--:--"
632    }
633}
634
635/// See [`TrackMetadata`] for explanation of values.
636#[derive(Debug, Clone, Copy, PartialEq, Default)]
637#[allow(clippy::struct_excessive_bools)] // configuration, this is not a state machine
638pub struct MetadataOptions<'a> {
639    pub album: bool,
640    pub album_artist: bool,
641    pub album_artists: bool,
642    pub artist: bool,
643    pub artists: bool,
644    /// Separators for fallback parsing of a single `artist` value into multiple `artists`.
645    ///
646    /// See [`DEFAULT_ARTIST_SEPARATORS`].
647    pub artist_separators: &'a [&'a str],
648    pub title: bool,
649    pub duration: bool,
650    pub genre: bool,
651    pub cover: bool,
652    pub lyrics: bool,
653    pub file_times: bool,
654}
655
656impl MetadataOptions<'_> {
657    /// Enable all options
658    #[must_use]
659    pub fn all() -> Self {
660        Self {
661            album: true,
662            album_artist: true,
663            album_artists: true,
664            artist: true,
665            artists: true,
666            artist_separators: &[],
667            title: true,
668            duration: true,
669            genre: true,
670            cover: true,
671            lyrics: true,
672            file_times: true,
673        }
674    }
675}
676
677/// For ID3v2 tags consult <https://exiftool.org/TagNames/ID3.html#v2_4>.
678///
679/// For common-usage consult <https://kodi.wiki/view/Music_tagging#Tags_Kodi_reads>.
680/// For common `TXX` tags consult <https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#artists>.
681#[derive(Debug, Clone, PartialEq, Default)]
682pub struct TrackMetadata {
683    /// ID3v2 tag `TALB` or equivalent
684    pub album: Option<String>,
685    /// ID3v2 tag `TPE2` or equivalent
686    pub album_artist: Option<String>,
687    /// ID3v2 tag `TXX:ALBUMARTISTS` <https://kodi.wiki/view/Music_tagging#Tags_Kodi_reads>
688    pub album_artists: Option<Vec<String>>,
689    /// ID3v2 tag `TPE1` or equivalent
690    pub artist: Option<String>,
691    /// ID3v2 tag `TXX:ARTISTS` <https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html>
692    pub artists: Option<Vec<String>>,
693    /// ID3v2 tag `TIT2` or equivalent
694    pub title: Option<String>,
695    /// Total duration, this may or may not come from a tag
696    pub duration: Option<Duration>,
697    /// ID3v2 tag `TCON` or equivalent
698    pub genre: Option<String>,
699    /// ID3v2 tag `APIC` or equivalent
700    pub cover: Option<Picture>,
701    /// ID3v2 tags `USLT` or equivalent
702    pub lyric_frames: Option<Vec<Id3Lyrics>>,
703    pub file_times: Option<FileTimes>,
704
705    pub file_type: Option<FileType>,
706}
707
708#[derive(Debug, Clone, PartialEq, Default)]
709pub struct FileTimes {
710    pub modified: Option<SystemTime>,
711    pub created: Option<SystemTime>,
712}
713
714/// Try to parse all specified metadata in the given `options`.
715pub fn parse_metadata_from_file(
716    path: &Path,
717    options: MetadataOptions<'_>,
718) -> Result<TrackMetadata> {
719    let mut parse_options = ParseOptions::new();
720
721    parse_options = parse_options.read_cover_art(options.cover);
722
723    let probe = Probe::open(path)?.options(parse_options);
724
725    let tagged_file = probe.read()?;
726
727    let mut res = TrackMetadata::default();
728
729    if options.duration {
730        let properties = tagged_file.properties();
731        res.duration = Some(properties.duration());
732    }
733
734    res.file_type = Some(tagged_file.file_type());
735
736    if let Some(tag) = tagged_file.primary_tag() {
737        handle_tag(tag, options, &mut res);
738    } else if let Some(tag) = tagged_file.first_tag() {
739        handle_tag(tag, options, &mut res);
740    }
741
742    if options.file_times {
743        if let Ok(metadata) = std::fs::metadata(path) {
744            let filetimes = FileTimes {
745                modified: metadata.modified().ok(),
746                created: metadata.created().ok(),
747            };
748
749            res.file_times = Some(filetimes);
750        }
751    }
752
753    Ok(res)
754}
755
756/// The inner working to actually copy data from the given [`LoftyTag`] into the `res`ult
757fn handle_tag(tag: &LoftyTag, options: MetadataOptions<'_>, res: &mut TrackMetadata) {
758    if let Some(len_tag) = tag.get_string(&ItemKey::Length) {
759        match len_tag.parse::<u64>() {
760            Ok(v) => res.duration = Some(Duration::from_millis(v)),
761            Err(_) => warn!(
762                "Failed reading precise \"Length\", expected u64 parseable, got \"{len_tag:#?}\"",
763            ),
764        }
765    }
766
767    if options.artist {
768        res.artist = tag.artist().map(Cow::into_owned);
769    }
770    if options.artists {
771        let mut artists: Vec<String> = tag
772            .get_strings(&ItemKey::TrackArtists)
773            .map(ToString::to_string)
774            .collect();
775
776        if artists.is_empty() && !options.artist_separators.is_empty() {
777            if let Some(artist) = tag.artist() {
778                let artists_iter = split_artists(&artist, options);
779                artists.extend(artists_iter);
780            }
781        }
782
783        res.artists = Some(artists);
784    }
785    if options.album {
786        res.album = tag.album().map(Cow::into_owned);
787    }
788    if options.album_artist {
789        res.album_artist = tag
790            .get(&ItemKey::AlbumArtist)
791            .and_then(|v| v.value().text())
792            .map(ToString::to_string);
793    }
794    if options.album_artists {
795        // TODO: manually split if convenient tag is not available
796
797        // manual implementation as it currently does not exist upstream
798        // see https://github.com/Serial-ATA/lofty-rs/issues/522
799        // res.album_artists = Some(tag.get_strings(&ItemKey::AlbumArtists).map(ToString::to_string).collect());
800        // lofty already separates them from a "; "
801        let mut album_artists: Vec<String> = tag
802            .get_strings(&ItemKey::Unknown("ALBUMARTISTS".to_string()))
803            .map(ToString::to_string)
804            .collect();
805
806        if album_artists.is_empty() && !options.artist_separators.is_empty() {
807            if let Some(album_artist) = tag
808                .get(&ItemKey::AlbumArtist)
809                .and_then(|v| v.value().text())
810            {
811                let artists_iter = split_artists(album_artist, options);
812                album_artists.extend(artists_iter);
813            }
814        }
815
816        res.album_artists = Some(album_artists);
817    }
818    if options.title {
819        res.title = tag.title().map(Cow::into_owned);
820    }
821    if options.genre {
822        res.genre = tag.genre().map(Cow::into_owned);
823    }
824
825    if options.cover {
826        res.cover = tag
827            .pictures()
828            .iter()
829            .find(|pic| pic.pic_type() == PictureType::CoverFront)
830            .or_else(|| tag.pictures().first())
831            .cloned();
832    }
833
834    if options.lyrics {
835        let mut lyric_frames: Vec<Id3Lyrics> = Vec::new();
836        get_lyrics_from_tags(tag, &mut lyric_frames);
837        res.lyric_frames = Some(lyric_frames);
838    }
839}
840
841/// Create a iterator which separates `artist` with options from `options`
842#[inline]
843fn split_artists<'a>(
844    artist: &'a str,
845    options: MetadataOptions<'a>,
846) -> impl Iterator<Item = String> + 'a {
847    SplitArrayIter::new(artist, options.artist_separators)
848        .map(str::trim)
849        .filter(|v| !v.is_empty())
850        .map(ToString::to_string)
851}
852
853/// Fetch all lyrics from the given Lofty tag into the given array.
854fn get_lyrics_from_tags(tag: &LoftyTag, lyric_frames: &mut Vec<Id3Lyrics>) {
855    let lyrics = tag.get_items(&ItemKey::Lyrics);
856    for lyric in lyrics {
857        if let ItemValue::Text(lyrics_text) = lyric.value() {
858            lyric_frames.push(Id3Lyrics {
859                lang: lyric.lang().escape_ascii().to_string(),
860                description: lyric.description().to_string(),
861                text: lyrics_text.clone(),
862            });
863        }
864    }
865
866    lyric_frames.sort_by(|a, b| {
867        a.description
868            .to_lowercase()
869            .cmp(&b.description.to_lowercase())
870    });
871}
872
873#[cfg(test)]
874mod tests {
875    mod durationfmt {
876        use std::time::Duration;
877
878        use crate::track::DurationFmtShort;
879
880        #[test]
881        fn should_format_without_hours() {
882            assert_eq!(
883                DurationFmtShort(Duration::from_secs(61)).to_string(),
884                "01:01"
885            );
886        }
887
888        #[test]
889        fn should_format_with_hours() {
890            assert_eq!(
891                DurationFmtShort(Duration::from_secs(60 * 61 + 1)).to_string(),
892                "1:01:01"
893            );
894        }
895    }
896}