Krolyxon
Commit 3a91ab1

rmptuisrctags.rs

186 lines · 5.6 KB · Rust

1//! Reading and writing tags of local files for the tag editor.
2
3use std::{collections::BTreeMap, path::PathBuf};
4
5use 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)]
9pub 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
23impl 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)]
86pub 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.
98pub 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)`.
140pub 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)]
155mod 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}