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
228pub 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 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 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 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
321pub 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}