Skip to main content

dsf/
edit.rs

1use std::collections::BTreeMap;
2
3use id3::frame::{Comment, ExtendedText, PictureType};
4use id3::{Tag, TagLike, Version};
5
6use crate::{Error, Result};
7
8/// A validated ID3v2 text-frame identifier (`T???`, excluding `TXXX`).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
10pub struct FrameId([u8; 4]);
11
12impl FrameId {
13    /// Track title (`TIT2`).
14    pub const TITLE: Self = Self(*b"TIT2");
15    /// Content group / grouping (`TIT1`).
16    pub const GROUPING: Self = Self(*b"TIT1");
17    /// Lead artist (`TPE1`).
18    pub const ARTIST: Self = Self(*b"TPE1");
19    /// Album artist (`TPE2`).
20    pub const ALBUM_ARTIST: Self = Self(*b"TPE2");
21    /// Album (`TALB`).
22    pub const ALBUM: Self = Self(*b"TALB");
23    /// Composer (`TCOM`).
24    pub const COMPOSER: Self = Self(*b"TCOM");
25    /// Track number, `"n"` or `"n/total"` (`TRCK`).
26    pub const TRACK: Self = Self(*b"TRCK");
27    /// Disc number (`TPOS`).
28    pub const DISC: Self = Self(*b"TPOS");
29    /// Genre (`TCON`).
30    pub const GENRE: Self = Self(*b"TCON");
31    /// Recording date (`TDRC`).
32    pub const RECORDING_DATE: Self = Self(*b"TDRC");
33    /// Conductor (`TPE3`).
34    pub const CONDUCTOR: Self = Self(*b"TPE3");
35    /// Label / publisher (`TPUB`).
36    pub const PUBLISHER: Self = Self(*b"TPUB");
37    /// Copyright message (`TCOP`).
38    pub const COPYRIGHT: Self = Self(*b"TCOP");
39    /// International Standard Recording Code (`TSRC`).
40    pub const ISRC: Self = Self(*b"TSRC");
41
42    /// Validates an arbitrary frame id string.
43    pub fn new(id: &str) -> Result<Self> {
44        let bytes: [u8; 4] = id
45            .as_bytes()
46            .try_into()
47            .map_err(|_| Error::InvalidFrameId(id.to_owned()))?;
48        let valid = bytes[0] == b'T'
49            && bytes != *b"TXXX"
50            && bytes
51                .iter()
52                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit());
53        if valid {
54            Ok(Self(bytes))
55        } else {
56            Err(Error::InvalidFrameId(id.to_owned()))
57        }
58    }
59
60    /// The identifier as a string (always ASCII).
61    pub fn as_str(&self) -> &str {
62        std::str::from_utf8(&self.0).unwrap_or_default()
63    }
64}
65
66/// What to do with a single frame.
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub enum Change {
69    /// Leave the frame untouched.
70    #[default]
71    Keep,
72    /// Replace the frame's text.
73    Set(String),
74    /// Remove the frame.
75    Clear,
76}
77
78/// A front-cover image to embed (`APIC`).
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct Picture {
81    /// MIME type, for example `image/jpeg`.
82    pub mime_type: String,
83    /// The encoded image.
84    pub data: Vec<u8>,
85}
86
87/// What to do with the front-cover picture.
88#[derive(Debug, Clone, Default, PartialEq, Eq)]
89enum PictureChange {
90    #[default]
91    Keep,
92    Set(Picture),
93    Clear,
94}
95
96/// Language written with comments (ID3 uses ISO 639-2 codes).
97const COMMENT_LANGUAGE: &str = "eng";
98
99/// A set of per-frame changes. Frames not mentioned are preserved.
100#[derive(Debug, Clone, Default)]
101pub struct TagEdit {
102    frames: BTreeMap<FrameId, Change>,
103    user_text: BTreeMap<String, Change>,
104    comment: Change,
105    picture: PictureChange,
106}
107
108impl TagEdit {
109    /// An empty edit (a no-op).
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    /// Sets a text frame. A blank value is treated as [`Change::Clear`].
115    #[must_use]
116    pub fn set(mut self, id: FrameId, value: impl Into<String>) -> Self {
117        let value = value.into();
118        let change = if value.trim().is_empty() {
119            Change::Clear
120        } else {
121            Change::Set(value)
122        };
123        self.frames.insert(id, change);
124        self
125    }
126
127    /// Removes a text frame.
128    #[must_use]
129    pub fn clear(mut self, id: FrameId) -> Self {
130        self.frames.insert(id, Change::Clear);
131        self
132    }
133
134    /// Sets a user-defined text frame (`TXXX`) identified by `description`, for example
135    /// `"MOOD"`. A blank value removes it.
136    #[must_use]
137    pub fn set_user_text(
138        mut self,
139        description: impl Into<String>,
140        value: impl Into<String>,
141    ) -> Self {
142        let value = value.into();
143        let change = if value.trim().is_empty() {
144            Change::Clear
145        } else {
146            Change::Set(value)
147        };
148        self.user_text.insert(description.into(), change);
149        self
150    }
151
152    /// Removes the user-defined text frame (`TXXX`) with this description.
153    #[must_use]
154    pub fn clear_user_text(mut self, description: impl Into<String>) -> Self {
155        self.user_text.insert(description.into(), Change::Clear);
156        self
157    }
158
159    /// Sets the general comment (`COMM` with an empty description). Blank removes it.
160    #[must_use]
161    pub fn set_comment(mut self, text: impl Into<String>) -> Self {
162        let text = text.into();
163        self.comment = if text.trim().is_empty() {
164            Change::Clear
165        } else {
166            Change::Set(text)
167        };
168        self
169    }
170
171    /// Embeds `picture` as the front cover, replacing any existing front cover.
172    #[must_use]
173    pub fn set_cover(mut self, picture: Picture) -> Self {
174        self.picture = PictureChange::Set(picture);
175        self
176    }
177
178    /// Removes the front cover.
179    #[must_use]
180    pub fn clear_cover(mut self) -> Self {
181        self.picture = PictureChange::Clear;
182        self
183    }
184
185    /// True if applying this edit would change nothing.
186    pub fn is_noop(&self) -> bool {
187        self.frames.values().all(|c| *c == Change::Keep)
188            && self.user_text.values().all(|c| *c == Change::Keep)
189            && self.comment == Change::Keep
190            && self.picture == PictureChange::Keep
191    }
192
193    pub(crate) fn apply(&self, tag: &mut Tag) {
194        for (id, change) in &self.frames {
195            match change {
196                Change::Keep => {}
197                Change::Set(text) => tag.set_text(id.as_str(), text.as_str()),
198                Change::Clear => {
199                    tag.remove(id.as_str());
200                }
201            }
202        }
203        for (description, change) in &self.user_text {
204            if *change == Change::Keep {
205                continue;
206            }
207            tag.remove_extended_text(Some(description.as_str()), None);
208            if let Change::Set(value) = change {
209                tag.add_frame(ExtendedText {
210                    description: description.clone(),
211                    value: value.clone(),
212                });
213            }
214        }
215        match &self.comment {
216            Change::Keep => {}
217            change => {
218                tag.remove_comment(Some(""), None);
219                if let Change::Set(text) = change {
220                    tag.add_frame(Comment {
221                        lang: COMMENT_LANGUAGE.to_owned(),
222                        description: String::new(),
223                        text: text.clone(),
224                    });
225                }
226            }
227        }
228        match &self.picture {
229            PictureChange::Keep => {}
230            change => {
231                tag.remove_picture_by_type(PictureType::CoverFront);
232                if let PictureChange::Set(picture) = change {
233                    tag.add_frame(id3::frame::Picture {
234                        mime_type: picture.mime_type.clone(),
235                        picture_type: PictureType::CoverFront,
236                        description: String::new(),
237                        data: picture.data.clone(),
238                    });
239                }
240            }
241        }
242    }
243}
244
245/// Options for tag writes.
246#[derive(Debug, Clone, Default)]
247#[non_exhaustive]
248pub struct WriteOptions {
249    /// ID3v2 version to write. `None` preserves the existing tag's version
250    /// (new tags default to v2.4).
251    pub version: Option<Version>,
252}
253
254impl WriteOptions {
255    /// Forces a specific ID3v2 version.
256    #[must_use]
257    pub fn with_version(mut self, version: Version) -> Self {
258        self.version = Some(version);
259        self
260    }
261}