| 1 | //! Reading and writing tags of local files for the tag editor. |
| 2 | |
| 3 | use std::{collections::BTreeMap, path::PathBuf}; |
| 4 | |
| 5 | use lofty::{config::WriteOptions, prelude::*, tag::Tag}; |
| 6 | |
| 7 | /// The tags the editor can change, in ncmpcpp's order. |
| 8 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 9 | pub enum TagField { |
| 10 | Title, |
| 11 | Artist, |
| 12 | AlbumArtist, |
| 13 | Album, |
| 14 | Date, |
| 15 | Track, |
| 16 | Genre, |
| 17 | Composer, |
| 18 | Performer, |
| 19 | Disc, |
| 20 | Comment, |
| 21 | } |
| 22 | |
| 23 | impl TagField { |
| 24 | pub const ALL: [Self; 11] = [ |
| 25 | Self::Title, |
| 26 | Self::Artist, |
| 27 | Self::AlbumArtist, |
| 28 | Self::Album, |
| 29 | Self::Date, |
| 30 | Self::Track, |
| 31 | Self::Genre, |
| 32 | Self::Composer, |
| 33 | Self::Performer, |
| 34 | Self::Disc, |
| 35 | Self::Comment, |
| 36 | ]; |
| 37 | |
| 38 | pub fn label(self) -> &'static str { |
| 39 | match self { |
| 40 | Self::Title => "Title", |
| 41 | Self::Artist => "Artist", |
| 42 | Self::AlbumArtist => "Album Artist", |
| 43 | Self::Album => "Album", |
| 44 | Self::Date => "Date", |
| 45 | Self::Track => "Track", |
| 46 | Self::Genre => "Genre", |
| 47 | Self::Composer => "Composer", |
| 48 | Self::Performer => "Performer", |
| 49 | Self::Disc => "Disc", |
| 50 | Self::Comment => "Comment", |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | /// The tag name MPD reports for this field. |
| 55 | pub fn mpd_tag(self) -> &'static str { |
| 56 | match self { |
| 57 | Self::AlbumArtist => "AlbumArtist", |
| 58 | other => other.label(), |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | /// Whether letter-case changes make sense for this field. |
| 63 | pub fn is_text(self) -> bool { |
| 64 | !matches!(self, Self::Date | Self::Track | Self::Disc) |
| 65 | } |
| 66 | |
| 67 | fn item_key(self) -> ItemKey { |
| 68 | match self { |
| 69 | Self::Title => ItemKey::TrackTitle, |
| 70 | Self::Artist => ItemKey::TrackArtist, |
| 71 | Self::AlbumArtist => ItemKey::AlbumArtist, |
| 72 | Self::Album => ItemKey::AlbumTitle, |
| 73 | Self::Date => ItemKey::RecordingDate, |
| 74 | Self::Track => ItemKey::TrackNumber, |
| 75 | Self::Genre => ItemKey::Genre, |
| 76 | Self::Composer => ItemKey::Composer, |
| 77 | Self::Performer => ItemKey::Performer, |
| 78 | Self::Disc => ItemKey::DiscNumber, |
| 79 | Self::Comment => ItemKey::Comment, |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | /// Pending changes for one file. |
| 85 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 86 | pub struct TagWrite { |
| 87 | pub path: PathBuf, |
| 88 | /// Library-relative path, for reporting and rescanning. |
| 89 | pub uri: String, |
| 90 | /// New tag values; an empty value removes the tag. |
| 91 | pub changes: BTreeMap<TagField, String>, |
| 92 | /// A new file name (without directory), if the file is to be renamed. |
| 93 | pub rename: Option<String>, |
| 94 | } |
| 95 | |
| 96 | /// Writes the tag changes, creating the primary tag if needed, then renames |
| 97 | /// the file if requested. |
| 98 | pub fn write(write: &TagWrite) -> Result<(), String> { |
| 99 | let describe = |error: &dyn std::fmt::Display| format!("{}: {error}", write.uri); |
| 100 | if let Some(name) = &write.rename |
| 101 | && (name.trim().is_empty() || name.contains('/') || matches!(name.trim(), "." | "..")) |
| 102 | { |
| 103 | return Err(format!("{}: invalid file name “{name}”", write.uri)); |
| 104 | } |
| 105 | if !write.changes.is_empty() { |
| 106 | let mut file = lofty::read_from_path(&write.path).map_err(|e| describe(&e))?; |
| 107 | if file.primary_tag().is_none() { |
| 108 | file.insert_tag(Tag::new(file.primary_tag_type())); |
| 109 | } |
| 110 | let tag = file |
| 111 | .primary_tag_mut() |
| 112 | .ok_or_else(|| format!("{}: cannot create a tag", write.uri))?; |
| 113 | for (field, value) in &write.changes { |
| 114 | let value = value.trim(); |
| 115 | if value.is_empty() { |
| 116 | tag.remove_key(field.item_key()); |
| 117 | } else if !tag.insert_text(field.item_key(), value.to_owned()) { |
| 118 | return Err(format!( |
| 119 | "{}: the {:?} tag cannot hold {}", |
| 120 | write.uri, |
| 121 | tag.tag_type(), |
| 122 | field.label() |
| 123 | )); |
| 124 | } |
| 125 | } |
| 126 | tag.save_to_path(&write.path, WriteOptions::default()) |
| 127 | .map_err(|e| describe(&e))?; |
| 128 | } |
| 129 | if let Some(name) = &write.rename { |
| 130 | let target = write.path.with_file_name(name.trim()); |
| 131 | if target.exists() { |
| 132 | return Err(format!("{}: “{}” already exists", write.uri, name.trim())); |
| 133 | } |
| 134 | std::fs::rename(&write.path, &target).map_err(|e| describe(&e))?; |
| 135 | } |
| 136 | Ok(()) |
| 137 | } |
| 138 | |
| 139 | /// `du hast (live)` → `Du Hast (Live)`. |
| 140 | pub fn capitalize_words(value: &str) -> String { |
| 141 | let mut out = String::with_capacity(value.len()); |
| 142 | let mut at_word_start = true; |
| 143 | for ch in value.chars() { |
| 144 | if at_word_start { |
| 145 | out.extend(ch.to_uppercase()); |
| 146 | } else { |
| 147 | out.push(ch); |
| 148 | } |
| 149 | at_word_start = ch.is_whitespace() || matches!(ch, '(' | '[' | '-' | '/' | '"'); |
| 150 | } |
| 151 | out |
| 152 | } |
| 153 | |
| 154 | #[cfg(test)] |
| 155 | mod tests { |
| 156 | use super::*; |
| 157 | |
| 158 | #[test] |
| 159 | fn capitalizes_first_letters() { |
| 160 | assert_eq!( |
| 161 | capitalize_words("du hast (live) - one"), |
| 162 | "Du Hast (Live) - One" |
| 163 | ); |
| 164 | assert_eq!(capitalize_words("été wORLD"), "Été WORLD"); |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn rejects_bad_renames() { |
| 169 | let rename = |name: &str| TagWrite { |
| 170 | path: "/nonexistent/a.mp3".into(), |
| 171 | uri: "a.mp3".into(), |
| 172 | rename: Some(name.into()), |
| 173 | ..TagWrite::default() |
| 174 | }; |
| 175 | assert!( |
| 176 | write(&rename("x/y.mp3")) |
| 177 | .unwrap_err() |
| 178 | .contains("invalid file name") |
| 179 | ); |
| 180 | assert!( |
| 181 | write(&rename("..")) |
| 182 | .unwrap_err() |
| 183 | .contains("invalid file name") |
| 184 | ); |
| 185 | } |
| 186 | } |