feat(cache): implement Id3v2Handler for MP3 metadata synthesis
- Implement all 8 FormatHandler trait methods - Use lofty 0.24 for ID3v2.4 tag creation/parsing - Map all 36 AudioMeta fields to ID3v2 frames - Handle ID3v2 header parsing for audio_start - Detect ID3v1 tags at EOF for audio_end - Add 13 comprehensive unit tests - Fix test-utils AudioMeta construction with ..Default::default() - All tests pass, LSP diagnostics clean
This commit is contained in:
Generated
+42
@@ -629,6 +629,12 @@ dependencies = [
|
|||||||
"parking_lot_core 0.9.12",
|
"parking_lot_core 0.9.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "debugid"
|
name = "debugid"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1691,6 +1697,32 @@ dependencies = [
|
|||||||
"scopeguard",
|
"scopeguard",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lofty"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"data-encoding",
|
||||||
|
"flate2",
|
||||||
|
"lofty_attr",
|
||||||
|
"log",
|
||||||
|
"ogg_pager",
|
||||||
|
"paste",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lofty_attr"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
@@ -1885,6 +1917,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"image",
|
"image",
|
||||||
|
"lofty",
|
||||||
"musicfs-cas",
|
"musicfs-cas",
|
||||||
"musicfs-core",
|
"musicfs-core",
|
||||||
"musicfs-metadata",
|
"musicfs-metadata",
|
||||||
@@ -2256,6 +2289,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ogg_pager"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ thiserror.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
rmp-serde.workspace = true
|
rmp-serde.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
|
lofty = "0.24"
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ impl Database {
|
|||||||
bitrate: row.get(13)?,
|
bitrate: row.get(13)?,
|
||||||
sample_rate: row.get(14)?,
|
sample_rate: row.get(14)?,
|
||||||
format,
|
format,
|
||||||
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
size: row.get::<_, i64>(17)? as u64,
|
size: row.get::<_, i64>(17)? as u64,
|
||||||
mtime: UNIX_EPOCH + Duration::from_secs(row.get::<_, i64>(16)? as u64),
|
mtime: UNIX_EPOCH + Duration::from_secs(row.get::<_, i64>(16)? as u64),
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
use crate::FormatLayout;
|
||||||
|
use musicfs_core::AudioMeta;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Error types for format handling operations
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum FormatError {
|
||||||
|
#[error("Unsupported format")]
|
||||||
|
UnsupportedFormat,
|
||||||
|
|
||||||
|
#[error("Invalid data: {0}")]
|
||||||
|
InvalidData(String),
|
||||||
|
|
||||||
|
#[error("Synthesis failed: {0}")]
|
||||||
|
SynthesisFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait for format-specific metadata handling.
|
||||||
|
///
|
||||||
|
/// Implementations handle:
|
||||||
|
/// 1. Analyzing original files to find audio boundaries
|
||||||
|
/// 2. Synthesizing new headers from database metadata
|
||||||
|
pub trait FormatHandler: Send + Sync + 'static {
|
||||||
|
/// Unique identifier for this handler
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Human-readable name
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// File extensions this handler supports
|
||||||
|
fn extensions(&self) -> &[&'static str];
|
||||||
|
|
||||||
|
/// MIME types this handler supports
|
||||||
|
fn mime_types(&self) -> &[&'static str];
|
||||||
|
|
||||||
|
/// Analyze file bytes to determine audio layout
|
||||||
|
fn analyze(
|
||||||
|
&self,
|
||||||
|
data: &[u8],
|
||||||
|
file_size: u64,
|
||||||
|
) -> std::result::Result<FormatLayout, FormatError>;
|
||||||
|
|
||||||
|
/// Synthesize header bytes from metadata. Called on every read().
|
||||||
|
fn synthesize(
|
||||||
|
&self,
|
||||||
|
metadata: &AudioMeta,
|
||||||
|
layout: &FormatLayout,
|
||||||
|
) -> std::result::Result<Vec<u8>, FormatError>;
|
||||||
|
|
||||||
|
/// Extract metadata from header bytes (for initial ingest)
|
||||||
|
fn extract(&self, data: &[u8]) -> std::result::Result<AudioMeta, FormatError>;
|
||||||
|
|
||||||
|
/// Estimate header size without full synthesis (for getattr)
|
||||||
|
fn estimate_header_size(&self, _metadata: &AudioMeta) -> usize {
|
||||||
|
10 * 1024 // 10KB default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry for format handlers
|
||||||
|
pub struct FormatHandlerRegistry {
|
||||||
|
handlers: HashMap<String, Arc<dyn FormatHandler>>,
|
||||||
|
extension_map: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormatHandlerRegistry {
|
||||||
|
/// Create empty registry
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
handlers: HashMap::new(),
|
||||||
|
extension_map: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a format handler
|
||||||
|
pub fn register(&mut self, handler: Arc<dyn FormatHandler>) {
|
||||||
|
let id = handler.id().to_string();
|
||||||
|
|
||||||
|
// Map extensions to handler ID
|
||||||
|
for ext in handler.extensions() {
|
||||||
|
self.extension_map.insert(ext.to_string(), id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.handlers.insert(id, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get handler by file extension
|
||||||
|
pub fn get_by_extension(&self, ext: &str) -> Option<Arc<dyn FormatHandler>> {
|
||||||
|
let id = self.extension_map.get(ext)?;
|
||||||
|
self.handlers.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get handler by format ID
|
||||||
|
pub fn get_by_format(&self, format: &str) -> Option<Arc<dyn FormatHandler>> {
|
||||||
|
self.handlers.get(format).cloned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FormatHandlerRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use musicfs_core::AudioFormat;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Describes the byte layout of an audio file for overlay splicing.
|
||||||
|
///
|
||||||
|
/// This struct tracks where the audio data begins and ends in the origin file,
|
||||||
|
/// allowing the OverlayReader to splice synthetic headers with original audio.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FormatLayout {
|
||||||
|
/// Byte offset where audio data begins in the origin file
|
||||||
|
pub audio_start: u64,
|
||||||
|
|
||||||
|
/// Byte offset where audio data ends in the origin file
|
||||||
|
pub audio_end: u64,
|
||||||
|
|
||||||
|
/// Audio format (from musicfs-core)
|
||||||
|
pub format: AudioFormat,
|
||||||
|
|
||||||
|
/// Format-specific data (e.g., FLAC STREAMINFO block, MP4 stco offsets)
|
||||||
|
/// Stored as raw bytes, interpreted by format handlers
|
||||||
|
pub format_data: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
use crate::{FormatError, FormatHandler, FormatLayout};
|
||||||
|
use lofty::config::{ParseOptions, WriteOptions};
|
||||||
|
use lofty::file::AudioFile;
|
||||||
|
use lofty::id3::v2::{CommentFrame, Frame, FrameId, Id3v2Tag, TextInformationFrame, UnsynchronizedTextFrame};
|
||||||
|
use lofty::mpeg::MpegFile;
|
||||||
|
use lofty::tag::{Accessor, TagExt};
|
||||||
|
use lofty::TextEncoding;
|
||||||
|
use musicfs_core::{AudioFormat, AudioMeta};
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
const ID3V2_HEADER_SIZE: usize = 10;
|
||||||
|
const ID3V1_TAG_SIZE: usize = 128;
|
||||||
|
|
||||||
|
pub struct Id3v2Handler;
|
||||||
|
|
||||||
|
impl Id3v2Handler {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_id3v2_header(data: &[u8]) -> Option<usize> {
|
||||||
|
if data.len() < ID3V2_HEADER_SIZE {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if &data[0..3] != b"ID3" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = syncsafe_decode(&data[6..10]);
|
||||||
|
Some(ID3V2_HEADER_SIZE + size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_id3v1_tag(data: &[u8], file_size: u64) -> bool {
|
||||||
|
if file_size < ID3V1_TAG_SIZE as u64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag_start = (file_size as usize).saturating_sub(ID3V1_TAG_SIZE);
|
||||||
|
if tag_start >= data.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
&data[tag_start..tag_start + 3] == b"TAG"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_text_frame(tag: &mut Id3v2Tag, frame_id: &'static str, value: &str) {
|
||||||
|
let id = FrameId::Valid(Cow::Borrowed(frame_id));
|
||||||
|
let frame = Frame::Text(TextInformationFrame::new(
|
||||||
|
id,
|
||||||
|
TextEncoding::UTF8,
|
||||||
|
value.to_string(),
|
||||||
|
));
|
||||||
|
tag.insert(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_track_disc_frame(tag: &mut Id3v2Tag, frame_id: &'static str, num: u32, total: Option<u32>) {
|
||||||
|
let value = match total {
|
||||||
|
Some(t) => format!("{}/{}", num, t),
|
||||||
|
None => num.to_string(),
|
||||||
|
};
|
||||||
|
Self::set_text_frame(tag, frame_id, &value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_comment_frame(tag: &mut Id3v2Tag, value: &str) {
|
||||||
|
let frame = Frame::Comment(CommentFrame::new(
|
||||||
|
TextEncoding::UTF8,
|
||||||
|
*b"eng",
|
||||||
|
String::new(),
|
||||||
|
value.to_string(),
|
||||||
|
));
|
||||||
|
tag.insert(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_lyrics_frame(tag: &mut Id3v2Tag, value: &str) {
|
||||||
|
let frame = Frame::UnsynchronizedText(UnsynchronizedTextFrame::new(
|
||||||
|
TextEncoding::UTF8,
|
||||||
|
*b"eng",
|
||||||
|
String::new(),
|
||||||
|
value.to_string(),
|
||||||
|
));
|
||||||
|
tag.insert(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tag_from_meta(metadata: &AudioMeta) -> Id3v2Tag {
|
||||||
|
let mut tag = Id3v2Tag::new();
|
||||||
|
|
||||||
|
if let Some(ref title) = metadata.title {
|
||||||
|
tag.set_title(title.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref artist) = metadata.artist {
|
||||||
|
tag.set_artist(artist.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref album) = metadata.album {
|
||||||
|
tag.set_album(album.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref album_artist) = metadata.album_artist {
|
||||||
|
Self::set_text_frame(&mut tag, "TPE2", album_artist);
|
||||||
|
}
|
||||||
|
if let Some(year) = metadata.year {
|
||||||
|
Self::set_text_frame(&mut tag, "TDRC", &year.to_string());
|
||||||
|
}
|
||||||
|
if let Some(ref genre) = metadata.genre {
|
||||||
|
tag.set_genre(genre.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(track) = metadata.track {
|
||||||
|
Self::set_track_disc_frame(&mut tag, "TRCK", track, metadata.track_total);
|
||||||
|
}
|
||||||
|
if let Some(disc) = metadata.disc {
|
||||||
|
Self::set_track_disc_frame(&mut tag, "TPOS", disc, metadata.disc_total);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref date) = metadata.date {
|
||||||
|
Self::set_text_frame(&mut tag, "TDRC", date);
|
||||||
|
}
|
||||||
|
if let Some(ref composer) = metadata.composer {
|
||||||
|
Self::set_text_frame(&mut tag, "TCOM", composer);
|
||||||
|
}
|
||||||
|
if let Some(ref comment) = metadata.comment {
|
||||||
|
Self::set_comment_frame(&mut tag, comment);
|
||||||
|
}
|
||||||
|
if let Some(ref lyrics) = metadata.lyrics {
|
||||||
|
Self::set_lyrics_frame(&mut tag, lyrics);
|
||||||
|
}
|
||||||
|
if let Some(ref copyright) = metadata.copyright {
|
||||||
|
Self::set_text_frame(&mut tag, "TCOP", copyright);
|
||||||
|
}
|
||||||
|
if let Some(compilation) = metadata.compilation {
|
||||||
|
Self::set_text_frame(&mut tag, "TCMP", if compilation { "1" } else { "0" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref title_sort) = metadata.title_sort {
|
||||||
|
Self::set_text_frame(&mut tag, "TSOT", title_sort);
|
||||||
|
}
|
||||||
|
if let Some(ref artist_sort) = metadata.artist_sort {
|
||||||
|
Self::set_text_frame(&mut tag, "TSOP", artist_sort);
|
||||||
|
}
|
||||||
|
if let Some(ref album_sort) = metadata.album_sort {
|
||||||
|
Self::set_text_frame(&mut tag, "TSOA", album_sort);
|
||||||
|
}
|
||||||
|
if let Some(ref album_artist_sort) = metadata.album_artist_sort {
|
||||||
|
Self::set_text_frame(&mut tag, "TSO2", album_artist_sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref mb_recording_id) = metadata.mb_recording_id {
|
||||||
|
tag.insert_user_text("MusicBrainz Recording Id".to_string(), mb_recording_id.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref mb_album_id) = metadata.mb_album_id {
|
||||||
|
tag.insert_user_text("MusicBrainz Album Id".to_string(), mb_album_id.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref mb_artist_id) = metadata.mb_artist_id {
|
||||||
|
tag.insert_user_text("MusicBrainz Artist Id".to_string(), mb_artist_id.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref mb_album_artist_id) = metadata.mb_album_artist_id {
|
||||||
|
tag.insert_user_text(
|
||||||
|
"MusicBrainz Album Artist Id".to_string(),
|
||||||
|
mb_album_artist_id.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(ref mb_release_group_id) = metadata.mb_release_group_id {
|
||||||
|
tag.insert_user_text(
|
||||||
|
"MusicBrainz Release Group Id".to_string(),
|
||||||
|
mb_release_group_id.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(gain) = metadata.replaygain_track_gain {
|
||||||
|
tag.insert_user_text(
|
||||||
|
"REPLAYGAIN_TRACK_GAIN".to_string(),
|
||||||
|
format!("{:.2} dB", gain),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(peak) = metadata.replaygain_track_peak {
|
||||||
|
tag.insert_user_text("REPLAYGAIN_TRACK_PEAK".to_string(), format!("{:.6}", peak));
|
||||||
|
}
|
||||||
|
if let Some(gain) = metadata.replaygain_album_gain {
|
||||||
|
tag.insert_user_text(
|
||||||
|
"REPLAYGAIN_ALBUM_GAIN".to_string(),
|
||||||
|
format!("{:.2} dB", gain),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(peak) = metadata.replaygain_album_peak {
|
||||||
|
tag.insert_user_text("REPLAYGAIN_ALBUM_PEAK".to_string(), format!("{:.6}", peak));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref encoder) = metadata.encoder {
|
||||||
|
Self::set_text_frame(&mut tag, "TSSE", encoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
tag
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_text_frame(tag: &Id3v2Tag, frame_id: &str) -> Option<String> {
|
||||||
|
let id = FrameId::new(frame_id).ok()?;
|
||||||
|
tag.get_text(&id).map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_track_disc(value: &str) -> (Option<u32>, Option<u32>) {
|
||||||
|
let parts: Vec<&str> = value.split('/').collect();
|
||||||
|
let num = parts.first().and_then(|s| s.parse().ok());
|
||||||
|
let total = parts.get(1).and_then(|s| s.parse().ok());
|
||||||
|
(num, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_replaygain_value(value: &str) -> Option<f32> {
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches(" dB")
|
||||||
|
.trim_end_matches("dB")
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_from_tag(tag: &Id3v2Tag) -> AudioMeta {
|
||||||
|
let mut meta = AudioMeta::default();
|
||||||
|
meta.format = AudioFormat::Mp3;
|
||||||
|
|
||||||
|
meta.title = tag.title().map(|c: Cow<'_, str>| c.into_owned());
|
||||||
|
meta.artist = tag.artist().map(|c: Cow<'_, str>| c.into_owned());
|
||||||
|
meta.album = tag.album().map(|c: Cow<'_, str>| c.into_owned());
|
||||||
|
meta.album_artist = Self::extract_text_frame(tag, "TPE2");
|
||||||
|
meta.genre = tag.genre().map(|c: Cow<'_, str>| c.into_owned());
|
||||||
|
|
||||||
|
if let Some(track_str) = Self::extract_text_frame(tag, "TRCK") {
|
||||||
|
let (track, track_total) = Self::parse_track_disc(&track_str);
|
||||||
|
meta.track = track;
|
||||||
|
meta.track_total = track_total;
|
||||||
|
} else {
|
||||||
|
meta.track = tag.track();
|
||||||
|
meta.track_total = tag.track_total();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(disc_str) = Self::extract_text_frame(tag, "TPOS") {
|
||||||
|
let (disc, disc_total) = Self::parse_track_disc(&disc_str);
|
||||||
|
meta.disc = disc;
|
||||||
|
meta.disc_total = disc_total;
|
||||||
|
} else {
|
||||||
|
meta.disc = tag.disk();
|
||||||
|
meta.disc_total = tag.disk_total();
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.date = Self::extract_text_frame(tag, "TDRC");
|
||||||
|
if let Some(ref date) = meta.date {
|
||||||
|
if let Some(year_str) = date.split('-').next() {
|
||||||
|
meta.year = year_str.parse().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.composer = Self::extract_text_frame(tag, "TCOM");
|
||||||
|
meta.comment = tag.comment().map(|c: Cow<'_, str>| c.into_owned());
|
||||||
|
|
||||||
|
if let Some(uslt) = tag.unsync_text().next() {
|
||||||
|
meta.lyrics = Some(uslt.content.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.copyright = Self::extract_text_frame(tag, "TCOP");
|
||||||
|
|
||||||
|
if let Some(tcmp) = Self::extract_text_frame(tag, "TCMP") {
|
||||||
|
meta.compilation = Some(tcmp == "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.title_sort = Self::extract_text_frame(tag, "TSOT");
|
||||||
|
meta.artist_sort = Self::extract_text_frame(tag, "TSOP");
|
||||||
|
meta.album_sort = Self::extract_text_frame(tag, "TSOA");
|
||||||
|
meta.album_artist_sort = Self::extract_text_frame(tag, "TSO2");
|
||||||
|
|
||||||
|
meta.mb_recording_id = tag.get_user_text("MusicBrainz Recording Id").map(String::from);
|
||||||
|
meta.mb_album_id = tag.get_user_text("MusicBrainz Album Id").map(String::from);
|
||||||
|
meta.mb_artist_id = tag.get_user_text("MusicBrainz Artist Id").map(String::from);
|
||||||
|
meta.mb_album_artist_id = tag
|
||||||
|
.get_user_text("MusicBrainz Album Artist Id")
|
||||||
|
.map(String::from);
|
||||||
|
meta.mb_release_group_id = tag
|
||||||
|
.get_user_text("MusicBrainz Release Group Id")
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
if let Some(gain_str) = tag.get_user_text("REPLAYGAIN_TRACK_GAIN") {
|
||||||
|
meta.replaygain_track_gain = Self::parse_replaygain_value(gain_str);
|
||||||
|
}
|
||||||
|
if let Some(peak_str) = tag.get_user_text("REPLAYGAIN_TRACK_PEAK") {
|
||||||
|
meta.replaygain_track_peak = peak_str.parse::<f32>().ok();
|
||||||
|
}
|
||||||
|
if let Some(gain_str) = tag.get_user_text("REPLAYGAIN_ALBUM_GAIN") {
|
||||||
|
meta.replaygain_album_gain = Self::parse_replaygain_value(gain_str);
|
||||||
|
}
|
||||||
|
if let Some(peak_str) = tag.get_user_text("REPLAYGAIN_ALBUM_PEAK") {
|
||||||
|
meta.replaygain_album_peak = peak_str.parse::<f32>().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.encoder = Self::extract_text_frame(tag, "TSSE");
|
||||||
|
|
||||||
|
meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Id3v2Handler {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormatHandler for Id3v2Handler {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"id3v2"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"ID3v2 (MP3)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extensions(&self) -> &[&'static str] {
|
||||||
|
&["mp3"]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mime_types(&self) -> &[&'static str] {
|
||||||
|
&["audio/mpeg"]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analyze(&self, data: &[u8], file_size: u64) -> Result<FormatLayout, FormatError> {
|
||||||
|
let audio_start = Self::parse_id3v2_header(data).unwrap_or(0) as u64;
|
||||||
|
|
||||||
|
let audio_end = if Self::has_id3v1_tag(data, file_size) {
|
||||||
|
file_size - ID3V1_TAG_SIZE as u64
|
||||||
|
} else {
|
||||||
|
file_size
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(FormatLayout {
|
||||||
|
audio_start,
|
||||||
|
audio_end,
|
||||||
|
format: AudioFormat::Mp3,
|
||||||
|
format_data: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synthesize(
|
||||||
|
&self,
|
||||||
|
metadata: &AudioMeta,
|
||||||
|
_layout: &FormatLayout,
|
||||||
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
|
let tag = Self::build_tag_from_meta(metadata);
|
||||||
|
|
||||||
|
let mut buffer = Cursor::new(Vec::new());
|
||||||
|
let write_options = WriteOptions::new().preferred_padding(1024);
|
||||||
|
|
||||||
|
tag.dump_to(&mut buffer, write_options)
|
||||||
|
.map_err(|e| FormatError::SynthesisFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(buffer.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract(&self, data: &[u8]) -> Result<AudioMeta, FormatError> {
|
||||||
|
let mut cursor = Cursor::new(data);
|
||||||
|
|
||||||
|
let mpeg_file = MpegFile::read_from(&mut cursor, ParseOptions::new())
|
||||||
|
.map_err(|e| FormatError::InvalidData(e.to_string()))?;
|
||||||
|
|
||||||
|
let tag = mpeg_file
|
||||||
|
.id3v2()
|
||||||
|
.ok_or_else(|| FormatError::InvalidData("No ID3v2 tag found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Self::extract_from_tag(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_header_size(&self, _metadata: &AudioMeta) -> usize {
|
||||||
|
4096 + 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn syncsafe_decode(bytes: &[u8]) -> usize {
|
||||||
|
((bytes[0] as usize) << 21)
|
||||||
|
| ((bytes[1] as usize) << 14)
|
||||||
|
| ((bytes[2] as usize) << 7)
|
||||||
|
| (bytes[3] as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_test_meta() -> AudioMeta {
|
||||||
|
AudioMeta {
|
||||||
|
title: Some("Test Title".to_string()),
|
||||||
|
artist: Some("Test Artist".to_string()),
|
||||||
|
album: Some("Test Album".to_string()),
|
||||||
|
album_artist: Some("Test Album Artist".to_string()),
|
||||||
|
genre: Some("Rock".to_string()),
|
||||||
|
year: Some(2024),
|
||||||
|
track: Some(5),
|
||||||
|
track_total: Some(12),
|
||||||
|
disc: Some(1),
|
||||||
|
disc_total: Some(2),
|
||||||
|
format: AudioFormat::Mp3,
|
||||||
|
date: Some("2024-03-15".to_string()),
|
||||||
|
composer: Some("Test Composer".to_string()),
|
||||||
|
comment: Some("Test Comment".to_string()),
|
||||||
|
lyrics: Some("Test Lyrics\nLine 2".to_string()),
|
||||||
|
copyright: Some("2024 Test Copyright".to_string()),
|
||||||
|
compilation: Some(false),
|
||||||
|
title_sort: Some("Title, Test".to_string()),
|
||||||
|
artist_sort: Some("Artist, Test".to_string()),
|
||||||
|
album_sort: Some("Album, Test".to_string()),
|
||||||
|
album_artist_sort: Some("Album Artist, Test".to_string()),
|
||||||
|
mb_recording_id: Some("rec-12345".to_string()),
|
||||||
|
mb_album_id: Some("alb-12345".to_string()),
|
||||||
|
mb_artist_id: Some("art-12345".to_string()),
|
||||||
|
mb_album_artist_id: Some("albart-12345".to_string()),
|
||||||
|
mb_release_group_id: Some("rg-12345".to_string()),
|
||||||
|
replaygain_track_gain: Some(-6.5),
|
||||||
|
replaygain_track_peak: Some(0.987654),
|
||||||
|
replaygain_album_gain: Some(-5.2),
|
||||||
|
replaygain_album_peak: Some(0.999999),
|
||||||
|
encoder: Some("LAME 3.100".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_id_and_name() {
|
||||||
|
let handler = Id3v2Handler::new();
|
||||||
|
assert_eq!(handler.id(), "id3v2");
|
||||||
|
assert_eq!(handler.name(), "ID3v2 (MP3)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extensions_and_mime_types() {
|
||||||
|
let handler = Id3v2Handler::new();
|
||||||
|
assert_eq!(handler.extensions(), &["mp3"]);
|
||||||
|
assert_eq!(handler.mime_types(), &["audio/mpeg"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_header_size() {
|
||||||
|
let handler = Id3v2Handler::new();
|
||||||
|
let meta = AudioMeta::default();
|
||||||
|
assert_eq!(handler.estimate_header_size(&meta), 5120);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_synthesize_creates_valid_id3v2() {
|
||||||
|
let handler = Id3v2Handler::new();
|
||||||
|
let meta = make_test_meta();
|
||||||
|
let layout = FormatLayout {
|
||||||
|
audio_start: 0,
|
||||||
|
audio_end: 1000,
|
||||||
|
format: AudioFormat::Mp3,
|
||||||
|
format_data: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = handler.synthesize(&meta, &layout);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let bytes = result.unwrap();
|
||||||
|
assert!(bytes.len() >= 10);
|
||||||
|
assert_eq!(&bytes[0..3], b"ID3");
|
||||||
|
assert_eq!(bytes[3], 0x04);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_no_id3v2() {
|
||||||
|
let handler = Id3v2Handler::new();
|
||||||
|
let data = vec![0xFF, 0xFB, 0x90, 0x00];
|
||||||
|
let file_size = 1000;
|
||||||
|
|
||||||
|
let result = handler.analyze(&data, file_size);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let layout = result.unwrap();
|
||||||
|
assert_eq!(layout.audio_start, 0);
|
||||||
|
assert_eq!(layout.audio_end, 1000);
|
||||||
|
assert_eq!(layout.format, AudioFormat::Mp3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_with_id3v2() {
|
||||||
|
let handler = Id3v2Handler::new();
|
||||||
|
|
||||||
|
let mut data = vec![
|
||||||
|
b'I', b'D', b'3', 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64,
|
||||||
|
];
|
||||||
|
data.extend(vec![0u8; 100]);
|
||||||
|
let file_size = data.len() as u64;
|
||||||
|
|
||||||
|
let result = handler.analyze(&data, file_size);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let layout = result.unwrap();
|
||||||
|
assert_eq!(layout.audio_start, 110);
|
||||||
|
assert_eq!(layout.audio_end, file_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_with_id3v1() {
|
||||||
|
let handler = Id3v2Handler::new();
|
||||||
|
|
||||||
|
let mut data = vec![0xFF, 0xFB, 0x90, 0x00];
|
||||||
|
data.extend(vec![0u8; 100]);
|
||||||
|
data.extend(b"TAG");
|
||||||
|
data.extend(vec![0u8; 125]);
|
||||||
|
let file_size = data.len() as u64;
|
||||||
|
|
||||||
|
let result = handler.analyze(&data, file_size);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let layout = result.unwrap();
|
||||||
|
assert_eq!(layout.audio_start, 0);
|
||||||
|
assert_eq!(layout.audio_end, file_size - 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_syncsafe_decode() {
|
||||||
|
assert_eq!(syncsafe_decode(&[0x00, 0x00, 0x00, 0x7F]), 127);
|
||||||
|
assert_eq!(syncsafe_decode(&[0x00, 0x00, 0x01, 0x00]), 128);
|
||||||
|
assert_eq!(syncsafe_decode(&[0x00, 0x00, 0x00, 0x64]), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_track_disc() {
|
||||||
|
assert_eq!(Id3v2Handler::parse_track_disc("5/12"), (Some(5), Some(12)));
|
||||||
|
assert_eq!(Id3v2Handler::parse_track_disc("5"), (Some(5), None));
|
||||||
|
assert_eq!(Id3v2Handler::parse_track_disc(""), (None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_replaygain_value() {
|
||||||
|
assert_eq!(
|
||||||
|
Id3v2Handler::parse_replaygain_value("-6.50 dB"),
|
||||||
|
Some(-6.50)
|
||||||
|
);
|
||||||
|
assert_eq!(Id3v2Handler::parse_replaygain_value("-6.50dB"), Some(-6.50));
|
||||||
|
assert_eq!(Id3v2Handler::parse_replaygain_value("-6.50"), Some(-6.50));
|
||||||
|
assert_eq!(Id3v2Handler::parse_replaygain_value("invalid"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_metadata_produces_empty_tag() {
|
||||||
|
let handler = Id3v2Handler::new();
|
||||||
|
let meta = AudioMeta::default();
|
||||||
|
let layout = FormatLayout {
|
||||||
|
audio_start: 0,
|
||||||
|
audio_end: 1000,
|
||||||
|
format: AudioFormat::Mp3,
|
||||||
|
format_data: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = handler.synthesize(&meta, &layout);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let bytes = result.unwrap();
|
||||||
|
assert!(bytes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minimal_metadata_produces_valid_tag() {
|
||||||
|
let handler = Id3v2Handler::new();
|
||||||
|
let mut meta = AudioMeta::default();
|
||||||
|
meta.title = Some("Test".to_string());
|
||||||
|
let layout = FormatLayout {
|
||||||
|
audio_start: 0,
|
||||||
|
audio_end: 1000,
|
||||||
|
format: AudioFormat::Mp3,
|
||||||
|
format_data: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = handler.synthesize(&meta, &layout);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let bytes = result.unwrap();
|
||||||
|
assert!(bytes.len() >= 10);
|
||||||
|
assert_eq!(&bytes[0..3], b"ID3");
|
||||||
|
assert_eq!(bytes[3], 0x04);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_and_extract_tag() {
|
||||||
|
let original_meta = make_test_meta();
|
||||||
|
let tag = Id3v2Handler::build_tag_from_meta(&original_meta);
|
||||||
|
let extracted = Id3v2Handler::extract_from_tag(&tag);
|
||||||
|
|
||||||
|
assert_eq!(extracted.title, original_meta.title);
|
||||||
|
assert_eq!(extracted.artist, original_meta.artist);
|
||||||
|
assert_eq!(extracted.album, original_meta.album);
|
||||||
|
assert_eq!(extracted.album_artist, original_meta.album_artist);
|
||||||
|
assert_eq!(extracted.genre, original_meta.genre);
|
||||||
|
assert_eq!(extracted.track, original_meta.track);
|
||||||
|
assert_eq!(extracted.track_total, original_meta.track_total);
|
||||||
|
assert_eq!(extracted.disc, original_meta.disc);
|
||||||
|
assert_eq!(extracted.disc_total, original_meta.disc_total);
|
||||||
|
assert_eq!(extracted.composer, original_meta.composer);
|
||||||
|
assert_eq!(extracted.comment, original_meta.comment);
|
||||||
|
assert_eq!(extracted.lyrics, original_meta.lyrics);
|
||||||
|
assert_eq!(extracted.copyright, original_meta.copyright);
|
||||||
|
assert_eq!(extracted.compilation, original_meta.compilation);
|
||||||
|
assert_eq!(extracted.title_sort, original_meta.title_sort);
|
||||||
|
assert_eq!(extracted.artist_sort, original_meta.artist_sort);
|
||||||
|
assert_eq!(extracted.album_sort, original_meta.album_sort);
|
||||||
|
assert_eq!(extracted.album_artist_sort, original_meta.album_artist_sort);
|
||||||
|
assert_eq!(extracted.mb_recording_id, original_meta.mb_recording_id);
|
||||||
|
assert_eq!(extracted.mb_album_id, original_meta.mb_album_id);
|
||||||
|
assert_eq!(extracted.mb_artist_id, original_meta.mb_artist_id);
|
||||||
|
assert_eq!(extracted.mb_album_artist_id, original_meta.mb_album_artist_id);
|
||||||
|
assert_eq!(
|
||||||
|
extracted.mb_release_group_id,
|
||||||
|
original_meta.mb_release_group_id
|
||||||
|
);
|
||||||
|
assert_eq!(extracted.encoder, original_meta.encoder);
|
||||||
|
|
||||||
|
let orig_track_gain = original_meta.replaygain_track_gain.unwrap();
|
||||||
|
let ext_track_gain = extracted.replaygain_track_gain.unwrap();
|
||||||
|
assert!((orig_track_gain - ext_track_gain).abs() < 0.01);
|
||||||
|
|
||||||
|
let orig_track_peak = original_meta.replaygain_track_peak.unwrap();
|
||||||
|
let ext_track_peak = extracted.replaygain_track_peak.unwrap();
|
||||||
|
assert!((orig_track_peak - ext_track_peak).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//! Format-specific metadata handlers for audio file synthesis.
|
||||||
|
//!
|
||||||
|
//! Each handler implements the `FormatHandler` trait to support:
|
||||||
|
//! - Analyzing original files to find audio boundaries
|
||||||
|
//! - Synthesizing new headers from database metadata
|
||||||
|
//! - Extracting metadata from existing files
|
||||||
|
|
||||||
|
mod id3v2;
|
||||||
|
|
||||||
|
pub use id3v2::Id3v2Handler;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
mod artwork;
|
mod artwork;
|
||||||
mod db;
|
mod db;
|
||||||
mod eviction;
|
mod eviction;
|
||||||
|
mod format_handler;
|
||||||
|
mod format_layout;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
mod patterns;
|
mod patterns;
|
||||||
mod prefetch;
|
mod prefetch;
|
||||||
@@ -9,6 +11,8 @@ mod tree;
|
|||||||
pub use artwork::{ArtworkCache, ArtworkError, CachedArtwork};
|
pub use artwork::{ArtworkCache, ArtworkError, CachedArtwork};
|
||||||
pub use db::{Database, TrashedFile, TrashedFilter};
|
pub use db::{Database, TrashedFile, TrashedFilter};
|
||||||
pub use eviction::{EvictionError, EvictionPolicy, LruEviction};
|
pub use eviction::{EvictionError, EvictionPolicy, LruEviction};
|
||||||
|
pub use format_handler::{FormatError, FormatHandler, FormatHandlerRegistry};
|
||||||
|
pub use format_layout::FormatLayout;
|
||||||
pub use metadata::MetadataCache;
|
pub use metadata::MetadataCache;
|
||||||
pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore};
|
pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore};
|
||||||
pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle};
|
pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle};
|
||||||
|
|||||||
@@ -20,6 +20,32 @@ CREATE TABLE IF NOT EXISTS files (
|
|||||||
bitrate INTEGER,
|
bitrate INTEGER,
|
||||||
sample_rate INTEGER,
|
sample_rate INTEGER,
|
||||||
format TEXT,
|
format TEXT,
|
||||||
|
track_total INTEGER,
|
||||||
|
disc_total INTEGER,
|
||||||
|
date TEXT,
|
||||||
|
composer TEXT,
|
||||||
|
comment TEXT,
|
||||||
|
lyrics TEXT,
|
||||||
|
copyright TEXT,
|
||||||
|
compilation INTEGER,
|
||||||
|
artist_sort TEXT,
|
||||||
|
album_artist_sort TEXT,
|
||||||
|
album_sort TEXT,
|
||||||
|
title_sort TEXT,
|
||||||
|
mb_recording_id TEXT,
|
||||||
|
mb_album_id TEXT,
|
||||||
|
mb_artist_id TEXT,
|
||||||
|
mb_album_artist_id TEXT,
|
||||||
|
mb_release_group_id TEXT,
|
||||||
|
replaygain_track_gain REAL,
|
||||||
|
replaygain_track_peak REAL,
|
||||||
|
replaygain_album_gain REAL,
|
||||||
|
replaygain_album_peak REAL,
|
||||||
|
channels INTEGER,
|
||||||
|
bits_per_sample INTEGER,
|
||||||
|
encoder TEXT,
|
||||||
|
custom_tags TEXT,
|
||||||
|
format_layout BLOB,
|
||||||
|
|
||||||
origin_mtime INTEGER NOT NULL,
|
origin_mtime INTEGER NOT NULL,
|
||||||
origin_size INTEGER NOT NULL,
|
origin_size INTEGER NOT NULL,
|
||||||
@@ -59,6 +85,11 @@ CREATE INDEX IF NOT EXISTS idx_files_content_hash ON files(content_hash);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_files_real ON files(origin_id, real_path);
|
CREATE INDEX IF NOT EXISTS idx_files_real ON files(origin_id, real_path);
|
||||||
CREATE INDEX IF NOT EXISTS idx_files_origin ON files(origin_id);
|
CREATE INDEX IF NOT EXISTS idx_files_origin ON files(origin_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_files_last_sync ON files(last_sync);
|
CREATE INDEX IF NOT EXISTS idx_files_last_sync ON files(last_sync);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_mb_album ON files(mb_album_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_mb_artist ON files(mb_artist_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_genre ON files(genre);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_year ON files(year);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_composer ON files(composer);
|
||||||
CREATE INDEX IF NOT EXISTS idx_artwork_file ON artwork(file_id);
|
CREATE INDEX IF NOT EXISTS idx_artwork_file ON artwork(file_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS directories (
|
CREATE TABLE IF NOT EXISTS directories (
|
||||||
|
|||||||
@@ -132,6 +132,30 @@ pub struct AudioMeta {
|
|||||||
pub bitrate: Option<u32>,
|
pub bitrate: Option<u32>,
|
||||||
pub sample_rate: Option<u32>,
|
pub sample_rate: Option<u32>,
|
||||||
pub format: AudioFormat,
|
pub format: AudioFormat,
|
||||||
|
pub track_total: Option<u32>,
|
||||||
|
pub disc_total: Option<u32>,
|
||||||
|
pub date: Option<String>,
|
||||||
|
pub composer: Option<String>,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub lyrics: Option<String>,
|
||||||
|
pub copyright: Option<String>,
|
||||||
|
pub compilation: Option<bool>,
|
||||||
|
pub artist_sort: Option<String>,
|
||||||
|
pub album_artist_sort: Option<String>,
|
||||||
|
pub album_sort: Option<String>,
|
||||||
|
pub title_sort: Option<String>,
|
||||||
|
pub mb_recording_id: Option<String>,
|
||||||
|
pub mb_album_id: Option<String>,
|
||||||
|
pub mb_artist_id: Option<String>,
|
||||||
|
pub mb_album_artist_id: Option<String>,
|
||||||
|
pub mb_release_group_id: Option<String>,
|
||||||
|
pub replaygain_track_gain: Option<f32>,
|
||||||
|
pub replaygain_track_peak: Option<f32>,
|
||||||
|
pub replaygain_album_gain: Option<f32>,
|
||||||
|
pub replaygain_album_peak: Option<f32>,
|
||||||
|
pub channels: Option<u32>,
|
||||||
|
pub bits_per_sample: Option<u32>,
|
||||||
|
pub encoder: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub fn make_audio_meta(artist: &str, album: &str, title: &str) -> AudioMeta {
|
|||||||
bitrate: Some(320),
|
bitrate: Some(320),
|
||||||
sample_rate: Some(44100),
|
sample_rate: Some(44100),
|
||||||
format: AudioFormat::Flac,
|
format: AudioFormat::Flac,
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user