1use 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#[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 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 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
73fn 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
86pub 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 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 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#[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
189pub 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
281pub type Change = (String, Option<String>);
283
284pub 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
303pub 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
372pub 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
404pub 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
425pub 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
435pub 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}