termusiclib/
track.rs

1use crate::library_db::const_unknown::{UNKNOWN_ARTIST, UNKNOWN_TITLE};
2use crate::podcast::episode::Episode;
3/**
4 * MIT License
5 *
6 * termusic - Copyright (c) 2021 Larry Hao
7 *
8 * Permission is hereby granted, free of charge, to any person obtaining a copy
9 * of this software and associated documentation files (the "Software"), to deal
10 * in the Software without restriction, including without limitation the rights
11 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12 * copies of the Software, and to permit persons to whom the Software is
13 * furnished to do so, subject to the following conditions:
14 *
15 * The above copyright notice and this permission notice shall be included in all
16 * copies or substantial portions of the Software.
17 *
18 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE US OR OTHER DEALINGS IN THE
24 * SOFTWARE.
25 */
26use crate::songtag::lrc::Lyric;
27use crate::utils::get_parent_folder;
28use anyhow::{bail, Context, Result};
29use id3::frame::Lyrics as Id3Lyrics;
30use lofty::config::WriteOptions;
31use lofty::id3::v2::{Frame, Id3v2Tag, UnsynchronizedTextFrame};
32use lofty::picture::{Picture, PictureType};
33use lofty::prelude::{Accessor, AudioFile, ItemKey, TagExt, TaggedFileExt};
34use lofty::tag::{ItemValue, Tag as LoftyTag, TagItem};
35use lofty::{file::FileType, probe::Probe};
36use std::convert::From;
37use std::ffi::OsStr;
38use std::fs::rename;
39use std::path::{Path, PathBuf};
40use std::str::FromStr;
41use std::time::{Duration, SystemTime};
42
43/// Location types for a Track, could be a local file with [`LocationType::Path`] or a remote URI with [`LocationType::Uri`]
44#[derive(Clone, Debug, PartialEq)]
45pub enum LocationType {
46    /// A Local file, for use with [`MediaType::Music`]
47    Path(PathBuf),
48    /// A remote URI, for use with [`MediaType::LiveRadio`] and [`MediaType::Podcast`]
49    Uri(String),
50}
51
52impl From<PathBuf> for LocationType {
53    fn from(value: PathBuf) -> Self {
54        Self::Path(value)
55    }
56}
57
58#[derive(Clone, Debug)]
59pub struct Track {
60    /// The URI or the Path of the file
61    location: LocationType,
62    pub media_type: MediaType,
63
64    /// Artist of the song
65    artist: Option<String>,
66    /// Album of the song
67    album: Option<String>,
68    /// Title of the song
69    title: Option<String>,
70    /// Duration of the song
71    duration: Duration,
72    pub last_modified: SystemTime,
73    /// USLT lyrics
74    lyric_frames: Vec<Id3Lyrics>,
75    lyric_selected_index: usize,
76    parsed_lyric: Option<Lyric>,
77    picture: Option<Picture>,
78    album_photo: Option<String>,
79    file_type: Option<FileType>,
80    // Date
81    // Track
82    genre: Option<String>,
83    // Composer
84    // Performer
85    // Disc
86    // Comment
87    pub podcast_localfile: Option<String>,
88}
89
90impl PartialEq for Track {
91    fn eq(&self, other: &Self) -> bool {
92        self.location == other.location
93    }
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum MediaType {
98    Music,
99    Podcast,
100    LiveRadio,
101}
102
103impl Track {
104    /// Create a new [`MediaType::Podcast`] track
105    #[allow(clippy::cast_sign_loss)]
106    #[must_use]
107    pub fn from_episode(ep: &Episode) -> Self {
108        let lyric_frames: Vec<Id3Lyrics> = Vec::new();
109        let mut podcast_localfile: Option<String> = None;
110        if let Some(path) = &ep.path {
111            if path.exists() {
112                podcast_localfile = Some(path.to_string_lossy().to_string());
113            }
114        }
115
116        Self {
117            artist: Some("Episode".to_string()),
118            album: None,
119            title: Some(ep.title.clone()),
120            location: LocationType::Uri(ep.url.clone()),
121            duration: Duration::from_secs(ep.duration.unwrap_or(0) as u64),
122            last_modified: SystemTime::now(),
123            lyric_frames,
124            lyric_selected_index: 0,
125            parsed_lyric: None,
126            picture: None,
127            album_photo: ep.image_url.clone(),
128            file_type: None,
129            genre: None,
130            media_type: MediaType::Podcast,
131            podcast_localfile,
132        }
133    }
134
135    /// Create a new [`MediaType::Music`] track
136    pub fn read_from_path<P: AsRef<Path>>(path: P, for_db: bool) -> Result<Self> {
137        let path = path.as_ref();
138
139        let probe = Probe::open(path)?;
140
141        let mut song = Self::new(LocationType::Path(path.to_path_buf()), MediaType::Music);
142        let tagged_file = match probe.read() {
143            Ok(v) => Some(v),
144            Err(err) => {
145                warn!(
146                    "Failed to read metadata from \"{}\": {}",
147                    path.display(),
148                    err
149                );
150                None
151            }
152        };
153
154        if let Some(mut tagged_file) = tagged_file {
155            // We can at most get the duration and file type at this point
156            let properties = tagged_file.properties();
157            song.duration = properties.duration();
158            song.file_type = Some(tagged_file.file_type());
159
160            if let Some(tag) = tagged_file.primary_tag_mut() {
161                Self::process_tag(tag, &mut song, for_db)?;
162            } else if let Some(tag) = tagged_file.first_tag_mut() {
163                Self::process_tag(tag, &mut song, for_db)?;
164            } else {
165                warn!("File \"{}\" does not have any tags!", path.display());
166            }
167        }
168
169        // exit early if its for db only as no cover is needed there
170        if for_db {
171            return Ok(song);
172        }
173
174        let parent_folder = get_parent_folder(path);
175
176        if let Ok(files) = std::fs::read_dir(parent_folder) {
177            for f in files.flatten() {
178                let path = f.path();
179                if let Some(extension) = path.extension() {
180                    if extension == "jpg" || extension == "png" {
181                        song.album_photo = Some(path.to_string_lossy().to_string());
182                    }
183                }
184            }
185        }
186
187        Ok(song)
188    }
189
190    /// Process a given [`LoftyTag`] into the given `track`
191    fn process_tag(tag: &mut LoftyTag, track: &mut Track, for_db: bool) -> Result<()> {
192        // Check for a length tag (Ex. TLEN in ID3v2)
193        if let Some(len_tag) = tag.get_string(&ItemKey::Length) {
194            track.duration = Duration::from_millis(len_tag.parse::<u64>()?);
195        }
196
197        track.artist = tag.artist().map(std::borrow::Cow::into_owned);
198        track.album = tag.album().map(std::borrow::Cow::into_owned);
199        track.title = tag.title().map(std::borrow::Cow::into_owned);
200        track.genre = tag.genre().map(std::borrow::Cow::into_owned);
201        track.media_type = MediaType::Music;
202
203        if for_db {
204            return Ok(());
205        }
206
207        // Get all of the lyrics tags
208        let mut lyric_frames: Vec<Id3Lyrics> = Vec::new();
209        create_lyrics(tag, &mut lyric_frames);
210
211        track.parsed_lyric = lyric_frames
212            .first()
213            .and_then(|lf| Lyric::from_str(&lf.text).ok());
214        track.lyric_frames = lyric_frames;
215
216        // Get the picture (not necessarily the front cover)
217        let picture = tag
218            .pictures()
219            .iter()
220            .find(|pic| pic.pic_type() == PictureType::CoverFront)
221            .or_else(|| tag.pictures().first())
222            .cloned();
223
224        track.picture = picture;
225
226        Ok(())
227    }
228
229    /// Create a new [`MediaType::LiveRadio`] track
230    #[must_use]
231    pub fn new_radio(url: &str) -> Self {
232        let mut track = Self::new(LocationType::Uri(url.to_string()), MediaType::LiveRadio);
233        track.artist = Some("Radio".to_string());
234        track.title = Some("Radio Station".to_string());
235        track.album = Some("Live".to_string());
236        track
237    }
238
239    #[must_use]
240    fn new(location: LocationType, media_type: MediaType) -> Self {
241        let duration = Duration::from_secs(0);
242        let lyric_frames: Vec<Id3Lyrics> = Vec::new();
243        let mut last_modified = SystemTime::now();
244        let mut title = None;
245
246        if let LocationType::Path(path) = &location {
247            if let Ok(meta) = path.metadata() {
248                if let Ok(modified) = meta.modified() {
249                    last_modified = modified;
250                }
251            }
252
253            title = path.file_stem().and_then(OsStr::to_str).map(String::from);
254        }
255
256        Self {
257            file_type: None,
258            artist: None,
259            album: None,
260            title,
261            duration,
262            location,
263            parsed_lyric: None,
264            lyric_frames,
265            lyric_selected_index: 0,
266            picture: None,
267            album_photo: None,
268            last_modified,
269            genre: None,
270            media_type,
271            podcast_localfile: None,
272        }
273    }
274
275    pub fn adjust_lyric_delay(&mut self, time_pos: Duration, offset: i64) -> Result<()> {
276        if let Some(lyric) = self.parsed_lyric.as_mut() {
277            lyric.adjust_offset(time_pos, offset);
278            let text = lyric.as_lrc_text();
279            self.set_lyric(&text, "Adjusted");
280            self.save_tag()?;
281        }
282        Ok(())
283    }
284
285    pub fn cycle_lyrics(&mut self) -> Result<&Id3Lyrics> {
286        if self.lyric_frames_is_empty() {
287            bail!("no lyrics embedded");
288        }
289
290        self.lyric_selected_index += 1;
291        if self.lyric_selected_index >= self.lyric_frames.len() {
292            self.lyric_selected_index = 0;
293        }
294
295        if let Some(f) = self.lyric_frames.get(self.lyric_selected_index) {
296            if let Ok(parsed_lyric) = Lyric::from_str(&f.text) {
297                self.parsed_lyric = Some(parsed_lyric);
298                return Ok(f);
299            }
300        }
301
302        bail!("cycle lyrics error")
303    }
304
305    #[must_use]
306    pub const fn parsed_lyric(&self) -> Option<&Lyric> {
307        self.parsed_lyric.as_ref()
308    }
309
310    pub fn set_parsed_lyric(&mut self, pl: Option<Lyric>) {
311        self.parsed_lyric = pl;
312    }
313
314    pub fn lyric_frames_remove_selected(&mut self) {
315        self.lyric_frames.remove(self.lyric_selected_index);
316    }
317
318    pub fn set_lyric_selected_index(&mut self, index: usize) {
319        self.lyric_selected_index = index;
320    }
321
322    #[must_use]
323    pub const fn lyric_selected_index(&self) -> usize {
324        self.lyric_selected_index
325    }
326
327    #[must_use]
328    pub fn lyric_selected(&self) -> Option<&Id3Lyrics> {
329        if self.lyric_frames.is_empty() {
330            return None;
331        }
332        if let Some(lf) = self.lyric_frames.get(self.lyric_selected_index) {
333            return Some(lf);
334        }
335        None
336    }
337
338    #[must_use]
339    pub fn lyric_frames_is_empty(&self) -> bool {
340        self.lyric_frames.is_empty()
341    }
342
343    #[must_use]
344    pub fn lyric_frames_len(&self) -> usize {
345        if self.lyric_frames.is_empty() {
346            return 0;
347        }
348        self.lyric_frames.len()
349    }
350
351    #[must_use]
352    pub fn lyric_frames(&self) -> Option<Vec<Id3Lyrics>> {
353        if self.lyric_frames.is_empty() {
354            return None;
355        }
356        Some(self.lyric_frames.clone())
357    }
358
359    #[must_use]
360    pub const fn picture(&self) -> Option<&Picture> {
361        self.picture.as_ref()
362    }
363    #[must_use]
364    pub fn album_photo(&self) -> Option<&str> {
365        self.album_photo.as_deref()
366    }
367
368    /// Optionally return the artist of the song
369    /// If `None` it wasn't able to read the tags
370    #[must_use]
371    pub fn artist(&self) -> Option<&str> {
372        self.artist.as_deref()
373    }
374
375    pub fn set_artist(&mut self, a: &str) {
376        self.artist = Some(a.to_string());
377    }
378
379    /// Optionally return the song's album
380    /// If `None` failed to read the tags
381    #[must_use]
382    pub fn album(&self) -> Option<&str> {
383        self.album.as_deref()
384    }
385
386    pub fn set_album(&mut self, album: &str) {
387        self.album = Some(album.to_string());
388    }
389
390    #[must_use]
391    pub fn genre(&self) -> Option<&str> {
392        self.genre.as_deref()
393    }
394
395    #[allow(unused)]
396    pub fn set_genre(&mut self, genre: &str) {
397        self.genre = Some(genre.to_string());
398    }
399
400    /// Optionally return the title of the song
401    /// If `None` it wasn't able to read the tags
402    #[must_use]
403    pub fn title(&self) -> Option<&str> {
404        self.title.as_deref()
405    }
406
407    pub fn set_title(&mut self, title: &str) {
408        self.title = Some(title.to_string());
409    }
410
411    /// Get the full Path or URI of the track, if its a local file
412    #[must_use]
413    pub fn file(&self) -> Option<&str> {
414        match &self.location {
415            LocationType::Path(path_buf) => path_buf.to_str(),
416            LocationType::Uri(uri) => Some(uri),
417        }
418    }
419
420    /// Get the directory the track is in, if its a local file
421    pub fn directory(&self) -> Option<&str> {
422        if let LocationType::Path(path) = &self.location {
423            // not using "utils::get_parent_directory" as if a track is "LocationType::Path", it should have a directory and a file in the path
424            path.parent().and_then(Path::to_str)
425        } else {
426            None
427        }
428    }
429
430    /// Get the extension of the track, if its a local file
431    pub fn ext(&self) -> Option<&str> {
432        if let LocationType::Path(path) = &self.location {
433            path.extension().and_then(OsStr::to_str)
434        } else {
435            None
436        }
437    }
438
439    #[must_use]
440    pub const fn duration(&self) -> Duration {
441        self.duration
442    }
443
444    #[must_use]
445    pub fn duration_formatted(&self) -> String {
446        Self::duration_formatted_short(&self.duration)
447    }
448
449    #[must_use]
450    pub fn duration_formatted_short(d: &Duration) -> String {
451        let duration_hour = d.as_secs() / 3600;
452        let duration_min = (d.as_secs() % 3600) / 60;
453        let duration_secs = d.as_secs() % 60;
454
455        if duration_hour == 0 {
456            format!("{duration_min:0>2}:{duration_secs:0>2}")
457        } else {
458            format!("{duration_hour}:{duration_min:0>2}:{duration_secs:0>2}")
459        }
460    }
461
462    /// Get the `file_name` or the full URI of the current Track
463    pub fn name(&self) -> Option<&str> {
464        match &self.location {
465            LocationType::Path(path) => path.file_name().and_then(OsStr::to_str),
466            // TODO: should this really return the uri here instead of None?
467            LocationType::Uri(uri) => Some(uri),
468        }
469    }
470
471    pub fn save_tag(&mut self) -> Result<()> {
472        match self.file_type {
473            Some(FileType::Mpeg) => {
474                if let Some(file_path) = self.file() {
475                    let mut tag = Id3v2Tag::default();
476                    self.update_tag(&mut tag);
477
478                    if !self.lyric_frames_is_empty() {
479                        if let Some(lyric_frames) = self.lyric_frames() {
480                            for l in lyric_frames {
481                                let l_frame =
482                                    Frame::UnsynchronizedText(UnsynchronizedTextFrame::new(
483                                        lofty::TextEncoding::UTF8,
484                                        l.lang.as_bytes()[0..3]
485                                            .try_into()
486                                            .with_context(|| "wrong length of language")?,
487                                        l.description,
488                                        l.text,
489                                    ));
490
491                                tag.insert(l_frame);
492                            }
493                        }
494                    }
495
496                    if let Some(any_picture) = self.picture().cloned() {
497                        tag.insert_picture(any_picture);
498                    }
499
500                    tag.save_to_path(file_path, WriteOptions::new())?;
501                }
502            }
503            _ => {
504                if let Some(file_path) = self.file() {
505                    let tag_type = match self.file_type {
506                        Some(file_type) => file_type.primary_tag_type(),
507                        None => return Ok(()),
508                    };
509
510                    let mut tag = LoftyTag::new(tag_type);
511                    self.update_tag(&mut tag);
512
513                    if !self.lyric_frames_is_empty() {
514                        if let Some(lyric_frames) = self.lyric_frames() {
515                            for l in lyric_frames {
516                                tag.push(TagItem::new(ItemKey::Lyrics, ItemValue::Text(l.text)));
517                            }
518                        }
519                    }
520
521                    if let Some(any_picture) = self.picture().cloned() {
522                        tag.push_picture(any_picture);
523                    }
524
525                    tag.save_to_path(file_path, WriteOptions::new())?;
526                }
527            }
528        }
529
530        self.rename_by_tag()?;
531        Ok(())
532    }
533
534    fn rename_by_tag(&mut self) -> Result<()> {
535        if let Some(ext) = self.ext() {
536            let new_name = format!(
537                "{}-{}.{}",
538                self.artist().unwrap_or(UNKNOWN_ARTIST),
539                self.title().unwrap_or(UNKNOWN_TITLE),
540                ext,
541            );
542
543            let new_name_path: &Path = Path::new(new_name.as_str());
544            if let Some(file) = self.file() {
545                let p_old: &Path = Path::new(file);
546                if let Some(p_prefix) = p_old.parent() {
547                    let p_new = p_prefix.join(new_name_path);
548                    rename(p_old, &p_new)?;
549                    self.location = LocationType::Path(p_new);
550                }
551            }
552        }
553
554        Ok(())
555    }
556
557    pub fn set_lyric(&mut self, lyric_str: &str, lang_ext: &str) {
558        let mut lyric_frames = self.lyric_frames.clone();
559        match self.lyric_frames.get(self.lyric_selected_index) {
560            Some(lyric_frame) => {
561                // No panic as the vec has just been cloned and using the same index into both vecs which has been checked
562                lyric_frames[self.lyric_selected_index] = Id3Lyrics {
563                    text: lyric_str.to_string(),
564                    ..lyric_frame.clone()
565                };
566            }
567            None => {
568                lyric_frames.push(Id3Lyrics {
569                    lang: "eng".to_string(),
570                    description: lang_ext.to_string(),
571                    text: lyric_str.to_string(),
572                });
573            }
574        }
575        self.lyric_frames = lyric_frames;
576    }
577
578    pub fn set_photo(&mut self, picture: Picture) {
579        self.picture = Some(picture);
580    }
581
582    fn update_tag<T: Accessor>(&self, tag: &mut T) {
583        tag.set_artist(
584            self.artist()
585                .map_or_else(|| String::from(UNKNOWN_ARTIST), str::to_string),
586        );
587
588        tag.set_title(
589            self.title()
590                .map_or_else(|| String::from(UNKNOWN_TITLE), str::to_string),
591        );
592
593        tag.set_album(self.album().map_or_else(String::new, str::to_string));
594        tag.set_genre(self.genre().map_or_else(String::new, str::to_string));
595    }
596}
597
598fn create_lyrics(tag: &mut LoftyTag, lyric_frames: &mut Vec<Id3Lyrics>) {
599    let lyrics = tag.take(&ItemKey::Lyrics);
600    for lyric in lyrics {
601        if let ItemValue::Text(lyrics_text) = lyric.value() {
602            lyric_frames.push(Id3Lyrics {
603                lang: lyric.lang().escape_ascii().to_string(),
604                description: lyric.description().to_string(),
605                text: lyrics_text.to_string(),
606            });
607            lyric_frames.sort_by(|a, b| {
608                a.description
609                    .to_lowercase()
610                    .cmp(&b.description.to_lowercase())
611            });
612        }
613    }
614}