Skip to main content

sift/
meta.rs

1//! Reading and writing tags.
2//!
3//! Tags are written through lofty's generic keys, which it maps to each
4//! format's native frame — Vorbis comments for FLAC and Ogg, ID3v2 for MP3,
5//! atoms for MP4 — so the same [`Tags`] lands correctly everywhere.
6
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use lofty::config::{ParseOptions, WriteOptions};
11use lofty::file::{AudioFile, TaggedFileExt};
12use lofty::picture::{MimeType, Picture, PictureType};
13use lofty::tag::{Accessor, ItemKey, Tag, TagExt};
14
15#[derive(Debug, thiserror::Error)]
16pub enum MetaError {
17    #[error("{path}: {source}")]
18    Lofty {
19        path: PathBuf,
20        source: Box<dyn std::error::Error + Send + Sync>,
21    },
22    #[error("{0}: no tag could be created for this file type")]
23    NoTag(PathBuf),
24}
25
26const AUDIO: &[&str] = &[
27    "flac", "mp3", "m4a", "mp4", "aac", "alac", "ogg", "oga", "opus", "wav", "aiff", "aif", "ape",
28    "wv",
29];
30
31pub fn is_audio(path: &Path) -> bool {
32    path.extension()
33        .and_then(|e| e.to_str())
34        .is_some_and(|e| AUDIO.contains(&e.to_ascii_lowercase().as_str()))
35}
36
37/// What a file says about itself.
38#[derive(Debug, Clone, Default)]
39pub struct Track {
40    pub path: PathBuf,
41    pub title: Option<String>,
42    pub artist: Option<String>,
43    pub album: Option<String>,
44    pub album_artist: Option<String>,
45    pub track: Option<u32>,
46    pub track_total: Option<u32>,
47    pub disc: Option<u32>,
48    pub disc_total: Option<u32>,
49    pub date: Option<String>,
50    pub original_date: Option<String>,
51    /// Filed with the compilation template.
52    pub compilation: bool,
53    pub genre: Option<String>,
54    pub mb_recording_id: Option<String>,
55    pub mb_album_id: Option<String>,
56    pub duration: Duration,
57    /// `FLAC`, `MP3`, `AAC`… as beets' `$format` spells it.
58    pub format: String,
59    pub bitrate: Option<u32>,
60    pub sample_rate: Option<u32>,
61    pub bit_depth: Option<u8>,
62}
63
64fn lofty<E: std::error::Error + Send + Sync + 'static>(
65    path: &Path,
66) -> impl FnOnce(E) -> MetaError + '_ {
67    move |source| MetaError::Lofty {
68        path: path.to_path_buf(),
69        source: Box::new(source),
70    }
71}
72
73/// lofty refuses any tag that would allocate past a global cap, 16 MB by
74/// default — small enough that one record with 4000×4000 art in its Vorbis
75/// comments fails to parse at all. The cap exists to stop a hostile file
76/// exhausting memory, which 256 MB still does.
77fn raise_allocation_limit() {
78    static ONCE: std::sync::Once = std::sync::Once::new();
79    ONCE.call_once(|| {
80        lofty::config::apply_global_options(
81            lofty::config::GlobalOptions::new().allocation_limit(256 * 1024 * 1024),
82        );
83    });
84}
85
86/// Read one file's tags. A corrupt tag can panic inside the parser; that is
87/// contained here and reported as this file's error, rather than taking down
88/// whatever was reading a whole folder.
89pub fn read(path: &Path) -> Result<Track, MetaError> {
90    raise_allocation_limit();
91    std::panic::catch_unwind(|| read_inner(path)).unwrap_or_else(|_| {
92        Err(MetaError::Lofty {
93            path: path.to_path_buf(),
94            source: "the tag parser panicked on this file".into(),
95        })
96    })
97}
98
99fn read_inner(path: &Path) -> Result<Track, MetaError> {
100    let file = lofty::probe::Probe::open(path)
101        .map_err(lofty(path))?
102        .options(ParseOptions::new().read_cover_art(false))
103        .read()
104        .map_err(lofty(path))?;
105    let props = file.properties();
106    let mut t = Track {
107        path: path.to_path_buf(),
108        duration: props.duration(),
109        // An MP4 carries AAC or ALAC; lofty reports a bit depth only for
110        // the lossless one, so that is what tells them apart here.
111        format: match (file.file_type(), props.bit_depth()) {
112            (lofty::file::FileType::Mp4, Some(_)) => "ALAC",
113            (ft, _) => format_name(ft),
114        }
115        .to_string(),
116        bitrate: props.audio_bitrate(),
117        sample_rate: props.sample_rate(),
118        bit_depth: props.bit_depth(),
119        ..Default::default()
120    };
121    if let Some(tag) = file.primary_tag().or_else(|| file.first_tag()) {
122        let s = |k: ItemKey| {
123            tag.get_string(k)
124                .map(str::to_string)
125                .filter(|v| !v.trim().is_empty())
126        };
127        t.title = tag.title().map(|c| c.to_string());
128        t.artist = tag.artist().map(|c| c.to_string());
129        t.album = tag.album().map(|c| c.to_string());
130        t.album_artist = s(ItemKey::AlbumArtist);
131        t.track = tag.track();
132        t.track_total = tag.track_total();
133        // Taggers write disc 0 for single-disc releases; it means no disc.
134        t.disc = tag.disk().filter(|d| *d > 0);
135        t.disc_total = tag.disk_total();
136        t.date = s(ItemKey::RecordingDate).or_else(|| s(ItemKey::Year));
137        t.original_date = s(ItemKey::OriginalReleaseDate);
138        t.compilation = s(ItemKey::FlagCompilation).is_some_and(|v| v == "1");
139        t.genre = tag.genre().map(|c| c.to_string());
140        t.mb_recording_id = s(ItemKey::MusicBrainzRecordingId);
141        t.mb_album_id = s(ItemKey::MusicBrainzReleaseId);
142    }
143    Ok(t)
144}
145
146fn format_name(ft: lofty::file::FileType) -> &'static str {
147    use lofty::file::FileType as F;
148    match ft {
149        F::Flac => "FLAC",
150        F::Mpeg => "MP3",
151        F::Mp4 => "AAC",
152        F::Vorbis => "OGG",
153        F::Opus => "Opus",
154        F::Wav => "WAV",
155        F::Aiff => "AIFF",
156        F::Ape => "APE",
157        F::WavPack => "WavPack",
158        _ => "Unknown",
159    }
160}
161
162/// Everything sift writes. `None` removes the field, so a retag never leaves
163/// a stale value from the source behind.
164#[derive(Debug, Clone, Default, PartialEq)]
165pub struct Tags {
166    pub title: String,
167    pub artist: String,
168    pub album: String,
169    pub album_artist: String,
170    pub track: u32,
171    pub track_total: u32,
172    pub disc: u32,
173    pub disc_total: u32,
174    pub date: Option<String>,
175    pub original_date: Option<String>,
176    pub label: Option<String>,
177    pub catalog_number: Option<String>,
178    pub country: Option<String>,
179    pub media: Option<String>,
180    pub compilation: bool,
181    pub mb_recording_id: Option<String>,
182    pub mb_track_id: Option<String>,
183    pub mb_album_id: Option<String>,
184    pub mb_artist_id: Option<String>,
185    pub mb_album_artist_id: Option<String>,
186    pub mb_release_group_id: Option<String>,
187}
188
189/// Write `tags` (and a front cover, if given) into `path`, replacing
190/// whatever was there. With `preserve_mtime`, the file's modification time
191/// is restored afterwards, as beets' `importadded` plugin: retagging on
192/// import should not make an old file look newly changed.
193pub fn write(
194    path: &Path,
195    tags: &Tags,
196    cover: Option<&[u8]>,
197    preserve_mtime: bool,
198) -> Result<(), MetaError> {
199    raise_allocation_limit();
200    let original_mtime = if preserve_mtime {
201        path.metadata().ok().and_then(|m| m.modified().ok())
202    } else {
203        None
204    };
205    let mut file = lofty::probe::Probe::open(path)
206        .map_err(lofty(path))?
207        .read()
208        .map_err(lofty(path))?;
209    let tag_type = file.primary_tag_type();
210    if file.tag(tag_type).is_none() {
211        file.insert_tag(Tag::new(tag_type));
212    }
213    let tag = file
214        .tag_mut(tag_type)
215        .ok_or_else(|| MetaError::NoTag(path.to_path_buf()))?;
216
217    tag.set_title(tags.title.clone());
218    tag.set_artist(tags.artist.clone());
219    tag.set_album(tags.album.clone());
220    tag.insert_text(ItemKey::AlbumArtist, tags.album_artist.clone());
221    tag.set_track(tags.track);
222    tag.set_track_total(tags.track_total);
223    tag.set_disk(tags.disc);
224    tag.set_disk_total(tags.disc_total);
225    let optional = [
226        (ItemKey::RecordingDate, &tags.date),
227        (ItemKey::OriginalReleaseDate, &tags.original_date),
228        (ItemKey::Label, &tags.label),
229        (ItemKey::CatalogNumber, &tags.catalog_number),
230        (ItemKey::ReleaseCountry, &tags.country),
231        (ItemKey::OriginalMediaType, &tags.media),
232        (ItemKey::MusicBrainzRecordingId, &tags.mb_recording_id),
233        (ItemKey::MusicBrainzTrackId, &tags.mb_track_id),
234        (ItemKey::MusicBrainzReleaseId, &tags.mb_album_id),
235        (ItemKey::MusicBrainzArtistId, &tags.mb_artist_id),
236        (
237            ItemKey::MusicBrainzReleaseArtistId,
238            &tags.mb_album_artist_id,
239        ),
240        (
241            ItemKey::MusicBrainzReleaseGroupId,
242            &tags.mb_release_group_id,
243        ),
244    ];
245    for (key, value) in optional {
246        tag.remove_key(key);
247        if let Some(v) = value {
248            tag.insert_text(key, v.clone());
249        }
250    }
251    tag.remove_key(ItemKey::FlagCompilation);
252    if tags.compilation {
253        tag.insert_text(ItemKey::FlagCompilation, "1".into());
254    }
255    if let Some(data) = cover {
256        tag.remove_picture_type(PictureType::CoverFront);
257        let mime = if data.starts_with(&[0x89, b'P', b'N', b'G']) {
258            MimeType::Png
259        } else {
260            MimeType::Jpeg
261        };
262        tag.push_picture(
263            Picture::unchecked(data.to_vec())
264                .pic_type(PictureType::CoverFront)
265                .mime_type(mime)
266                .build(),
267        );
268    }
269    tag.save_to_path(path, WriteOptions::default())
270        .map_err(lofty(path))?;
271    if let Some(modified) = original_mtime {
272        std::fs::File::options()
273            .write(true)
274            .open(path)
275            .and_then(|f| f.set_modified(modified))
276            .map_err(lofty(path))?;
277    }
278    Ok(())
279}
280
281/// A field to set, by its beets name, or to clear with `None`.
282pub type Change = (String, Option<String>);
283
284/// A field `modify` can change, by its beets name.
285pub const EDITABLE: &[&str] = &[
286    "title",
287    "artist",
288    "album",
289    "albumartist",
290    "track",
291    "tracktotal",
292    "disc",
293    "disctotal",
294    "date",
295    "year",
296    "original_date",
297    "genre",
298    "label",
299    "catalognum",
300    "comp",
301];
302
303/// Set or clear only the named fields in `path`, leaving every other tag as
304/// it is. [`write`] replaces everything sift knows about, which is right for
305/// an import and wrong for correcting one field of a filed album.
306pub fn set(path: &Path, changes: &[Change]) -> Result<(), MetaError> {
307    raise_allocation_limit();
308    let mut file = lofty::probe::Probe::open(path)
309        .map_err(lofty(path))?
310        .read()
311        .map_err(lofty(path))?;
312    let tag_type = file.primary_tag_type();
313    if file.tag(tag_type).is_none() {
314        file.insert_tag(Tag::new(tag_type));
315    }
316    let tag = file
317        .tag_mut(tag_type)
318        .ok_or_else(|| MetaError::NoTag(path.to_path_buf()))?;
319    for (field, value) in changes {
320        let number = || value.as_deref().and_then(|v| v.parse::<u32>().ok());
321        match field.as_str() {
322            "track" => match number() {
323                Some(n) => tag.set_track(n),
324                None => tag.remove_track(),
325            },
326            "tracktotal" => match number() {
327                Some(n) => tag.set_track_total(n),
328                None => tag.remove_track_total(),
329            },
330            "disc" => match number() {
331                Some(n) => tag.set_disk(n),
332                None => tag.remove_disk(),
333            },
334            "disctotal" => match number() {
335                Some(n) => tag.set_disk_total(n),
336                None => tag.remove_disk_total(),
337            },
338            "comp" => {
339                tag.remove_key(ItemKey::FlagCompilation);
340                if value.as_deref().is_some_and(|v| v == "1" || v == "true") {
341                    tag.insert_text(ItemKey::FlagCompilation, "1".into());
342                }
343            }
344            name => {
345                let key = match name {
346                    "title" => ItemKey::TrackTitle,
347                    "artist" => ItemKey::TrackArtist,
348                    "album" => ItemKey::AlbumTitle,
349                    "albumartist" => ItemKey::AlbumArtist,
350                    "date" | "year" => ItemKey::RecordingDate,
351                    "original_date" => ItemKey::OriginalReleaseDate,
352                    "genre" => ItemKey::Genre,
353                    "label" => ItemKey::Label,
354                    "catalognum" => ItemKey::CatalogNumber,
355                    _ => continue,
356                };
357                tag.remove_key(key);
358                if name == "year" {
359                    tag.remove_key(ItemKey::Year);
360                }
361                if let Some(v) = value {
362                    tag.insert_text(key, v.clone());
363                }
364            }
365        }
366    }
367    tag.save_to_path(path, WriteOptions::default())
368        .map_err(lofty(path))?;
369    Ok(())
370}
371
372/// Write ReplayGain track and album gain, leaving every other tag alone.
373pub fn set_replaygain(
374    path: &Path,
375    track: crate::replaygain::Gain,
376    album: crate::replaygain::Gain,
377) -> Result<(), MetaError> {
378    raise_allocation_limit();
379    let mut file = lofty::probe::Probe::open(path)
380        .map_err(lofty(path))?
381        .read()
382        .map_err(lofty(path))?;
383    let tag_type = file.primary_tag_type();
384    if file.tag(tag_type).is_none() {
385        file.insert_tag(Tag::new(tag_type));
386    }
387    let tag = file
388        .tag_mut(tag_type)
389        .ok_or_else(|| MetaError::NoTag(path.to_path_buf()))?;
390    for (key, value) in [
391        (ItemKey::ReplayGainTrackGain, format!("{:.2} dB", track.db)),
392        (ItemKey::ReplayGainTrackPeak, format!("{:.6}", track.peak)),
393        (ItemKey::ReplayGainAlbumGain, format!("{:.2} dB", album.db)),
394        (ItemKey::ReplayGainAlbumPeak, format!("{:.6}", album.peak)),
395    ] {
396        tag.remove_key(key);
397        tag.insert_text(key, value);
398    }
399    tag.save_to_path(path, WriteOptions::default())
400        .map_err(lofty(path))?;
401    Ok(())
402}
403
404/// Write lyrics, leaving every other tag alone.
405pub fn set_lyrics(path: &Path, lyrics: &str) -> Result<(), MetaError> {
406    raise_allocation_limit();
407    let mut file = lofty::probe::Probe::open(path)
408        .map_err(lofty(path))?
409        .read()
410        .map_err(lofty(path))?;
411    let tag_type = file.primary_tag_type();
412    if file.tag(tag_type).is_none() {
413        file.insert_tag(Tag::new(tag_type));
414    }
415    let tag = file
416        .tag_mut(tag_type)
417        .ok_or_else(|| MetaError::NoTag(path.to_path_buf()))?;
418    tag.remove_key(ItemKey::Lyrics);
419    tag.insert_text(ItemKey::Lyrics, lyrics.to_string());
420    tag.save_to_path(path, WriteOptions::default())
421        .map_err(lofty(path))?;
422    Ok(())
423}
424
425/// Whether `path` already carries lyrics.
426pub fn has_lyrics(path: &Path) -> bool {
427    lofty::read_from_path(path).is_ok_and(|f| {
428        f.primary_tag().or_else(|| f.first_tag()).is_some_and(|t| {
429            t.get_string(ItemKey::Lyrics)
430                .is_some_and(|l| !l.trim().is_empty())
431        })
432    })
433}
434
435/// Whether `path` already carries an album gain.
436pub fn has_album_gain(path: &Path) -> bool {
437    lofty::read_from_path(path).is_ok_and(|f| {
438        f.primary_tag()
439            .or_else(|| f.first_tag())
440            .is_some_and(|t| t.get_string(ItemKey::ReplayGainAlbumGain).is_some())
441    })
442}
443
444pub fn embedded_cover(path: &Path) -> Option<Vec<u8>> {
445    let file = lofty::read_from_path(path).ok()?;
446    let tag = file.primary_tag().or_else(|| file.first_tag())?;
447    let pic = tag
448        .pictures()
449        .iter()
450        .find(|p| p.pic_type() == PictureType::CoverFront)
451        .or_else(|| tag.pictures().first())?;
452    Some(pic.data().to_vec())
453}