Skip to main content

reader/
metadata.rs

1use super::models::{Album, CoverChange, Library, Track, TrackEdits, TrackId};
2use super::utils::{find_folder_cover, save_cover};
3use lofty::file::TaggedFileExt;
4use lofty::picture::{Picture, PictureType};
5use lofty::prelude::*;
6use lofty::tag::ItemKey;
7use lofty::{file::TaggedFile, probe::Probe, properties::FileProperties, tag::Tag};
8use std::path::Path;
9use symphonia::core::formats::FormatOptions;
10use symphonia::core::formats::probe::Hint;
11use symphonia::core::io::MediaSourceStream;
12use symphonia::core::meta::{MetadataOptions, RawValue, StandardTag, Tag as SymphoniaTag};
13use symphonia::core::units::Timestamp;
14
15fn slugify_album_key(value: &str) -> String {
16    value
17        .to_lowercase()
18        .replace(' ', "_")
19        .replace(|c: char| !c.is_alphanumeric() && c != '_', "")
20}
21
22pub fn make_album_id(album: &str, grouping_key: &str) -> String {
23    let normalized_album = album.trim();
24
25    if !normalized_album.is_empty() {
26        return format!("alb_{}", slugify_album_key(normalized_album));
27    }
28
29    let fallback = slugify_album_key(grouping_key);
30    if fallback.is_empty() {
31        "alb_unknown".to_string()
32    } else {
33        format!("alb_unknown_{fallback}")
34    }
35}
36
37fn select_best_picture(pictures: &[Picture]) -> Option<&Picture> {
38    pictures
39        .iter()
40        .find(|picture| picture.pic_type() == PictureType::CoverFront)
41        .or_else(|| pictures.first())
42}
43
44pub fn extract_embedded_cover<'a>(
45    tagged_file: &'a TaggedFile,
46    tag: Option<&'a Tag>,
47) -> Option<&'a Picture> {
48    let candidate_tags = tag
49        .into_iter()
50        .chain(tagged_file.tags().iter())
51        .collect::<Vec<_>>();
52
53    candidate_tags
54        .iter()
55        .find_map(|tag| tag.get_picture_type(PictureType::CoverFront))
56        .or_else(|| {
57            candidate_tags
58                .iter()
59                .find_map(|tag| select_best_picture(tag.pictures()))
60        })
61}
62
63pub fn extract_metadata(
64    tag: Option<&Tag>,
65    properties: &FileProperties,
66    track_path: &Path,
67) -> Track {
68    let artist = tag
69        .and_then(|t| t.artist().map(|a| a.to_string()))
70        .unwrap_or_else(|| "Unknown Artist".to_string());
71
72    let artists: Vec<String> = tag
73        .map(|t| {
74            let from_tag: Vec<String> = t
75                .get_strings(ItemKey::TrackArtists)
76                .flat_map(|s| s.split(';').map(|a| a.trim().to_string()))
77                .filter(|s| !s.is_empty())
78                .collect();
79            if !from_tag.is_empty() {
80                from_tag
81            } else if artist.contains(';') {
82                artist
83                    .split(';')
84                    .map(|a| a.trim().to_string())
85                    .filter(|s| !s.is_empty())
86                    .collect()
87            } else {
88                vec![artist.clone()]
89            }
90        })
91        .unwrap_or_else(|| vec![artist.clone()]);
92
93    let album_title = tag.and_then(|t| t.album().map(|a| a.to_string()));
94
95    let album_artist = tag
96        .and_then(|t| t.get_string(ItemKey::AlbumArtist))
97        .map(|s| s.to_string());
98
99    let parent_path = track_path.parent().map(|p| p.to_string_lossy());
100    let grouping_key = album_artist
101        .as_deref()
102        .or(parent_path.as_deref())
103        .unwrap_or(&artist);
104
105    let title = tag
106        .and_then(|t| t.title().map(|t| t.to_string()))
107        .or_else(|| {
108            track_path
109                .file_stem()
110                .map(|s| s.to_string_lossy().into_owned())
111        })
112        .unwrap_or_else(|| "Unknown Title".to_string());
113
114    let musicbrainz_release_id = tag
115        .and_then(|t| t.get_string(ItemKey::MusicBrainzReleaseId))
116        .map(|s| s.to_string());
117
118    let musicbrainz_recording_id = tag
119        .and_then(|t| t.get_string(ItemKey::MusicBrainzRecordingId))
120        .map(|s| s.to_string());
121
122    let musicbrainz_track_id = tag
123        .and_then(|t| t.get_string(ItemKey::MusicBrainzTrackId))
124        .map(|s| s.to_string());
125
126    let sample_rate = properties.sample_rate().unwrap_or(0);
127    let file_size = std::fs::metadata(track_path)
128        .ok()
129        .map(|m| m.len())
130        .unwrap_or(0);
131    let _bitdepth = properties.bit_depth().unwrap_or(0);
132    let duration_secs = properties.duration().as_secs().max(1);
133    let bitrate_kbps = ((file_size * 8) / duration_secs / 1000).min(u16::MAX as u64) as u16;
134
135    Track {
136        id: TrackId::Local(track_path.to_path_buf()),
137        cover: None,
138        album_id: make_album_id(album_title.as_deref().unwrap_or(""), grouping_key),
139        title,
140        artist,
141        artists,
142        album: album_title.unwrap_or_else(|| "Unknown Album".to_string()),
143        khz: sample_rate,
144        bitrate: bitrate_kbps,
145        duration: properties.duration().as_secs()
146            + u64::from(properties.duration().subsec_nanos() > 0),
147        track_number: tag.and_then(|t| t.track()),
148        disc_number: tag.and_then(|t| t.disk()),
149        musicbrainz_release_id,
150        musicbrainz_recording_id,
151        musicbrainz_track_id,
152        playlist_item_id: None,
153    }
154}
155
156pub fn read(track_path: &Path, cover_cache: &Path, library: &mut Library) -> Option<Track> {
157    let tagged_file = match Probe::open(track_path).ok()?.read() {
158        Ok(tagged_file) => tagged_file,
159        Err(_) if is_matroska_audio(track_path) => {
160            return read_with_symphonia(track_path, cover_cache, library);
161        }
162        Err(_) => return None,
163    };
164    let properties = tagged_file.properties();
165    let tag = tagged_file
166        .primary_tag()
167        .or_else(|| tagged_file.first_tag());
168
169    let track = extract_metadata(tag, properties, track_path);
170    let album_id = track.album_id.clone();
171
172    let album_artist = tag
173        .and_then(|t| t.get_string(ItemKey::AlbumArtist))
174        .map(|s| s.to_string())
175        .unwrap_or_else(|| track.artist.clone());
176
177    let album = library.albums.iter().find(|a| a.id == album_id);
178    let album_exists = album.is_some();
179    let needs_cover = album.and_then(|album| album.cover_path.as_ref()).is_none();
180    let mut cover = None;
181
182    if needs_cover {
183        if let Some(picture) = extract_embedded_cover(&tagged_file, tag) {
184            let extension = picture.mime_type().and_then(|mime_type| mime_type.ext());
185            cover = save_cover(&album_id, picture.data(), extension, cover_cache).ok();
186        } else if let Some(folder_cover) = track_path.parent().and_then(|parent| {
187            let stem = track_path.file_stem().and_then(|s| s.to_str());
188            let extensions = ["jpg", "jpeg", "png", "webp"];
189            if let Some(stem) = stem {
190                for ext in &extensions {
191                    let candidate = parent.join(stem).with_extension(ext);
192                    if candidate.is_file() {
193                        return Some(candidate);
194                    }
195                }
196            }
197            find_folder_cover(parent)
198        }) {
199            cover = Some(folder_cover);
200        }
201    }
202
203    if !album_exists || cover.is_some() {
204        let genre = tag
205            .and_then(|t| t.genre().map(|g| g.to_string()))
206            .unwrap_or_else(|| "Unknown".to_string());
207
208        let year = tag
209            .and_then(|t| t.get_string(ItemKey::Year))
210            .and_then(|s| s.get(..4).unwrap_or(s).parse::<u16>().ok())
211            .unwrap_or(0);
212
213        library.add_album(Album {
214            id: album_id.clone(),
215            title: track.album.clone(),
216            artist: album_artist,
217            genre,
218            year,
219            cover_path: cover,
220            manual_cover: false,
221        });
222    }
223
224    library.add_track(track.clone());
225    Some(track)
226}
227
228/// Write `edits` back into the audio file's primary tag. Empty string fields
229/// and `None` numbers remove the corresponding tag entry. Creates a primary
230/// tag of the format's default type if the file has none. This rewrites the
231/// file in place — there's no undo.
232pub fn write_tags(track_path: &Path, edits: &TrackEdits) -> Result<(), String> {
233    use lofty::config::WriteOptions;
234    use lofty::file::AudioFile;
235
236    let mut tagged = Probe::open(track_path)
237        .map_err(|e| e.to_string())?
238        .read()
239        .map_err(|e| e.to_string())?;
240
241    if tagged.primary_tag().is_none() {
242        let tag_type = tagged.primary_tag_type();
243        tagged.insert_tag(Tag::new(tag_type));
244    }
245    let tag = tagged
246        .primary_tag_mut()
247        .ok_or_else(|| "no writable tag for this format".to_string())?;
248
249    let title = edits.title.trim();
250    if title.is_empty() {
251        tag.remove_title();
252    } else {
253        tag.set_title(title.to_string());
254    }
255
256    let artist = edits.artist.trim();
257    // Compare against the same representation the editor seeds from (structured
258    // TrackArtists joined with ", ", else the single artist field). Only rewrite
259    // the artist tags when it actually changed, so editing unrelated fields keeps
260    // the structured multi-artist data intact.
261    let existing_track_artists: Vec<String> = tag
262        .get_strings(ItemKey::TrackArtists)
263        .flat_map(|s| s.split(';').map(|a| a.trim().to_string()))
264        .filter(|s| !s.is_empty())
265        .collect();
266    let existing_artist_repr = if existing_track_artists.is_empty() {
267        tag.artist().map(|a| a.to_string()).unwrap_or_default()
268    } else {
269        existing_track_artists.join(", ")
270    };
271    if artist != existing_artist_repr {
272        if artist.is_empty() {
273            tag.remove_artist();
274        } else {
275            tag.set_artist(artist.to_string());
276        }
277        // Drop the now-stale structured split; re-derived from `artist` on scan.
278        tag.remove_key(ItemKey::TrackArtists);
279    }
280
281    let album = edits.album.trim();
282    if album.is_empty() {
283        tag.remove_album();
284    } else {
285        tag.set_album(album.to_string());
286    }
287
288    match edits.track_number {
289        Some(n) => tag.set_track(n),
290        None => tag.remove_track(),
291    }
292    match edits.disc_number {
293        Some(n) => tag.set_disk(n),
294        None => tag.remove_disk(),
295    }
296
297    match &edits.cover {
298        CoverChange::Keep => {}
299        CoverChange::Remove => {
300            // Clear every embedded picture, not just CoverFront — read_cover()
301            // falls back to any picture type, so a leftover would reappear.
302            while !tag.pictures().is_empty() {
303                tag.remove_picture(0);
304            }
305        }
306        CoverChange::Set(bytes) => {
307            let mut picture = Picture::from_reader(&mut &bytes[..]).map_err(|e| e.to_string())?;
308            picture.set_pic_type(PictureType::CoverFront);
309            while !tag.pictures().is_empty() {
310                tag.remove_picture(0);
311            }
312            tag.push_picture(picture);
313        }
314    }
315
316    tagged
317        .save_to_path(track_path, WriteOptions::default())
318        .map_err(|e| e.to_string())
319}
320
321/// Read the embedded front-cover picture (or best available) as raw bytes plus
322/// its MIME type, for previewing in the metadata editor. `None` if the file has
323/// no embedded artwork.
324pub fn read_cover(track_path: &Path) -> Option<(Vec<u8>, String)> {
325    let tagged = Probe::open(track_path).ok()?.read().ok()?;
326    let tag = tagged.primary_tag().or_else(|| tagged.first_tag());
327    let picture = extract_embedded_cover(&tagged, tag)?;
328    let mime = picture
329        .mime_type()
330        .map(|m| m.as_str().to_string())
331        .unwrap_or_else(|| "image/jpeg".to_string());
332    Some((picture.data().to_vec(), mime))
333}
334
335fn is_matroska_audio(track_path: &Path) -> bool {
336    track_path
337        .extension()
338        .and_then(|ext| ext.to_str())
339        .is_some_and(|ext| ext.eq_ignore_ascii_case("mka"))
340}
341
342fn symphonia_tag_to_string(tag: &SymphoniaTag) -> Option<String> {
343    match &tag.raw.value {
344        RawValue::String(value) => {
345            let value = value.trim();
346            (!value.is_empty()).then(|| value.to_string())
347        }
348        RawValue::StringList(values) => {
349            let joined = values.join(", ");
350            let joined = joined.trim();
351            (!joined.is_empty()).then(|| joined.to_string())
352        }
353        RawValue::UnsignedInt(value) => Some(value.to_string()),
354        RawValue::SignedInt(value) => Some(value.to_string()),
355        RawValue::Float(value) => Some(value.to_string()),
356        RawValue::Boolean(value) => Some(value.to_string()),
357        _ => None,
358    }
359}
360
361fn find_symphonia_tag<'a>(
362    tags: &'a [SymphoniaTag],
363    matches_std: impl Fn(&StandardTag) -> bool,
364    fallback_keys: &[&str],
365) -> Option<&'a SymphoniaTag> {
366    tags.iter()
367        .find(|tag| tag.std.as_ref().is_some_and(&matches_std))
368        .or_else(|| {
369            tags.iter().find(|tag| {
370                fallback_keys
371                    .iter()
372                    .any(|key| tag.raw.key.eq_ignore_ascii_case(key))
373            })
374        })
375}
376
377fn read_with_symphonia(
378    track_path: &Path,
379    cover_cache: &Path,
380    library: &mut Library,
381) -> Option<Track> {
382    let file = std::fs::File::open(track_path).ok()?;
383    let file_size = file.metadata().ok().map(|m| m.len()).unwrap_or(0);
384
385    let mut hint = Hint::new();
386    if let Some(ext) = track_path.extension().and_then(|ext| ext.to_str()) {
387        hint.with_extension(ext);
388    }
389
390    let mut tags = Vec::new();
391    let mut sample_rate = 0;
392    let mut duration = 0;
393
394    let mss = MediaSourceStream::new(Box::new(file), Default::default());
395    if let Ok(mut format) = symphonia::default::get_probe().probe(
396        &hint,
397        mss,
398        FormatOptions::default(),
399        MetadataOptions::default(),
400    ) {
401        {
402            let mut metadata = format.metadata();
403            if let Some(revision) = metadata.skip_to_latest() {
404                tags.extend(revision.media.tags.iter().cloned());
405            }
406        }
407
408        if let Some(track_info) = format
409            .tracks()
410            .iter()
411            .find(|track| {
412                track
413                    .codec_params
414                    .as_ref()
415                    .and_then(|p| p.audio())
416                    .is_some()
417            })
418            .or_else(|| format.tracks().first())
419        {
420            if let Some(audio) = track_info.codec_params.as_ref().and_then(|p| p.audio()) {
421                sample_rate = audio.sample_rate.unwrap_or(0);
422            }
423            duration = track_info
424                .time_base
425                .zip(track_info.num_frames)
426                .and_then(|(time_base, n_frames)| {
427                    time_base.calc_time(Timestamp::from(n_frames as i64))
428                })
429                .map(|time| time.as_secs_f64().ceil().max(0.0) as u64)
430                .unwrap_or(0);
431        }
432    }
433
434    let artist = find_symphonia_tag(&tags, |t| matches!(t, StandardTag::Artist(_)), &["ARTIST"])
435        .and_then(symphonia_tag_to_string)
436        .unwrap_or_else(|| "Unknown Artist".to_string());
437
438    let album_title = find_symphonia_tag(&tags, |t| matches!(t, StandardTag::Album(_)), &["ALBUM"])
439        .and_then(symphonia_tag_to_string);
440
441    let album_artist = find_symphonia_tag(
442        &tags,
443        |t| matches!(t, StandardTag::AlbumArtist(_)),
444        &["ALBUMARTIST"],
445    )
446    .and_then(symphonia_tag_to_string)
447    .unwrap_or_else(|| artist.clone());
448
449    let parent_path = track_path.parent().map(|p| p.to_string_lossy());
450    let grouping_key = album_title
451        .as_deref()
452        .and_then(|title| (!title.trim().is_empty()).then_some(album_artist.as_str()))
453        .or(parent_path.as_deref())
454        .unwrap_or(&artist);
455
456    let title = find_symphonia_tag(
457        &tags,
458        |t| matches!(t, StandardTag::TrackTitle(_)),
459        &["TITLE"],
460    )
461    .and_then(symphonia_tag_to_string)
462    .or_else(|| {
463        track_path
464            .file_stem()
465            .map(|stem| stem.to_string_lossy().into_owned())
466    })
467    .unwrap_or_else(|| "Unknown Title".to_string());
468
469    let bitrate_kbps = (file_size * 8)
470        .checked_div(duration)
471        .map(|bps| (bps / 1000).min(u16::MAX as u64) as u16)
472        .unwrap_or(0);
473
474    let track = Track {
475        id: TrackId::Local(track_path.to_path_buf()),
476        cover: None,
477        album_id: make_album_id(album_title.as_deref().unwrap_or(""), grouping_key),
478        title,
479        artist: artist.clone(),
480        artists: vec![artist.clone()],
481        album: album_title.unwrap_or_else(|| "Unknown Album".to_string()),
482        khz: sample_rate,
483        bitrate: bitrate_kbps,
484        duration,
485        track_number: find_symphonia_tag(
486            &tags,
487            |t| matches!(t, StandardTag::TrackNumber(_)),
488            &["TRACKNUMBER"],
489        )
490        .and_then(symphonia_tag_to_string)
491        .and_then(|value| value.parse().ok()),
492        disc_number: find_symphonia_tag(
493            &tags,
494            |t| matches!(t, StandardTag::DiscNumber(_)),
495            &["DISCNUMBER"],
496        )
497        .and_then(symphonia_tag_to_string)
498        .and_then(|value| value.parse().ok()),
499        musicbrainz_release_id: find_symphonia_tag(
500            &tags,
501            |t| matches!(t, StandardTag::MusicBrainzAlbumId(_)),
502            &["MUSICBRAINZ_ALBUMID"],
503        )
504        .and_then(symphonia_tag_to_string),
505        musicbrainz_recording_id: find_symphonia_tag(
506            &tags,
507            |t| matches!(t, StandardTag::MusicBrainzRecordingId(_)),
508            &["MUSICBRAINZ_TRACKID"],
509        )
510        .and_then(symphonia_tag_to_string),
511        musicbrainz_track_id: find_symphonia_tag(
512            &tags,
513            |t| matches!(t, StandardTag::MusicBrainzTrackId(_)),
514            &["MUSICBRAINZ_RELEASETRACKID"],
515        )
516        .and_then(symphonia_tag_to_string),
517        playlist_item_id: None,
518    };
519
520    let album_id = track.album_id.clone();
521    let album = library.albums.iter().find(|a| a.id == album_id);
522    let album_exists = album.is_some();
523    let needs_cover = album.and_then(|album| album.cover_path.as_ref()).is_none();
524    let cover = if needs_cover {
525        track_path.parent().and_then(|parent| {
526            let stem = track_path.file_stem().and_then(|s| s.to_str());
527            let extensions = ["jpg", "jpeg", "png", "webp"];
528            if let Some(stem) = stem {
529                for ext in &extensions {
530                    let candidate = parent.join(stem).with_extension(ext);
531                    if candidate.is_file() {
532                        return Some(candidate);
533                    }
534                }
535            }
536            find_folder_cover(parent)
537        })
538    } else {
539        None
540    };
541
542    if !album_exists || cover.is_some() {
543        let genre = find_symphonia_tag(&tags, |t| matches!(t, StandardTag::Genre(_)), &["GENRE"])
544            .and_then(symphonia_tag_to_string)
545            .unwrap_or_else(|| "Unknown".to_string());
546        let year = find_symphonia_tag(
547            &tags,
548            |t| matches!(t, StandardTag::ReleaseDate(_) | StandardTag::ReleaseYear(_)),
549            &["DATE", "YEAR"],
550        )
551        .and_then(symphonia_tag_to_string)
552        .and_then(|value| value.get(..4).and_then(|prefix| prefix.parse::<u16>().ok()))
553        .unwrap_or(0);
554
555        library.add_album(Album {
556            id: album_id.clone(),
557            title: track.album.clone(),
558            artist: album_artist,
559            genre,
560            year,
561            cover_path: cover,
562            manual_cover: false,
563        });
564    }
565
566    library.add_track(track.clone());
567    let _ = cover_cache;
568    Some(track)
569}