mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +00:00
Add ability to fully clear cached online library by deleting and recreating the database file. Integrate new Clear Cache option in settings UI, which restarts the app after clearing. Remove unused artist/album fields from cache and UI. Add process plugin for relaunch.
359 lines
10 KiB
Rust
359 lines
10 KiB
Rust
use id3::{
|
|
frame::{Picture, PictureType},
|
|
Tag as ID3Tag, TagLike, Version,
|
|
};
|
|
use metaflac::Tag as FlacTag;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
|
|
/// Metadata structure for audio file tagging
|
|
/// Matches DeezerTrack interface from TypeScript
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TaggingMetadata {
|
|
pub title: Option<String>,
|
|
pub artist: Option<String>,
|
|
pub artists: Option<Vec<String>>,
|
|
pub album: Option<String>,
|
|
pub album_artist: Option<String>,
|
|
pub track_number: Option<u32>,
|
|
pub disc_number: Option<u32>,
|
|
pub year: Option<i32>,
|
|
pub genre: Option<Vec<String>>,
|
|
pub duration: Option<u32>, // in seconds
|
|
pub bpm: Option<u32>,
|
|
pub isrc: Option<String>,
|
|
pub label: Option<String>,
|
|
pub barcode: Option<String>,
|
|
pub copyright: Option<String>,
|
|
pub explicit: Option<bool>,
|
|
pub replay_gain: Option<String>,
|
|
pub source_id: Option<String>,
|
|
pub lyrics_unsync: Option<String>,
|
|
}
|
|
|
|
/// Tag an audio file (MP3 or FLAC) with metadata
|
|
pub fn tag_audio_file(
|
|
path: &str,
|
|
metadata: &TaggingMetadata,
|
|
cover_data: Option<&[u8]>,
|
|
embed_lyrics: bool,
|
|
) -> Result<(), String> {
|
|
let path_obj = Path::new(path);
|
|
|
|
// Determine file type by extension
|
|
let extension = path_obj
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.map(|e| e.to_lowercase())
|
|
.ok_or_else(|| "File has no extension".to_string())?;
|
|
|
|
match extension.as_str() {
|
|
"mp3" => tag_mp3(path, metadata, cover_data, embed_lyrics),
|
|
"flac" => tag_flac(path, metadata, cover_data, embed_lyrics),
|
|
_ => Err(format!("Unsupported file format: {}", extension)),
|
|
}
|
|
}
|
|
|
|
/// Tag MP3 file using id3 crate
|
|
fn tag_mp3(
|
|
path: &str,
|
|
metadata: &TaggingMetadata,
|
|
cover_data: Option<&[u8]>,
|
|
embed_lyrics: bool,
|
|
) -> Result<(), String> {
|
|
// Read or create tag
|
|
let mut tag = ID3Tag::read_from_path(path).unwrap_or_else(|_| ID3Tag::new());
|
|
|
|
// Basic metadata
|
|
if let Some(ref title) = metadata.title {
|
|
tag.set_title(title);
|
|
}
|
|
|
|
if let Some(ref artists) = metadata.artists {
|
|
if !artists.is_empty() {
|
|
tag.set_artist(artists.join(", "));
|
|
}
|
|
} else if let Some(ref artist) = metadata.artist {
|
|
tag.set_artist(artist);
|
|
}
|
|
|
|
if let Some(ref album) = metadata.album {
|
|
tag.set_album(album);
|
|
}
|
|
|
|
if let Some(ref album_artist) = metadata.album_artist {
|
|
tag.set_album_artist(album_artist);
|
|
}
|
|
|
|
// Track and disc numbers
|
|
if let Some(track_number) = metadata.track_number {
|
|
tag.set_track(track_number);
|
|
}
|
|
|
|
if let Some(disc_number) = metadata.disc_number {
|
|
tag.set_disc(disc_number);
|
|
}
|
|
|
|
// Year
|
|
if let Some(year) = metadata.year {
|
|
tag.set_year(year);
|
|
}
|
|
|
|
// Genre
|
|
if let Some(ref genres) = metadata.genre {
|
|
if let Some(first_genre) = genres.first() {
|
|
tag.set_genre(first_genre);
|
|
}
|
|
}
|
|
|
|
// Duration (milliseconds)
|
|
if let Some(duration) = metadata.duration {
|
|
tag.set_duration(duration * 1000);
|
|
}
|
|
|
|
// Additional metadata as text frames
|
|
if let Some(ref isrc) = metadata.isrc {
|
|
tag.add_frame(id3::frame::Frame::text("TSRC", isrc.clone()));
|
|
}
|
|
|
|
if let Some(ref label) = metadata.label {
|
|
tag.add_frame(id3::frame::Frame::text("TPUB", label.clone()));
|
|
}
|
|
|
|
if let Some(bpm) = metadata.bpm {
|
|
tag.add_frame(id3::frame::Frame::text("TBPM", bpm.to_string()));
|
|
}
|
|
|
|
if let Some(ref copyright) = metadata.copyright {
|
|
tag.add_frame(id3::frame::Frame::text("TCOP", copyright.clone()));
|
|
}
|
|
|
|
// Custom text frames (TXXX)
|
|
if let Some(ref barcode) = metadata.barcode {
|
|
use id3::frame::{Content, ExtendedText};
|
|
let ext_text = ExtendedText {
|
|
description: "BARCODE".to_string(),
|
|
value: barcode.clone(),
|
|
};
|
|
tag.add_frame(id3::frame::Frame::with_content(
|
|
"TXXX",
|
|
Content::ExtendedText(ext_text),
|
|
));
|
|
}
|
|
|
|
if let Some(explicit) = metadata.explicit {
|
|
use id3::frame::{Content, ExtendedText};
|
|
let ext_text = ExtendedText {
|
|
description: "ITUNESADVISORY".to_string(),
|
|
value: if explicit { "1" } else { "0" }.to_string(),
|
|
};
|
|
tag.add_frame(id3::frame::Frame::with_content(
|
|
"TXXX",
|
|
Content::ExtendedText(ext_text),
|
|
));
|
|
}
|
|
|
|
if let Some(ref replay_gain) = metadata.replay_gain {
|
|
use id3::frame::{Content, ExtendedText};
|
|
let ext_text = ExtendedText {
|
|
description: "REPLAYGAIN_TRACK_GAIN".to_string(),
|
|
value: replay_gain.clone(),
|
|
};
|
|
tag.add_frame(id3::frame::Frame::with_content(
|
|
"TXXX",
|
|
Content::ExtendedText(ext_text),
|
|
));
|
|
}
|
|
|
|
if let Some(ref source_id) = metadata.source_id {
|
|
use id3::frame::{Content, ExtendedText};
|
|
let source_text = ExtendedText {
|
|
description: "SOURCE".to_string(),
|
|
value: "Deezer".to_string(),
|
|
};
|
|
tag.add_frame(id3::frame::Frame::with_content(
|
|
"TXXX",
|
|
Content::ExtendedText(source_text),
|
|
));
|
|
|
|
let sourceid_text = ExtendedText {
|
|
description: "SOURCEID".to_string(),
|
|
value: source_id.clone(),
|
|
};
|
|
tag.add_frame(id3::frame::Frame::with_content(
|
|
"TXXX",
|
|
Content::ExtendedText(sourceid_text),
|
|
));
|
|
}
|
|
|
|
// Lyrics (USLT frame)
|
|
if embed_lyrics {
|
|
if let Some(ref lyrics) = metadata.lyrics_unsync {
|
|
use id3::frame::{Content, Lyrics};
|
|
let lyrics_frame = Lyrics {
|
|
lang: "eng".to_string(),
|
|
description: String::new(),
|
|
text: lyrics.clone(),
|
|
};
|
|
tag.add_frame(id3::frame::Frame::with_content(
|
|
"USLT",
|
|
Content::Lyrics(lyrics_frame),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Cover art (APIC frame)
|
|
if let Some(cover_bytes) = cover_data {
|
|
if !cover_bytes.is_empty() {
|
|
let mime_type = detect_mime_type_str(cover_bytes);
|
|
let picture = Picture {
|
|
mime_type: mime_type.to_string(),
|
|
picture_type: PictureType::CoverFront,
|
|
description: "Cover".to_string(),
|
|
data: cover_bytes.to_vec(),
|
|
};
|
|
tag.add_frame(id3::frame::Frame::with_content(
|
|
"APIC",
|
|
id3::frame::Content::Picture(picture),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Write tag
|
|
tag.write_to_path(path, Version::Id3v24)
|
|
.map_err(|e| format!("Failed to write MP3 tags: {}", e))
|
|
}
|
|
|
|
/// Tag FLAC file using metaflac crate
|
|
fn tag_flac(
|
|
path: &str,
|
|
metadata: &TaggingMetadata,
|
|
cover_data: Option<&[u8]>,
|
|
embed_lyrics: bool,
|
|
) -> Result<(), String> {
|
|
let mut tag =
|
|
FlacTag::read_from_path(path).map_err(|e| format!("Failed to read FLAC file: {}", e))?;
|
|
|
|
// Remove all existing vorbis comments to start fresh
|
|
let vorbis = tag.vorbis_comments_mut();
|
|
vorbis.comments.clear();
|
|
|
|
// Basic metadata
|
|
if let Some(ref title) = metadata.title {
|
|
tag.set_vorbis("TITLE", vec![title.clone()]);
|
|
}
|
|
|
|
if let Some(ref artists) = metadata.artists {
|
|
if !artists.is_empty() {
|
|
// FLAC supports multiple ARTIST tags
|
|
tag.set_vorbis("ARTIST", artists.clone());
|
|
}
|
|
} else if let Some(ref artist) = metadata.artist {
|
|
tag.set_vorbis("ARTIST", vec![artist.clone()]);
|
|
}
|
|
|
|
if let Some(ref album) = metadata.album {
|
|
tag.set_vorbis("ALBUM", vec![album.clone()]);
|
|
}
|
|
|
|
if let Some(ref album_artist) = metadata.album_artist {
|
|
tag.set_vorbis("ALBUMARTIST", vec![album_artist.clone()]);
|
|
}
|
|
|
|
// Track and disc numbers
|
|
if let Some(track_number) = metadata.track_number {
|
|
tag.set_vorbis("TRACKNUMBER", vec![track_number.to_string()]);
|
|
}
|
|
|
|
if let Some(disc_number) = metadata.disc_number {
|
|
tag.set_vorbis("DISCNUMBER", vec![disc_number.to_string()]);
|
|
}
|
|
|
|
// Year/Date
|
|
if let Some(year) = metadata.year {
|
|
tag.set_vorbis("DATE", vec![year.to_string()]);
|
|
}
|
|
|
|
// Genre
|
|
if let Some(ref genres) = metadata.genre {
|
|
tag.set_vorbis("GENRE", genres.clone());
|
|
}
|
|
|
|
// Additional metadata
|
|
if let Some(ref isrc) = metadata.isrc {
|
|
tag.set_vorbis("ISRC", vec![isrc.clone()]);
|
|
}
|
|
|
|
if let Some(ref label) = metadata.label {
|
|
tag.set_vorbis("PUBLISHER", vec![label.clone()]);
|
|
}
|
|
|
|
if let Some(bpm) = metadata.bpm {
|
|
tag.set_vorbis("BPM", vec![bpm.to_string()]);
|
|
}
|
|
|
|
if let Some(ref copyright) = metadata.copyright {
|
|
tag.set_vorbis("COPYRIGHT", vec![copyright.clone()]);
|
|
}
|
|
|
|
if let Some(ref barcode) = metadata.barcode {
|
|
tag.set_vorbis("BARCODE", vec![barcode.clone()]);
|
|
}
|
|
|
|
if let Some(explicit) = metadata.explicit {
|
|
tag.set_vorbis(
|
|
"ITUNESADVISORY",
|
|
vec![if explicit { "1" } else { "0" }.to_string()],
|
|
);
|
|
}
|
|
|
|
if let Some(ref replay_gain) = metadata.replay_gain {
|
|
tag.set_vorbis("REPLAYGAIN_TRACK_GAIN", vec![replay_gain.clone()]);
|
|
}
|
|
|
|
if let Some(ref source_id) = metadata.source_id {
|
|
tag.set_vorbis("SOURCE", vec!["Deezer".to_string()]);
|
|
tag.set_vorbis("SOURCEID", vec![source_id.clone()]);
|
|
}
|
|
|
|
// Lyrics
|
|
if embed_lyrics {
|
|
if let Some(ref lyrics) = metadata.lyrics_unsync {
|
|
tag.set_vorbis("LYRICS", vec![lyrics.clone()]);
|
|
}
|
|
}
|
|
|
|
// Cover art
|
|
if let Some(cover_bytes) = cover_data {
|
|
if !cover_bytes.is_empty() {
|
|
let mime_type = detect_mime_type_str(cover_bytes);
|
|
tag.remove_picture_type(metaflac::block::PictureType::CoverFront);
|
|
tag.add_picture(
|
|
mime_type,
|
|
metaflac::block::PictureType::CoverFront,
|
|
cover_bytes.to_vec(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Write tag
|
|
tag.save()
|
|
.map_err(|e| format!("Failed to write FLAC tags: {}", e))
|
|
}
|
|
|
|
/// Detect MIME type from image data header
|
|
fn detect_mime_type_str(data: &[u8]) -> &'static str {
|
|
if data.len() < 4 {
|
|
return "image/jpeg";
|
|
}
|
|
|
|
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
|
"image/jpeg"
|
|
} else if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
|
"image/png"
|
|
} else {
|
|
"image/jpeg"
|
|
}
|
|
}
|