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#[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 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 #[must_use]
56 pub fn url(&self) -> &str {
57 &self.url
58 }
59
60 #[must_use]
62 pub fn localfile(&self) -> Option<&Path> {
63 self.localfile.as_deref()
64 }
65
66 #[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 #[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 url: String,
95}
96
97impl RadioTrackData {
98 #[must_use]
100 pub fn url(&self) -> &str {
101 &self.url
102 }
103
104 #[must_use]
108 pub fn new(url: String) -> Self {
109 Self { url }
110 }
111}
112
113#[derive(Debug, Clone)]
114pub struct TrackData {
115 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 #[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 #[must_use]
145 pub fn file_type(&self) -> Option<FileType> {
146 self.file_type
147 }
148
149 #[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
178std::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 #[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 #[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 title: None,
237 artist: None,
238 }
239 }
240
241 pub fn read_track_from_path<P: Into<PathBuf>>(path: P) -> Result<Self> {
243 let path: PathBuf = path.into();
244
245 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 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 #[must_use]
305 pub fn duration_str_short(&self) -> Option<DurationFmtShort> {
306 let dur = self.duration?;
307
308 Some(DurationFmtShort(dur))
309 }
310
311 #[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 #[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 #[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 #[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 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 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 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 #[must_use]
450 pub fn id_str(&self) -> Cow<'_, str> {
451 match &self.inner {
452 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 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 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
520fn 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
544const SUPPORTED_IMG_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png"];
548
549fn 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 if !SUPPORTED_IMG_EXTENSIONS
578 .iter()
579 .any(|v| ext.eq_ignore_ascii_case(v))
580 {
581 continue;
582 }
583
584 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#[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
627#[derive(Debug, Clone, Copy, PartialEq, Default)]
629#[allow(clippy::struct_excessive_bools)] pub struct MetadataOptions<'a> {
631 pub album: bool,
632 pub album_artist: bool,
633 pub album_artists: bool,
634 pub artist: bool,
635 pub artists: bool,
636 pub artist_separators: &'a [&'a str],
640 pub title: bool,
641 pub duration: bool,
642 pub genre: bool,
643 pub cover: bool,
644 pub lyrics: bool,
645 pub file_times: bool,
646}
647
648impl MetadataOptions<'_> {
649 #[must_use]
651 pub fn all() -> Self {
652 Self {
653 album: true,
654 album_artist: true,
655 album_artists: true,
656 artist: true,
657 artists: true,
658 artist_separators: &[],
659 title: true,
660 duration: true,
661 genre: true,
662 cover: true,
663 lyrics: true,
664 file_times: true,
665 }
666 }
667}
668
669#[derive(Debug, Clone, PartialEq, Default)]
674pub struct TrackMetadata {
675 pub album: Option<String>,
677 pub album_artist: Option<String>,
679 pub album_artists: Option<Vec<String>>,
681 pub artist: Option<String>,
683 pub artists: Option<Vec<String>>,
685 pub title: Option<String>,
687 pub duration: Option<Duration>,
689 pub genre: Option<String>,
691 pub cover: Option<Picture>,
693 pub lyric_frames: Option<Vec<Id3Lyrics>>,
695 pub file_times: Option<FileTimes>,
696
697 pub file_type: Option<FileType>,
698}
699
700#[derive(Debug, Clone, PartialEq, Default)]
701pub struct FileTimes {
702 pub modified: Option<SystemTime>,
703 pub created: Option<SystemTime>,
704}
705
706pub fn parse_metadata_from_file(
708 path: &Path,
709 options: MetadataOptions<'_>,
710) -> Result<TrackMetadata> {
711 let mut parse_options = ParseOptions::new();
712
713 parse_options = parse_options.read_cover_art(options.cover);
714
715 let probe = Probe::open(path)?.options(parse_options);
716
717 let tagged_file = probe.read()?;
718
719 let mut res = TrackMetadata::default();
720
721 if options.duration {
722 let properties = tagged_file.properties();
723 res.duration = Some(properties.duration());
724 }
725
726 res.file_type = Some(tagged_file.file_type());
727
728 if let Some(tag) = tagged_file.primary_tag() {
729 handle_tag(tag, options, &mut res);
730 } else if let Some(tag) = tagged_file.first_tag() {
731 handle_tag(tag, options, &mut res);
732 }
733
734 if options.file_times {
735 if let Ok(metadata) = std::fs::metadata(path) {
736 let filetimes = FileTimes {
737 modified: metadata.modified().ok(),
738 created: metadata.created().ok(),
739 };
740
741 res.file_times = Some(filetimes);
742 }
743 }
744
745 Ok(res)
746}
747
748fn handle_tag(tag: &LoftyTag, options: MetadataOptions<'_>, res: &mut TrackMetadata) {
750 if let Some(len_tag) = tag.get_string(&ItemKey::Length) {
751 match len_tag.parse::<u64>() {
752 Ok(v) => res.duration = Some(Duration::from_millis(v)),
753 Err(_) => warn!(
754 "Failed reading precise \"Length\", expected u64 parseable, got \"{len_tag:#?}\"",
755 ),
756 }
757 }
758
759 if options.artist {
760 res.artist = tag.artist().map(Cow::into_owned);
761 }
762 if options.artists {
763 let mut artists: Vec<String> = tag
764 .get_strings(&ItemKey::TrackArtists)
765 .map(ToString::to_string)
766 .collect();
767
768 if artists.is_empty() && !options.artist_separators.is_empty() {
769 if let Some(artist) = tag.artist() {
770 let artists_iter = split_artists(&artist, options);
771 artists.extend(artists_iter);
772 }
773 }
774
775 res.artists = Some(artists);
776 }
777 if options.album {
778 res.album = tag.album().map(Cow::into_owned);
779 }
780 if options.album_artist {
781 res.album_artist = tag
782 .get(&ItemKey::AlbumArtist)
783 .and_then(|v| v.value().text())
784 .map(ToString::to_string);
785 }
786 if options.album_artists {
787 let mut album_artists: Vec<String> = tag
794 .get_strings(&ItemKey::Unknown("ALBUMARTISTS".to_string()))
795 .map(ToString::to_string)
796 .collect();
797
798 if album_artists.is_empty() && !options.artist_separators.is_empty() {
799 if let Some(album_artist) = tag
800 .get(&ItemKey::AlbumArtist)
801 .and_then(|v| v.value().text())
802 {
803 let artists_iter = split_artists(album_artist, options);
804 album_artists.extend(artists_iter);
805 }
806 }
807
808 res.album_artists = Some(album_artists);
809 }
810 if options.title {
811 res.title = tag.title().map(Cow::into_owned);
812 }
813 if options.genre {
814 res.genre = tag.genre().map(Cow::into_owned);
815 }
816
817 if options.cover {
818 res.cover = tag
819 .pictures()
820 .iter()
821 .find(|pic| pic.pic_type() == PictureType::CoverFront)
822 .or_else(|| tag.pictures().first())
823 .cloned();
824 }
825
826 if options.lyrics {
827 let mut lyric_frames: Vec<Id3Lyrics> = Vec::new();
828 get_lyrics_from_tags(tag, &mut lyric_frames);
829 res.lyric_frames = Some(lyric_frames);
830 }
831}
832
833#[inline]
835fn split_artists<'a>(
836 artist: &'a str,
837 options: MetadataOptions<'a>,
838) -> impl Iterator<Item = String> + 'a {
839 SplitArrayIter::new(artist, options.artist_separators)
840 .map(str::trim)
841 .filter(|v| !v.is_empty())
842 .map(ToString::to_string)
843}
844
845fn get_lyrics_from_tags(tag: &LoftyTag, lyric_frames: &mut Vec<Id3Lyrics>) {
847 let lyrics = tag.get_items(&ItemKey::Lyrics);
848 for lyric in lyrics {
849 if let ItemValue::Text(lyrics_text) = lyric.value() {
850 lyric_frames.push(Id3Lyrics {
851 lang: lyric.lang().escape_ascii().to_string(),
852 description: lyric.description().to_string(),
853 text: lyrics_text.clone(),
854 });
855 }
856 }
857
858 lyric_frames.sort_by(|a, b| {
859 a.description
860 .to_lowercase()
861 .cmp(&b.description.to_lowercase())
862 });
863}
864
865#[cfg(test)]
866mod tests {
867 mod durationfmt {
868 use std::time::Duration;
869
870 use crate::track::DurationFmtShort;
871
872 #[test]
873 fn should_format_without_hours() {
874 assert_eq!(
875 DurationFmtShort(Duration::from_secs(61)).to_string(),
876 "01:01"
877 );
878 }
879
880 #[test]
881 fn should_format_with_hours() {
882 assert_eq!(
883 DurationFmtShort(Duration::from_secs(60 * 61 + 1)).to_string(),
884 "1:01:01"
885 );
886 }
887 }
888}