Skip to main content

taggie/
audio_file.rs

1use std::env;
2use std::fmt;
3use std::fs;
4use std::io::{self};
5use std::path::PathBuf;
6
7pub struct AudioFile {
8    path: PathBuf,
9    file: taglib::File,
10}
11
12pub enum FileError {
13    NotAFile,
14    TaglibError(taglib::FileError),
15}
16
17impl From<taglib::FileError> for FileError {
18    fn from(error: taglib::FileError) -> Self {
19        FileError::TaglibError(error)
20    }
21}
22
23impl AudioFile {
24    pub fn new(path: PathBuf) -> Result<Self, FileError> {
25        let is_file = path
26            .metadata()
27            .map(|metadata| metadata.is_file())
28            .unwrap_or(false);
29
30        if !is_file {
31            return Err(FileError::NotAFile);
32        }
33
34        let file = taglib::File::new(&path)?;
35
36        Ok(AudioFile { path, file })
37    }
38
39    pub fn collection_to_editable_content(audio_files: &[AudioFile]) -> String {
40        let lines_with_tags: String = audio_files
41            .iter()
42            .map(AudioFile::to_editable_content_line)
43            .collect();
44
45        format!(
46            "{}\n{}",
47            "Title\tArtist\t(Remove all lines to abort the update)", lines_with_tags
48        )
49    }
50
51    pub fn from_current_dir() -> Result<Vec<AudioFile>, io::Error> {
52        let dir = env::current_dir()?;
53        let entries: Result<Vec<_>, _> = fs::read_dir(dir)?.collect();
54
55        Ok(entries?
56            .iter()
57            .filter_map(|entry| AudioFile::new(entry.path()).ok())
58            .collect())
59    }
60
61    pub fn update_tags_from_edited_content(
62        audio_files: &mut [AudioFile],
63        content: String,
64    ) -> Result<(), UpdateError> {
65        if content.trim().is_empty() {
66            return Err(UpdateError::UpdateAborted);
67        }
68
69        let number_of_lines = content.lines().count() - 1; // -1 for the header line
70
71        if audio_files.len() != number_of_lines {
72            return Err(UpdateError::LineNumberMismatch {
73                number_of_files: audio_files.len(),
74                number_of_lines,
75            });
76        }
77
78        for (audio_file, line) in audio_files.iter_mut().zip(
79            // Skip the header line.
80            content.lines().skip(1),
81        ) {
82            audio_file.update_tag_from_line(line)?
83        }
84
85        Ok(())
86    }
87
88    pub fn to_editable_content_line(&self) -> String {
89        let tag = self
90            .file
91            .tag()
92            .expect("Failed to get tag inside to_editable_content_line");
93
94        format!(
95            "{}\t{}\n",
96            tag.title().unwrap_or_default(),
97            tag.artist().unwrap_or_default()
98        )
99    }
100
101    pub fn update_tag_from_line(&mut self, line: &str) -> Result<(), UpdateError> {
102        let mut tag = match self.file.tag() {
103            Ok(tag) => tag,
104            Err(e) => return Err(UpdateError::CannotReadTag(e)),
105        };
106
107        if let [title, artist] = line.split('\t').collect::<Vec<_>>()[..] {
108            tag.set_title(title);
109            tag.set_artist(artist);
110
111            if self.file.save() {
112                Ok(())
113            } else {
114                Err(UpdateError::SaveFailed(self.path.clone()))
115            }
116        } else {
117            Err(UpdateError::InvalidLine(line.to_string()))
118        }
119    }
120}
121
122#[derive(Debug)]
123pub enum UpdateError {
124    UpdateAborted,
125    LineNumberMismatch {
126        number_of_files: usize,
127        number_of_lines: usize,
128    },
129    InvalidLine(String),
130    CannotReadTag(taglib::FileError),
131    SaveFailed(PathBuf),
132}
133
134impl fmt::Display for UpdateError {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        use UpdateError::*;
137
138        match self {
139            UpdateAborted => write!(f, "Update aborted"),
140            LineNumberMismatch {
141                number_of_files,
142                number_of_lines,
143            } => write!(
144                f,
145                "Found {}, so expected {}, but found only {}",
146                pluralize(*number_of_files, "file", "files"),
147                pluralize(*number_of_files, "line", "lines"),
148                pluralize(*number_of_lines, "line", "lines"),
149            ),
150            InvalidLine(invalid_line) => write!(
151                f,
152                "Expected the line to have format\n\n\t`title<TAB>artist`\n\nbut found this instead:\n\n\t{}",
153                invalid_line.replace('\t', "<TAB>")
154            ),
155            SaveFailed(path) => write!(f, "Failed to save updated tags to file {:?}", path.clone().into_os_string()),
156            CannotReadTag(error) => write!(f, "Failed to read tags from the file {:?}", error)
157        }
158    }
159}
160
161fn pluralize(count: usize, singular: &str, plural: &str) -> String {
162    format!("{} {}", count, if count == 1 { singular } else { plural })
163}