feat(backend): migrate production backend to Rust

Replace the Go backend with the Rust workspace and route Android/iOS through
UniFFI bindings. Include the migrated extension runtime, network, providers,
media metadata, downloads and library operations, with version 5.0.0+147.

Remove Go sources, adapters, native build selection and CI dependencies.
Build Rust unconditionally and lock the iOS Rust pod using a relative path.
Retain legacy source and migration evidence in a local ignored archive.

Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch
equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and
diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI
build checkpoint; no new APK or iOS application build for this cleanup.

Known follow-up: URL/URLSearchParams globals are missing from the Rust JS
runtime. A controlled extension replay confirms a URL-resolution regression;
this commit does not fix that runtime gap.
This commit is contained in:
zarzet committed 2026-09-14 22:58:27 +07:00
1 parent 1da0da18a6
commit aa99726439
452 files changed
+64417 -65088

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
//! One application version shared by native owners, VMs and HTTP clients.
use std::sync::{Arc, RwLock};
#[derive(Debug, Default)]
struct State {
version: String,
closed: bool,
}
#[derive(Clone, Debug, Default)]
pub struct AppVersion(Arc<RwLock<State>>);
#[derive(Clone, Copy, Debug)]
pub struct AppVersionClosed;
impl std::fmt::Display for AppVersionClosed {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("application version state closed")
}
}
impl std::error::Error for AppVersionClosed {}
impl AppVersion {
pub fn get(&self) -> String {
self.0
.read()
.expect("application version lock")
.version
.clone()
}
pub fn user_agent(&self) -> String {
let version = self.get();
if version.is_empty() {
"SpotiFLAC-Mobile".into()
} else {
format!("SpotiFLAC-Mobile/{version}")
}
}
pub fn set(&self, version: &str) -> Result<(), AppVersionClosed> {
let mut state = self.0.write().expect("application version lock");
if state.closed {
return Err(AppVersionClosed);
}
state.version = version.trim().into();
Ok(())
}
/// Reads keep the last snapshot for calls already unwinding. Owners reject
/// new calls separately; no retained handle can change a closed version.
pub fn close(&self) {
self.0.write().expect("application version lock").closed = true;
}
}
impl From<&str> for AppVersion {
fn from(version: &str) -> Self {
Self(Arc::new(RwLock::new(State {
version: version.trim().into(),
closed: false,
})))
}
}
impl From<String> for AppVersion {
fn from(version: String) -> Self {
Self::from(version.as_str())
}
}
#[cfg(test)]
mod tests {
use super::AppVersion;
#[test]
fn shared_updates_are_atomic_trimmed_and_cannot_outlive_the_owner() {
let version = AppVersion::from("\u{0085} 1.0 \u{2003}");
assert_eq!(version.get(), "1.0");
let retained = version.clone();
std::thread::scope(|scope| {
for value in ["", "first version", "second version"] {
let version = &version;
scope.spawn(move || {
for _ in 0..1000 {
version.set(value).unwrap();
assert!(matches!(
version.user_agent().as_str(),
"SpotiFLAC-Mobile"
| "SpotiFLAC-Mobile/first version"
| "SpotiFLAC-Mobile/second version"
));
}
});
}
});
version.set("").unwrap();
assert_eq!(retained.user_agent(), "SpotiFLAC-Mobile");
version.close();
assert!(retained.set("new").is_err());
assert_eq!(retained.get(), "");
assert_eq!(AppVersion::from("separate owner").get(), "separate owner");
}
}
@@ -0,0 +1,395 @@
//! Request cancellation with Go-compatible sentinels and reference counting.
//!
//! Each registry owns one domain. Leases release their reference on drop; an
//! explicit release also wakes a pending wait. Shutdown closes the instance and
//! wakes all leases, including untracked requests with an empty ID.
use std::collections::BTreeMap;
use std::fmt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, Weak};
use std::task::Waker;
use std::thread::Thread;
use std::time::{Duration, Instant};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CancellationDomain {
Download,
ExtensionRequest,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CancellationError {
DownloadCancelled,
ExtensionRequestCancelled,
RegistryClosed,
LeaseReleased,
}
impl fmt::Display for CancellationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::DownloadCancelled => "download cancelled",
Self::ExtensionRequestCancelled => "extension request cancelled",
Self::RegistryClosed => "cancellation registry closed",
Self::LeaseReleased => "request lease released",
})
}
}
impl std::error::Error for CancellationError {}
#[derive(Default)]
struct Entry {
cancelled: Arc<AtomicBool>,
references: usize,
}
#[derive(Default)]
struct State {
closed: bool,
entries: BTreeMap<String, Entry>,
}
impl State {
fn check_open(&self) -> Result<(), CancellationError> {
if self.closed {
Err(CancellationError::RegistryClosed)
} else {
Ok(())
}
}
}
#[derive(Default)]
struct Shared {
state: Mutex<State>,
changed: Condvar,
waker: Option<Waker>,
threads: Mutex<Vec<Weak<Thread>>>,
}
impl Shared {
fn notify(&self) {
self.changed.notify_all();
self.threads
.lock()
.expect("cancellation thread lock")
.retain(|thread| {
if let Some(thread) = thread.upgrade() {
thread.unpark();
true
} else {
false
}
});
if let Some(waker) = &self.waker {
waker.wake_by_ref();
}
}
}
pub struct CancellationRegistry {
shared: Arc<Shared>,
domain: CancellationDomain,
}
impl CancellationRegistry {
pub fn new(domain: CancellationDomain) -> Self {
Self {
shared: Arc::new(Shared::default()),
domain,
}
}
/// Wake an external executor after cancellation, release or shutdown.
/// The executor must recheck its own request; other IDs may still be active.
pub fn with_waker(domain: CancellationDomain, waker: Waker) -> Self {
Self {
shared: Arc::new(Shared {
waker: Some(waker),
..Shared::default()
}),
domain,
}
}
pub fn acquire(&self, id: &str) -> Result<RequestLease, CancellationError> {
let mut state = self.shared.state.lock().expect("cancellation state lock");
state.check_open()?;
let cancelled = if id.is_empty() {
Arc::new(AtomicBool::new(false))
} else {
let entry = state.entries.entry(id.to_owned()).or_default();
entry.references += 1;
Arc::clone(&entry.cancelled)
};
Ok(RequestLease {
shared: Arc::clone(&self.shared),
id: id.to_owned(),
domain: self.domain,
cancelled,
released: AtomicBool::new(false),
})
}
pub fn cancel(&self, id: &str) -> Result<(), CancellationError> {
let mut state = self.shared.state.lock().expect("cancellation state lock");
state.check_open()?;
if !id.is_empty() {
state
.entries
.entry(id.to_owned())
.or_default()
.cancelled
.store(true, Ordering::Release);
drop(state);
self.shared.notify();
}
Ok(())
}
/// Cancel active work only. Idle sentinels remain available to reset/retry.
/// IDs are sorted for deterministic native results; Go's ordering is unspecified.
pub fn cancel_active(&self) -> Result<Vec<String>, CancellationError> {
let state = self.shared.state.lock().expect("cancellation state lock");
state.check_open()?;
let mut ids = Vec::new();
for (id, entry) in &state.entries {
if entry.references > 0 {
entry.cancelled.store(true, Ordering::Release);
ids.push(id.clone());
}
}
drop(state);
self.shared.notify();
Ok(ids)
}
pub fn is_cancelled(&self, id: &str) -> Result<bool, CancellationError> {
let state = self.shared.state.lock().expect("cancellation state lock");
state.check_open()?;
Ok(state
.entries
.get(id)
.is_some_and(|entry| entry.cancelled.load(Ordering::Acquire)))
}
pub fn reset_if_idle(&self, id: &str) -> Result<(), CancellationError> {
let mut state = self.shared.state.lock().expect("cancellation state lock");
state.check_open()?;
if state
.entries
.get(id)
.is_some_and(|entry| entry.references == 0)
{
state.entries.remove(id);
}
Ok(())
}
/// Terminal and idempotent. A new registry is required for subsequent work.
pub fn shutdown(&self) {
let mut state = self.shared.state.lock().expect("cancellation state lock");
state.closed = true;
state.entries.clear();
drop(state);
self.shared.notify();
}
}
impl Drop for CancellationRegistry {
fn drop(&mut self) {
self.shutdown();
}
}
pub struct RequestLease {
shared: Arc<Shared>,
id: String,
domain: CancellationDomain,
cancelled: Arc<AtomicBool>,
released: AtomicBool,
}
impl RequestLease {
/// Notify a parked native worker on cancellation/release/shutdown. The
/// caller retains the thread while waiting and rechecks after subscribing.
pub fn observe_thread(&self, thread: &Arc<Thread>) {
let mut threads = self
.shared
.threads
.lock()
.expect("cancellation thread lock");
threads.retain(|thread| thread.strong_count() > 0);
threads.push(Arc::downgrade(thread));
}
pub fn domain(&self) -> CancellationDomain {
self.domain
}
/// Query the active request identity with Go's whitespace normalization.
/// The lease itself still retains its original, exact cancellation ID.
pub fn active_request_cancelled(&self) -> bool {
if self.domain != CancellationDomain::ExtensionRequest || self.id.trim().is_empty() {
return false;
}
let state = self.shared.state.lock().expect("cancellation state lock");
state
.entries
.get(self.id.trim())
.is_some_and(|entry| entry.cancelled.load(Ordering::Acquire))
}
fn check_live(&self, state: &State) -> Result<(), CancellationError> {
state.check_open()?;
if self.released.load(Ordering::Acquire) {
return Err(CancellationError::LeaseReleased);
}
Ok(())
}
pub fn is_cancelled(&self) -> Result<bool, CancellationError> {
let state = self.shared.state.lock().expect("cancellation state lock");
self.check_live(&state)?;
Ok(self.cancelled.load(Ordering::Acquire))
}
/// Preserve the exact existing download/extension cancellation messages.
pub fn check_active(&self) -> Result<(), CancellationError> {
if self.is_cancelled()? {
return Err(match self.domain {
CancellationDomain::Download => CancellationError::DownloadCancelled,
CancellationDomain::ExtensionRequest => {
CancellationError::ExtensionRequestCancelled
}
});
}
Ok(())
}
/// Block without polling. True means cancelled, false means heartbeat expiry.
/// Bounds match the existing progress waiter: default 15 s, maximum 60 s.
/// Call from a worker thread; cancellation/release/shutdown may run elsewhere.
pub fn wait_cancelled(&self, timeout_ms: i64) -> Result<bool, CancellationError> {
let timeout = wait_timeout(timeout_ms);
let started = Instant::now();
let mut state = self.shared.state.lock().expect("cancellation state lock");
loop {
self.check_live(&state)?;
if self.cancelled.load(Ordering::Acquire) {
return Ok(true);
}
let remaining = timeout.saturating_sub(started.elapsed());
if remaining.is_zero() {
return Ok(false);
}
// Every predicate change takes this same lock, avoiding lost wakeups
// between checking the predicate and entering Condvar::wait_timeout.
(state, _) = self
.shared
.changed
.wait_timeout(state, remaining)
.expect("cancellation wait lock");
}
}
/// Release this acquisition once; other acquisitions for the same ID remain.
/// Holding or dropping this old lease cannot remove a later retry's entry.
pub fn release(&self) {
let mut state = self.shared.state.lock().expect("cancellation state lock");
if self.released.swap(true, Ordering::AcqRel) {
return;
}
if let Some(entry) = state.entries.get_mut(&self.id) {
entry.references -= 1;
if entry.references == 0 {
state.entries.remove(&self.id);
}
}
drop(state);
self.shared.notify();
}
}
impl Drop for RequestLease {
fn drop(&mut self) {
self.release();
}
}
fn wait_timeout(timeout_ms: i64) -> Duration {
let bounded_ms = if timeout_ms <= 0 {
15_000
} else {
timeout_ms.min(60_000)
};
Duration::from_millis(bounded_ms as u64)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Weak;
use std::sync::atomic::AtomicUsize;
use std::task::Wake;
#[test]
fn external_waker_observes_changes_without_holding_the_registry_lock() {
#[derive(Default)]
struct Observer {
shared: Mutex<Weak<Shared>>,
calls: AtomicUsize,
}
impl Wake for Observer {
fn wake(self: Arc<Self>) {
if let Some(shared) = self.shared.lock().unwrap().upgrade() {
// Executors may synchronously inspect cancellation on wake.
let _state = shared.state.try_lock().expect("wake held registry lock");
self.calls.fetch_add(1, Ordering::Relaxed);
}
}
}
let observer = Arc::new(Observer::default());
let registry = CancellationRegistry::with_waker(
CancellationDomain::Download,
Waker::from(Arc::clone(&observer)),
);
*observer.shared.lock().unwrap() = Arc::downgrade(&registry.shared);
let first = registry.acquire("first").unwrap();
let second = registry.acquire("second").unwrap();
let anonymous = registry.acquire("").unwrap();
registry.cancel("first").unwrap();
assert!(first.is_cancelled().unwrap());
assert!(!second.is_cancelled().unwrap());
assert_eq!(observer.calls.load(Ordering::Relaxed), 1);
registry.cancel("").unwrap();
assert_eq!(observer.calls.load(Ordering::Relaxed), 1);
assert_eq!(registry.cancel_active().unwrap(), ["first", "second"]);
assert!(second.is_cancelled().unwrap());
assert!(!anonymous.is_cancelled().unwrap());
assert_eq!(observer.calls.load(Ordering::Relaxed), 2);
second.release();
second.release();
assert_eq!(second.check_active(), Err(CancellationError::LeaseReleased));
assert_eq!(observer.calls.load(Ordering::Relaxed), 3);
registry.shutdown();
assert_eq!(
anonymous.check_active(),
Err(CancellationError::RegistryClosed)
);
assert_eq!(observer.calls.load(Ordering::Relaxed), 4);
}
#[test]
fn timeout_bounds_do_not_overflow() {
for input in [i64::MIN, -1, 0] {
assert_eq!(wait_timeout(input), Duration::from_secs(15));
}
assert_eq!(wait_timeout(1), Duration::from_millis(1));
for input in [60_000, 60_001, i64::MAX] {
assert_eq!(wait_timeout(input), Duration::from_secs(60));
}
}
}
+117
View File
@@ -0,0 +1,117 @@
use chrono::{DateTime, Datelike, Timelike, Utc};
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalTime {
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
weekday: u32,
offset_minutes: i32,
timezone: String,
timestamp: i64,
}
pub fn local_time() -> LocalTime {
local_time_at(Utc::now().timestamp()).expect("current timestamp fits calendar range")
}
pub fn local_time_at(timestamp: i64) -> Result<LocalTime, String> {
let (offset, _, timezone) = zone_at(timestamp);
let local = DateTime::from_timestamp(
timestamp
.checked_add(i64::from(offset))
.ok_or("timestamp overflow")?,
0,
)
.ok_or("timestamp outside calendar range")?;
Ok(LocalTime {
year: local.year(),
month: local.month(),
day: local.day(),
hour: local.hour(),
minute: local.minute(),
second: local.second(),
weekday: local.weekday().num_days_from_sunday(),
offset_minutes: -(offset / 60),
timezone,
timestamp,
})
}
pub(crate) fn zone_at(timestamp: i64) -> (i32, String, String) {
// Go 1.26's Android and iOS initLocal use UTC. Host Unix builds load TZ or
// /etc/localtime once; retain that distinction in the compatibility SDK.
#[cfg(any(target_os = "android", target_os = "ios"))]
{
let _ = timestamp;
(0, "UTC".into(), "UTC".into())
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
static LOCAL: std::sync::LazyLock<(tz::TimeZone, String)> =
std::sync::LazyLock::new(load_zone);
let (zone, name) = &*LOCAL;
match zone.find_local_time_type(timestamp) {
Ok(kind) => (
kind.ut_offset(),
kind.time_zone_designation().into(),
name.clone(),
),
Err(_) => (0, "UTC".into(), "UTC".into()),
}
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn load_zone() -> (tz::TimeZone, String) {
use std::io::Read;
let load = |path: &std::path::Path| {
let file = std::fs::File::open(path).ok()?;
let mut data = Vec::new();
file.take(10 * 1024 * 1024 + 1)
.read_to_end(&mut data)
.ok()?;
if data.len() > 10 * 1024 * 1024 {
return None;
}
tz::TimeZone::from_tz_data(&data).ok()
};
let configured = std::env::var("TZ").ok();
if configured.is_none() {
if let Some(zone) = load(std::path::Path::new("/etc/localtime")) {
return (zone, "Local".into());
}
} else if let Some(raw) = configured {
let name = raw.strip_prefix(':').unwrap_or(&raw);
if name.starts_with('/') {
if let Some(zone) = load(std::path::Path::new(name)) {
return (
zone,
if name == "/etc/localtime" {
"Local"
} else {
name
}
.into(),
);
}
} else if !name.is_empty() && name != "UTC" && !name.contains("..") {
for directory in [
"/usr/share/zoneinfo",
"/usr/share/lib/zoneinfo",
"/usr/lib/locale/TZ",
"/etc/zoneinfo",
] {
if let Some(zone) = load(&std::path::Path::new(directory).join(name)) {
return (zone, name.into());
}
}
}
}
(tz::TimeZone::utc(), "UTC".into())
}
+242
View File
@@ -0,0 +1,242 @@
//! Provider artwork sizing. Resolution selection remains extension-owned.
use image::{DynamicImage, ImageFormat, ImageReader};
use std::borrow::Cow;
use std::io::Cursor;
pub const MAX_DOWNLOAD_BYTES: usize = 24 << 20;
pub const LIBRARY_MAX_DIMENSION: i64 = 800;
const MAX_DECODE_PIXELS: u64 = 16_000_000;
fn reader(data: &[u8]) -> Result<ImageReader<Cursor<&[u8]>>, String> {
let reader = ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|error| error.to_string())?;
match reader.format() {
Some(ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::Gif | ImageFormat::WebP) => {
Ok(reader)
}
_ => Err("unknown image format".into()),
}
}
pub fn dimensions(data: &[u8]) -> (u32, u32) {
reader(data)
.and_then(|reader| reader.into_dimensions().map_err(|error| error.to_string()))
.unwrap_or_default()
}
/// Keep original bytes unless shrinking is required. Returned PNG pixels retain
/// alpha; other decoded formats are encoded as JPEG at the Go quality setting.
pub fn resize<'a>(
data: &'a [u8],
max_dimension: i64,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Cow<'a, [u8]>, String> {
check()?;
if data.is_empty() || max_dimension <= 0 {
return Ok(Cow::Borrowed(data));
}
let header = reader(data).map_err(|error| format!("decode artwork dimensions: {error}"))?;
let png = header.format() == Some(ImageFormat::Png);
let (width, height) = header
.into_dimensions()
.map_err(|error| format!("decode artwork dimensions: {error}"))?;
if width == 0 || height == 0 {
return Err(format!("invalid artwork dimensions {width}x{height}"));
}
if i64::from(width.max(height)) <= max_dimension {
return Ok(Cow::Borrowed(data));
}
if u64::from(width) * u64::from(height) > MAX_DECODE_PIXELS {
return Err(format!(
"artwork dimensions {width}x{height} exceed safe decode limit"
));
}
check()?;
let source = reader(data)?
.decode()
.map_err(|error| format!("decode artwork: {error}"))?;
check()?;
let limit = max_dimension as u32; // Positive and less than an input u32 dimension.
let (dw, dh) = if width >= height {
(
limit,
((u64::from(height) * u64::from(limit) + u64::from(width) / 2) / u64::from(width))
.max(1) as u32,
)
} else {
(
((u64::from(width) * u64::from(limit) + u64::from(height) / 2) / u64::from(height))
.max(1) as u32,
limit,
)
};
let source = Pixels::new(source);
let mut pixels = vec![0; dw as usize * dh as usize * 4];
// Match x/image/draw ApproxBiLinear: sample four neighbors at pixel centers
// in premultiplied 16-bit space, then truncate into a premultiplied RGBA8
// destination. See NOTICE.
for y in 0..dh {
let (y0, y1, fy) = neighbors(y, height, dh);
for x in 0..dw {
if x % 1024 == 0 {
check()?;
}
let (x0, x1, fx) = neighbors(x, width, dw);
let samples = [
source.at(x0, y0),
source.at(x1, y0),
source.at(x0, y1),
source.at(x1, y1),
];
let offset = (y as usize * dw as usize + x as usize) * 4;
for channel in 0..4 {
let top = (1.0 - fx) * f64::from(samples[0][channel])
+ fx * f64::from(samples[1][channel]);
let bottom = (1.0 - fx) * f64::from(samples[2][channel])
+ fx * f64::from(samples[3][channel]);
pixels[offset + channel] = (((1.0 - fy) * top + fy * bottom) as u32 >> 8) as u8;
}
}
}
check()?;
let mut encoded = Vec::new();
if png {
for pixel in pixels.as_chunks_mut::<4>().0 {
let alpha = u32::from(pixel[3]);
for channel in &mut pixel[..3] {
*channel = ((u32::from(*channel) * 0xffff)
.checked_div(alpha)
.unwrap_or(0)
>> 8)
.min(255) as u8;
}
}
let image = image::RgbaImage::from_raw(dw, dh, pixels).expect("cover pixel dimensions");
image
.write_to(&mut Cursor::new(&mut encoded), ImageFormat::Png)
.map_err(|error| format!("encode resized PNG artwork: {error}"))?;
} else {
let rgb: Vec<_> = pixels
.as_chunks::<4>()
.0
.iter()
.flat_map(|pixel| pixel[..3].iter().copied())
.collect();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 88)
.encode(&rgb, dw, dh, image::ExtendedColorType::Rgb8)
.map_err(|error| format!("encode resized JPEG artwork: {error}"))?;
}
check()?;
Ok(Cow::Owned(encoded))
}
fn neighbors(destination: u32, source_size: u32, destination_size: u32) -> (u32, u32, f64) {
let source = (f64::from(destination) + 0.5)
* (f64::from(source_size) / f64::from(destination_size))
- 0.5;
if source < 0.0 {
return (0, 0, 0.0);
}
let first = source as u32;
if first >= source_size - 1 {
return (source_size - 1, source_size - 1, 0.0);
}
(first, first + 1, source - f64::from(first))
}
enum Pixels {
Rgb(image::RgbImage),
Eight(image::RgbaImage),
Sixteen(image::ImageBuffer<image::Rgba<u16>, Vec<u16>>),
}
impl Pixels {
fn new(image: DynamicImage) -> Self {
if let DynamicImage::ImageRgb8(image) = image {
return Self::Rgb(image);
}
match image.color() {
image::ColorType::L16
| image::ColorType::La16
| image::ColorType::Rgb16
| image::ColorType::Rgba16 => Self::Sixteen(image.into_rgba16()),
_ => Self::Eight(image.into_rgba8()),
}
}
fn at(&self, x: u32, y: u32) -> [u32; 4] {
let mut pixel = match self {
Self::Rgb(image) => {
let [r, g, b] = image
.get_pixel(x, y)
.0
.map(|value| u32::from(value) * 0x101);
return [r, g, b, 0xffff];
}
Self::Eight(image) => image
.get_pixel(x, y)
.0
.map(|value| u32::from(value) * 0x101),
Self::Sixteen(image) => image.get_pixel(x, y).0.map(u32::from),
};
for channel in 0..3 {
pixel[channel] = pixel[channel] * pixel[3] / 0xffff;
}
pixel
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb_and_opaque_rgba_produce_identical_resized_pngs() {
for (width, height) in [(13, 7), (7, 13), (1, 13), (13, 1)] {
let rgb = image::RgbImage::from_fn(width, height, |x, y| {
image::Rgb([(x * 37 + y * 11) as u8, (x * 3 + y * 71) as u8, 255])
});
let rgb = DynamicImage::ImageRgb8(rgb);
let rgba = DynamicImage::ImageRgba8(rgb.to_rgba8());
let encode = |image: &DynamicImage| {
let mut output = Cursor::new(Vec::new());
image.write_to(&mut output, ImageFormat::Png).unwrap();
output.into_inner()
};
let (rgb, rgba) = (encode(&rgb), encode(&rgba));
for dimension in [1, 2, 3, 6] {
assert_eq!(
resize(&rgb, dimension, &|| Ok(())).unwrap(),
resize(&rgba, dimension, &|| Ok(())).unwrap(),
"{width}x{height} to {dimension}"
);
}
}
}
#[test]
fn webp_is_preserved_when_small_and_resized_to_jpeg_with_alpha_composited() {
let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
6,
3,
image::Rgba([20, 40, 60, 128]),
));
let mut encoded = Cursor::new(Vec::new());
image.write_to(&mut encoded, ImageFormat::WebP).unwrap();
let data = encoded.into_inner();
let unchanged = resize(&data, 6, &|| Ok(())).unwrap();
assert!(matches!(unchanged, Cow::Borrowed(_)));
assert_eq!(unchanged.as_ref(), data);
let resized = resize(&data, 3, &|| Ok(())).unwrap();
assert_eq!(image::guess_format(&resized).unwrap(), ImageFormat::Jpeg);
let image = image::load_from_memory(&resized).unwrap().into_rgb8();
assert_eq!(image.dimensions(), (3, 2));
for pixel in image.pixels() {
for (actual, expected) in pixel.0.into_iter().zip([10, 20, 30]) {
assert!(actual.abs_diff(expected) <= 3);
}
}
}
}
+380
View File
@@ -0,0 +1,380 @@
use serde::Serialize;
use std::io::Read;
const MAX_SCANNER_BUFFER: usize = 64 * 1024;
const READ_BUFFER_SIZE: usize = 8 * 1024;
#[derive(Clone, Debug, Serialize)]
pub struct CueSheet {
pub performer: String,
pub title: String,
pub file_name: String,
pub file_type: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub genre: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub date: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub comment: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub composer: String,
pub tracks: Vec<CueTrack>,
}
#[derive(Clone, Debug, Serialize)]
pub struct CueTrack {
pub number: i64,
pub title: String,
pub performer: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub isrc: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub composer: String,
pub start_time: f64,
pub pre_gap: f64,
}
/// Parse a CUE sheet from a bounded stream.
pub fn parse(
reader: &mut impl Read,
check: &dyn Fn() -> Result<(), String>,
) -> Result<CueSheet, String> {
check()?;
let mut lines = Lines {
reader,
check,
buffer: [0; READ_BUFFER_SIZE],
start: 0,
end: 0,
eof: false,
};
let mut raw_line = Vec::new();
let mut sheet = CueSheet {
performer: String::new(),
title: String::new(),
file_name: String::new(),
file_type: String::new(),
genre: String::new(),
date: String::new(),
comment: String::new(),
composer: String::new(),
tracks: Vec::new(),
};
let mut current_track: Option<CueTrack> = None;
while lines.next(&mut raw_line)? {
let mut line = crate::text::utf8(&raw_line);
line = line.trim().to_owned();
if line.is_empty() {
continue;
}
if let Some(without_bom) = line.strip_prefix('\u{feff}') {
line = without_bom.trim().to_owned();
if line.is_empty() {
continue;
}
}
if let Some((key, value)) = parse_rem(&line) {
let value = unquote(value);
match key.to_uppercase().as_str() {
"GENRE" => sheet.genre = value,
"DATE" => sheet.date = value,
"COMMENT" => sheet.comment = value,
"COMPOSER" => {
if let Some(track) = current_track.as_mut() {
track.composer = value;
} else {
sheet.composer = value;
}
}
_ => {}
}
continue;
}
if let Some(rest) = command_rest(&line, "PERFORMER ") {
let value = unquote(rest);
if let Some(track) = current_track.as_mut() {
track.performer = value;
} else {
sheet.performer = value;
}
continue;
}
if let Some(rest) = command_rest(&line, "TITLE ") {
let value = unquote(rest);
if let Some(track) = current_track.as_mut() {
track.title = value;
} else {
sheet.title = value;
}
continue;
}
if let Some(rest) = command_rest(&line, "FILE ") {
let (file_name, file_type) = parse_file_line(rest);
sheet.file_name = file_name;
sheet.file_type = file_type;
continue;
}
if command_rest(&line, "TRACK ").is_some() {
if let Some(track) = current_track.take() {
sheet.tracks.push(track);
}
let number = line
.split_whitespace()
.nth(1)
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0);
current_track = Some(CueTrack {
number,
title: String::new(),
performer: String::new(),
isrc: String::new(),
composer: String::new(),
start_time: 0.0,
pre_gap: -1.0,
});
continue;
}
if command_rest(&line, "INDEX ").is_some() && current_track.is_some() {
let parts: Vec<_> = line.split_whitespace().collect();
if parts.len() >= 3 {
let index = parts[1].parse::<i64>().unwrap_or(0);
let seconds = timestamp(parts[2]);
if let Some(track) = current_track.as_mut() {
match index {
0 => track.pre_gap = seconds,
1 => track.start_time = seconds,
_ => {}
}
}
}
continue;
}
if let Some(rest) = command_rest(&line, "ISRC ") {
if let Some(track) = current_track.as_mut() {
track.isrc = rest.trim().to_owned();
}
continue;
}
if let Some(rest) = command_rest(&line, "SONGWRITER ") {
let value = unquote(rest);
if let Some(track) = current_track.as_mut() {
track.composer = value;
} else {
sheet.composer = value;
}
}
}
if let Some(track) = current_track {
sheet.tracks.push(track);
}
if sheet.tracks.is_empty() {
return Err("no tracks found in cue file".into());
}
Ok(sheet)
}
struct Lines<'a, R> {
reader: &'a mut R,
check: &'a dyn Fn() -> Result<(), String>,
buffer: [u8; READ_BUFFER_SIZE],
start: usize,
end: usize,
eof: bool,
}
impl<R: Read> Lines<'_, R> {
fn next(&mut self, line: &mut Vec<u8>) -> Result<bool, String> {
line.clear();
let mut raw_length = 0;
loop {
(self.check)()?;
if self.start == self.end {
if self.eof {
if line.is_empty() {
return Ok(false);
}
strip_carriage_return(line);
return Ok(true);
}
let count = self
.reader
.read(&mut self.buffer)
.map_err(|error| format!("error reading cue file: {error}"))?;
if count == 0 {
self.eof = true;
continue;
}
self.start = 0;
self.end = count;
}
let remaining = &self.buffer[self.start..self.end];
if let Some(offset) = remaining.iter().position(|byte| *byte == b'\n') {
if raw_length + offset + 1 > MAX_SCANNER_BUFFER {
return Err("error reading cue file: bufio.Scanner: token too long".into());
}
line.extend_from_slice(&remaining[..offset]);
self.start += offset + 1;
strip_carriage_return(line);
return Ok(true);
}
raw_length += remaining.len();
if raw_length >= MAX_SCANNER_BUFFER {
return Err("error reading cue file: bufio.Scanner: token too long".into());
}
line.extend_from_slice(remaining);
self.start = self.end;
}
}
}
fn strip_carriage_return(line: &mut Vec<u8>) {
if line.last() == Some(&b'\r') {
line.pop();
}
}
fn command_rest<'a>(line: &'a str, command: &str) -> Option<&'a str> {
let prefix = line.get(..command.len())?;
if prefix.eq_ignore_ascii_case(command) {
line.get(command.len()..)
} else {
None
}
}
fn parse_rem(line: &str) -> Option<(&str, &str)> {
if !line.starts_with("REM ") {
return None;
}
let bytes = line.as_bytes();
let mut key_start = 3;
while key_start < bytes.len() && rem_space(bytes[key_start]) {
key_start += 1;
}
if key_start == bytes.len() {
return None;
}
let mut key_end = key_start;
while key_end < bytes.len() && !rem_space(bytes[key_end]) {
key_end += 1;
}
if key_end == bytes.len() {
return None;
}
let mut value_start = key_end;
while value_start < bytes.len() && rem_space(bytes[value_start]) {
value_start += 1;
}
if value_start == bytes.len() {
return None;
}
Some((&line[key_start..key_end], &line[value_start..]))
}
fn rem_space(byte: u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\r' | b'\n' | b'\x0c')
}
fn timestamp(value: &str) -> f64 {
let parts: Vec<_> = value.split(':').collect();
if parts.len() != 3 {
return 0.0;
}
let minutes = parts[0].parse::<i64>().unwrap_or(0);
let seconds = parts[1].parse::<i64>().unwrap_or(0);
let frames = parts[2].parse::<i64>().unwrap_or(0);
minutes as f64 * 60.0 + seconds as f64 + frames as f64 / 75.0
}
fn unquote(value: &str) -> String {
let value = value.trim();
let Some(start) = value.find('"') else {
return value.to_owned();
};
let Some(end) = value[start + 1..].find('"') else {
return value.to_owned();
};
value[start + 1..start + 1 + end].to_owned()
}
fn parse_file_line(rest: &str) -> (String, String) {
let rest = rest.trim();
if let Some(end) = rest.strip_prefix('"').and_then(|value| value.find('"')) {
let file_name = rest[1..end + 1].to_owned();
let file_type = rest[end + 2..].trim().to_owned();
return (file_name, file_type);
}
if rest.starts_with('"') {
return (rest.to_owned(), String::new());
}
let parts: Vec<_> = rest.split_whitespace().collect();
match parts.len() {
0 => (String::new(), String::new()),
1 => (parts[0].to_owned(), String::new()),
_ => (
parts[..parts.len() - 1].join(" "),
parts[parts.len() - 1].to_owned(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::io::Cursor;
#[test]
fn parse_cue_semantics_and_bounds() {
let input = "\u{feff}PERFORMER \"Album Artist\"\nREM COMPOSER \"Album Writer\"\nTITLE \"Album\"\nFILE \"album.flac\" FLAC\nTRACK 01 AUDIO\nTITLE \"Song\"\nPERFORMER \"Track Artist\"\nREM COMPOSER \"Track Writer\"\nINDEX 01 01:02:37\nTRACK 02 AUDIO\nTITLE \"Second\"\nINDEX 00 03:00:00\n";
let sheet = parse(&mut Cursor::new(input.as_bytes()), &|| Ok(())).unwrap();
assert_eq!(sheet.performer, "Album Artist");
assert_eq!(sheet.composer, "Album Writer");
assert_eq!(sheet.file_name, "album.flac");
assert_eq!(sheet.tracks.len(), 2);
assert_eq!(sheet.tracks[0].composer, "Track Writer");
assert!((sheet.tracks[0].start_time - (62.0 + 37.0 / 75.0)).abs() < f64::EPSILON);
assert_eq!(sheet.tracks[0].pre_gap, -1.0);
assert_eq!(sheet.tracks[1].pre_gap, 180.0);
let mut malformed = Cursor::new(b"TITLE \"no tracks\"\n".to_vec());
assert_eq!(
parse(&mut malformed, &|| Ok(())).unwrap_err(),
"no tracks found in cue file"
);
let mut oversized = Cursor::new(vec![b'x'; MAX_SCANNER_BUFFER]);
assert!(
parse(&mut oversized, &|| Ok(()))
.unwrap_err()
.contains("token too long")
);
let checks = Cell::new(0);
let mut cancelled = Cursor::new(b"TRACK 01 AUDIO\n".to_vec());
let error = parse(&mut cancelled, &|| {
checks.set(checks.get() + 1);
if checks.get() > 1 {
Err("cancelled".into())
} else {
Ok(())
}
})
.unwrap_err();
assert_eq!(error, "cancelled");
}
}
+155
View File
@@ -0,0 +1,155 @@
//! The native download domain owns cancellation and progress together.
use crate::cancellation::{
CancellationDomain, CancellationError, CancellationRegistry, RequestLease,
};
use crate::progress::ProgressRegistry;
use std::sync::Arc;
/// Application download input, shared by the strategy and extension facades.
/// Keep Go's null/duplicate/case-folded JSON handling in the existing decoder.
#[derive(Clone, Default, serde::Serialize)]
pub struct DownloadRequest {
pub contract_version: i64,
pub isrc: String,
pub service: String,
pub download_provider: String,
pub provider_track_id: String,
pub spotify_id: String,
pub track_name: String,
pub artist_name: String,
pub album_name: String,
pub album_artist: String,
pub cover_url: String,
pub cover_max_dimension: i64,
pub output_dir: String,
pub album_folder_template: String,
pub output_path: String,
pub output_fd: i64,
pub output_ext: String,
pub filename_format: String,
pub quality: String,
pub embed_metadata: bool,
pub artist_tag_mode: String,
pub embed_lyrics: bool,
pub embed_replaygain: bool,
pub post_processing_enabled: bool,
pub track_number: i64,
pub playlist_position: i64,
pub disc_number: i64,
pub total_tracks: i64,
pub total_discs: i64,
pub release_date: String,
pub item_id: String,
pub duration_ms: i64,
pub source: String,
pub genre: String,
pub label: String,
pub copyright: String,
pub composer: String,
pub comment: String,
pub explicit: bool,
pub album_type: String,
pub upc: String,
pub tidal_id: String,
pub qobuz_id: String,
pub deezer_id: String,
pub lyrics_mode: String,
pub use_extensions: bool,
pub use_fallback: bool,
pub requires_container_conversion: bool,
pub allow_quality_variant: bool,
pub quality_variant: String,
pub songlink_region: String,
pub network_concurrency_limit: i64,
}
crate::lyrics::json::go_deserialize!(DownloadRequest {
"contract_version" => contract_version, "isrc" => isrc, "service" => service,
"download_provider" => download_provider, "provider_track_id" => provider_track_id,
"spotify_id" => spotify_id, "track_name" => track_name, "artist_name" => artist_name,
"album_name" => album_name, "album_artist" => album_artist, "cover_url" => cover_url,
"cover_max_dimension" => cover_max_dimension, "output_dir" => output_dir,
"album_folder_template" => album_folder_template, "output_path" => output_path,
"output_fd" => output_fd, "output_ext" => output_ext, "filename_format" => filename_format,
"quality" => quality, "embed_metadata" => embed_metadata, "artist_tag_mode" => artist_tag_mode,
"embed_lyrics" => embed_lyrics, "embed_replaygain" => embed_replaygain,
"post_processing_enabled" => post_processing_enabled, "track_number" => track_number,
"playlist_position" => playlist_position, "disc_number" => disc_number,
"total_tracks" => total_tracks, "total_discs" => total_discs, "release_date" => release_date,
"item_id" => item_id, "duration_ms" => duration_ms, "source" => source, "genre" => genre,
"label" => label, "copyright" => copyright, "composer" => composer, "comment" => comment,
"explicit" => explicit, "album_type" => album_type, "upc" => upc, "tidal_id" => tidal_id,
"qobuz_id" => qobuz_id, "deezer_id" => deezer_id, "lyrics_mode" => lyrics_mode,
"use_extensions" => use_extensions, "use_fallback" => use_fallback,
"requires_container_conversion" => requires_container_conversion,
"allow_quality_variant" => allow_quality_variant, "quality_variant" => quality_variant,
"songlink_region" => songlink_region, "network_concurrency_limit" => network_concurrency_limit,
});
impl DownloadRequest {
pub fn parse(raw: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(&crate::text::json_surrogates(raw))
}
pub fn normalize(&mut self) {
for value in [
&mut self.track_name,
&mut self.artist_name,
&mut self.album_name,
&mut self.album_artist,
&mut self.output_dir,
&mut self.output_path,
&mut self.output_ext,
] {
*value = value.trim().to_owned();
}
}
}
pub struct DownloadState {
pub progress: Arc<ProgressRegistry>,
pub cancellation: Arc<CancellationRegistry>,
}
impl Default for DownloadState {
fn default() -> Self {
Self {
progress: Arc::new(ProgressRegistry::new()),
cancellation: Arc::new(CancellationRegistry::new(CancellationDomain::Download)),
}
}
}
impl DownloadState {
pub fn acquire(&self, id: &str) -> Result<RequestLease, CancellationError> {
self.cancellation.acquire(id)
}
pub fn cancel(&self, id: &str) -> Result<(), CancellationError> {
if !id.is_empty() {
self.cancellation.cancel(id)?;
let _ = self.progress.remove(id);
}
Ok(())
}
pub fn cancel_active(&self) -> Result<Vec<String>, CancellationError> {
let ids = self.cancellation.cancel_active()?;
for id in &ids {
let _ = self.progress.remove(id);
}
Ok(ids)
}
pub fn shutdown(&self) {
self.cancellation.shutdown();
self.progress.shutdown();
}
}
impl Drop for DownloadState {
fn drop(&mut self) {
self.shutdown();
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Filename rules shared with the existing Go backend.
mod api;
mod date;
mod template;
pub use api::build_filename_json;
pub use template::{build_filename, build_filename_checked};
pub const MAX_SANITIZED_FILENAME_BYTES: usize = 200;
/// Normalize a UTF-8 filename with the existing `SanitizeFilename` contract.
///
/// The limit applies to UTF-8 bytes, not characters. This does not normalize
/// Unicode, remove format characters, or preserve an extension separately.
pub fn sanitize_filename(filename: &str) -> String {
let mut filtered = String::with_capacity(filename.len());
for character in filename.chars() {
if matches!(
character,
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
) || character <= '\u{1f}'
{
filtered.push(' ');
} else if !character.is_control() {
filtered.push(character);
}
}
let trimmed = filtered.trim().trim_matches(['.', ' ']);
let mut normalized = String::with_capacity(trimmed.len());
for word in trimmed.split_whitespace() {
if !normalized.is_empty() {
normalized.push(' ');
}
for character in word.chars() {
if character != '_' || !normalized.ends_with('_') {
normalized.push(character);
}
}
}
let mut sanitized = normalized.trim_matches(['_', ' ']);
if sanitized.len() > MAX_SANITIZED_FILENAME_BYTES {
let mut end = MAX_SANITIZED_FILENAME_BYTES;
while !sanitized.is_char_boundary(end) {
end -= 1;
}
sanitized = sanitized[..end]
.trim_matches(['.', ' '])
.trim()
.trim_matches(['_', ' ']);
}
if sanitized.is_empty() {
"Unknown".to_owned()
} else {
sanitized.to_owned()
}
}
/// Sanitize a filename while preserving a token that was present in the raw
/// filename, such as a quality variant marker.
pub fn sanitize_filename_preserving_token(filename: &str, token: &str) -> String {
let sanitized = sanitize_filename(filename);
let token = token.trim();
if token.is_empty() || !filename.contains(token) || sanitized.contains(token) {
return sanitized;
}
let safe_token = sanitize_filename(token);
let suffix = format!(" - {safe_token}");
let prefix_limit = MAX_SANITIZED_FILENAME_BYTES.saturating_sub(suffix.len());
if prefix_limit == 0 {
return truncate_utf8_bytes(&safe_token, MAX_SANITIZED_FILENAME_BYTES).to_owned();
}
let raw_prefix = filename.replace(token, "");
let raw_prefix = raw_prefix.trim_matches([' ', '_', '-']);
let prefix = sanitize_filename(raw_prefix);
let prefix = truncate_utf8_bytes(&prefix, prefix_limit)
.trim_matches(['.', ' ', '_', '-'])
.trim();
if prefix.is_empty() || prefix == "Unknown" {
return safe_token;
}
format!("{prefix}{suffix}")
}
fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> &str {
if value.len() <= max_bytes {
return value;
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end -= 1;
}
&value[..end]
}
@@ -0,0 +1,23 @@
use serde_json::{Map, Number, Value};
const MAX_INPUT_BYTES: usize = 8 * 1024 * 1024;
/// Native BuildFilename accepts a JSON object (or null). Go's JSON decoder
/// exports every number as float64, even when the token looks like an integer.
pub fn build_filename_json(template: &str, metadata_json: &str) -> Result<String, String> {
if template.len() > MAX_INPUT_BYTES || metadata_json.len() > MAX_INPUT_BYTES {
return Err("filename input exceeds 8 MiB limit".into());
}
let mut metadata = serde_json::from_str::<Option<Map<String, Value>>>(
&crate::text::json_surrogates(metadata_json),
)
.map_err(|error| error.to_string())?
.unwrap_or_default();
for value in metadata.values_mut() {
if let Value::Number(number) = value {
*number = Number::from_f64(number.as_f64().ok_or("invalid metadata number")?)
.ok_or("invalid metadata number")?;
}
}
super::build_filename_checked(template, &metadata, MAX_INPUT_BYTES, &|| Ok(()))
}
@@ -0,0 +1,334 @@
use chrono::{Datelike, NaiveDate, NaiveDateTime, Timelike};
use regex::Regex;
use std::sync::LazyLock;
static YEAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[0-9]{4}").unwrap());
static SIMPLE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^([0-9]{4})(?:([-/.])([0-9]{2})(?:([-/.])([0-9]{2}))?)?$").unwrap()
});
static RFC3339: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{1,2}):([0-9]{2}):([0-9]{2})(?:[.,]([0-9]+))?(Z|([-+])([0-9]{2}):([0-9]{2}))$").unwrap()
});
struct Date {
value: NaiveDateTime,
offset: i32,
zone: String,
}
pub(super) fn format(
raw: &str,
pattern: &str,
limit: usize,
check: &dyn Fn() -> Result<(), String>,
) -> Result<String, String> {
let Some(date) = parse(raw) else {
return Ok(String::new());
};
let mut layout = String::new();
let mut chars = pattern.chars();
let mut next_check = 0;
while let Some(character) = chars.next() {
if layout.len() >= next_check {
check()?;
next_check = layout.len().saturating_add(1024);
}
if layout.len() > limit {
return Err("filename result exceeds output limit".into());
}
if character != '%' {
layout.push(character);
continue;
}
match chars.next() {
Some('Y') => layout.push_str("2006"),
Some('y') => layout.push_str("06"),
Some('m') => layout.push_str("01"),
Some('d') => layout.push_str("02"),
Some('b') => layout.push_str("Jan"),
Some('B') => layout.push_str("January"),
Some('%') => layout.push('%'),
Some(other) => {
layout.push('%');
layout.push(other);
}
None => layout.push('%'),
}
}
render(&date, &layout, limit, check)
}
fn parse(raw: &str) -> Option<Date> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if let Some(value) = rfc3339(raw) {
return Some(value);
}
if let Some(value) = simple(raw) {
return Some(value);
}
if let Some(prefix) = raw.get(..10)
&& prefix.as_bytes().get(4) == Some(&b'-')
&& let Some(value) = simple(prefix)
{
return Some(value);
}
let year = YEAR.find(raw)?.as_str().parse::<i32>().ok()?;
if year <= 0 {
return None;
}
utc(year, 1, 1)
}
fn rfc3339(raw: &str) -> Option<Date> {
let captures = RFC3339.captures(raw)?;
let number = |index| captures.get(index)?.as_str().parse::<u32>().ok();
let fraction = captures.get(7).map_or("", |value| value.as_str());
let mut nanos = 0;
for index in 0..9 {
nanos = nanos * 10
+ fraction
.as_bytes()
.get(index)
.map_or(0, |byte| u32::from(*byte - b'0'));
}
let value = NaiveDate::from_ymd_opt(number(1)? as i32, number(2)?, number(3)?)?
.and_hms_nano_opt(number(4)?, number(5)?, number(6)?, nanos)?;
if &captures[8] == "Z" {
return Some(Date {
value,
offset: 0,
zone: "UTC".into(),
});
}
let (hours, minutes) = (number(10)?, number(11)?);
// Go's non-strict RFC3339 path accepts inclusive 24-hour/60-minute offsets.
if hours > 24 || minutes > 60 {
return None;
}
let offset = (hours * 3600 + minutes * 60) as i32 * if &captures[9] == "-" { -1 } else { 1 };
let (local_offset, name, _) =
crate::clock::zone_at(value.and_utc().timestamp() - i64::from(offset));
Some(Date {
value,
offset,
zone: if offset == local_offset {
name
} else {
String::new()
},
})
}
fn simple(raw: &str) -> Option<Date> {
let captures = SIMPLE.captures(raw)?;
if captures.get(4).is_some() && captures.get(2)?.as_str() != captures.get(4)?.as_str() {
return None;
}
utc(
captures[1].parse().ok()?,
captures
.get(3)
.map_or(Some(1), |value| value.as_str().parse().ok())?,
captures
.get(5)
.map_or(Some(1), |value| value.as_str().parse().ok())?,
)
}
fn utc(year: i32, month: u32, day: u32) -> Option<Date> {
Some(Date {
value: NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(0, 0, 0)?,
offset: 0,
zone: "UTC".into(),
})
}
fn render(
date: &Date,
mut layout: &str,
limit: usize,
check: &dyn Fn() -> Result<(), String>,
) -> Result<String, String> {
let value = date.value;
let mut output = String::new();
while !layout.is_empty() {
check()?;
if output.len() > limit {
return Err("filename result exceeds output limit".into());
}
let token = token(layout);
let Some(token) = token else {
let character = layout.chars().next().unwrap();
output.push(character);
layout = &layout[character.len_utf8()..];
continue;
};
let text = match token {
"January" => MONTHS[value.month0() as usize].into(),
"Jan" => MONTHS[value.month0() as usize][..3].into(),
"Monday" => DAYS[value.weekday().num_days_from_sunday() as usize].into(),
"Mon" => DAYS[value.weekday().num_days_from_sunday() as usize][..3].into(),
"2006" => format!("{:04}", value.year()),
"06" => format!("{:02}", value.year().abs() % 100),
"1" => value.month().to_string(),
"01" => format!("{:02}", value.month()),
"2" => value.day().to_string(),
"02" => format!("{:02}", value.day()),
"_2" => format!("{:2}", value.day()),
"002" => format!("{:03}", value.ordinal()),
"__2" => format!("{:3}", value.ordinal()),
"15" => format!("{:02}", value.hour()),
"3" => value.hour12().1.to_string(),
"03" => format!("{:02}", value.hour12().1),
"4" => value.minute().to_string(),
"04" => format!("{:02}", value.minute()),
"5" => value.second().to_string(),
"05" => format!("{:02}", value.second()),
"PM" => if value.hour() >= 12 { "PM" } else { "AM" }.into(),
"pm" => if value.hour() >= 12 { "pm" } else { "am" }.into(),
"MST" => {
if date.zone.is_empty() {
timezone(date.offset, "-0700")
} else {
date.zone.clone()
}
}
other if other.starts_with(['.', ',']) => {
let length = (other.len() - 1).min(9);
let digits = format!("{:09}", value.nanosecond());
let digits = &digits[..length];
let digits = if other.as_bytes()[1] == b'9' {
digits.trim_end_matches('0')
} else {
digits
};
if digits.is_empty() {
String::new()
} else {
format!("{}{digits}", &other[..1])
}
}
other => timezone(date.offset, other),
};
output.push_str(&text);
layout = &layout[token.len()..];
}
if output.len() > limit {
return Err("filename result exceeds output limit".into());
}
Ok(output)
}
fn token(layout: &str) -> Option<&str> {
if layout.starts_with("_2006") {
return None;
}
for token in [
"January",
"Monday",
"Jan",
"Mon",
"MST",
"002",
"01",
"02",
"03",
"04",
"05",
"06",
"15",
"1",
"2006",
"2",
"_2",
"__2",
"3",
"4",
"5",
"PM",
"pm",
"-070000",
"-07:00:00",
"-0700",
"-07:00",
"-07",
"Z070000",
"Z07:00:00",
"Z0700",
"Z07:00",
"Z07",
] {
if layout.starts_with(token)
&& !(matches!(token, "Jan" | "Mon")
&& layout
.as_bytes()
.get(token.len())
.is_some_and(u8::is_ascii_lowercase))
{
return Some(&layout[..token.len()]);
}
}
let bytes = layout.as_bytes();
if matches!(bytes[0], b'.' | b',')
&& bytes
.get(1)
.is_some_and(|value| matches!(value, b'0' | b'9'))
{
let length = 1 + bytes[1..]
.iter()
.take_while(|value| **value == bytes[1])
.count();
if !bytes.get(length).is_some_and(u8::is_ascii_digit) {
return Some(&layout[..length]);
}
}
None
}
fn timezone(offset: i32, token: &str) -> String {
if offset == 0 && token.starts_with('Z') {
return "Z".into();
}
let minutes = offset.abs() / 60;
let mut result = format!("{}{:02}", if offset < 0 { '-' } else { '+' }, minutes / 60);
if token.len() > 3 {
if token.contains(':') {
result.push(':');
}
result.push_str(&format!("{:02}", minutes % 60));
}
if token.len() >= 7 {
if token.contains(':') {
result.push(':');
}
result.push_str(&format!("{:02}", offset.abs() % 60));
}
result
}
const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const DAYS: [&str; 7] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
@@ -0,0 +1,208 @@
use super::date;
use regex::{Captures, Regex};
use serde_json::{Map, Value};
use std::sync::LazyLock;
static NUMBERS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}").unwrap()
});
static DATES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{date:([^{}]+)\}").unwrap());
static EMPTY_GROUP: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[[\t\n\f\r ]*\]|\([\t\n\f\r ]*\)").unwrap());
static DANGLING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[\t\n\f\r ]*[-_|][\t\n\f\r ]*([\]\)])").unwrap());
static REPEATED: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[\t\n\f\r ]*[-–—_|][\t\n\f\r ]*(?:[-–—_|][\t\n\f\r ]*)+").unwrap()
});
/// Expand the legacy filename template without sanitizing its result. Path
/// publication applies sanitization separately, as in the existing backend.
pub fn build_filename(template: &str, metadata: &Map<String, Value>) -> String {
build_filename_checked(template, metadata, usize::MAX, &|| Ok(()))
.expect("unlimited filename expansion")
}
pub fn build_filename_checked(
template: &str,
metadata: &Map<String, Value>,
limit: usize,
check: &dyn Fn() -> Result<(), String>,
) -> Result<String, String> {
let budget = Budget { limit, check };
check()?;
let template = if template.is_empty() {
"{artist} - {title}"
} else {
template
};
let numbers = budget.replace(template, &NUMBERS, |capture: &Captures<'_>| {
let key = match &capture[1] {
"playlistPosition" | "position" => "playlist_position",
key => key,
};
Ok(capture[2]
.parse::<isize>()
.map(|width| padded(integer(metadata, key), width))
.unwrap_or_default())
})?;
let date = ["date", "release_date", "year"]
.into_iter()
.map(|key| string(metadata, key))
.find(|value| !value.is_empty())
.unwrap_or_default();
let mut result = budget.replace(&numbers, &DATES, |capture| {
date::format(&date, &capture[1], limit, check)
})?;
let mut year = string(metadata, "year");
if year.is_empty() {
year = crate::text::utf8(&date.as_bytes()[..date.len().min(4)]);
}
let track = integer(metadata, "track");
let disc = integer(metadata, "disc");
let position = integer(metadata, "playlist_position");
let mut placeholders = vec![
("title", string(metadata, "title")),
("artist", string(metadata, "artist")),
("album", string(metadata, "album")),
("track", padded(track, 2)),
("track_raw", padded(track, 1)),
("disc", padded(disc, 1)),
("disc_raw", padded(disc, 1)),
("playlist_position", padded(position, 2)),
("playlist position", padded(position, 2)),
("playlistPosition", padded(position, 2)),
("position", padded(position, 2)),
("playlist_position_raw", padded(position, 1)),
("year", year),
("date", date),
("quality", string(metadata, "quality")),
("quality_variant", string(metadata, "quality_variant")),
("isrc", string(metadata, "isrc")),
("provider", string(metadata, "provider")),
("platform", string(metadata, "provider")),
("provider_id", string(metadata, "provider_id")),
("id", string(metadata, "provider_id")),
];
// Go's map iteration permits several outcomes when a metadata value itself
// contains another placeholder. Use a stable order from that allowed set.
placeholders.sort_unstable_by_key(|(key, _)| *key);
let mut cleanup = false;
for (key, value) in placeholders {
let placeholder = format!("{{{key}}}");
if value.is_empty()
&& matches!(key, "isrc" | "provider" | "platform" | "provider_id" | "id")
&& result.contains(&placeholder)
{
cleanup = true;
}
result = budget.literal(&result, &placeholder, &value)?;
}
if cleanup {
let groups = budget.replace(&result, &EMPTY_GROUP, |_| Ok(String::new()))?;
let dangling = budget.replace(&groups, &DANGLING, |capture| Ok(capture[1].into()))?;
let repeated = budget.replace(&dangling, &REPEATED, |_| Ok(" - ".into()))?;
result = repeated
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim_matches([' ', '-', '–', '—', '_', '|'])
.into();
}
check()?;
Ok(result)
}
struct Budget<'a> {
limit: usize,
check: &'a dyn Fn() -> Result<(), String>,
}
impl Budget<'_> {
fn push(&self, output: &mut String, value: &str) -> Result<(), String> {
(self.check)()?;
if value.len() > self.limit.saturating_sub(output.len()) {
return Err("filename result exceeds output limit".into());
}
output.push_str(value);
Ok(())
}
fn replace(
&self,
input: &str,
pattern: &Regex,
replacement: impl Fn(&Captures<'_>) -> Result<String, String>,
) -> Result<String, String> {
let mut output = String::new();
let mut offset = 0;
for capture in pattern.captures_iter(input) {
let matched = capture.get(0).unwrap();
self.push(&mut output, &input[offset..matched.start()])?;
self.push(&mut output, &replacement(&capture)?)?;
offset = matched.end();
}
self.push(&mut output, &input[offset..])?;
Ok(output)
}
fn literal(&self, input: &str, pattern: &str, value: &str) -> Result<String, String> {
let mut output = String::new();
let mut offset = 0;
for (start, _) in input.match_indices(pattern) {
self.push(&mut output, &input[offset..start])?;
self.push(&mut output, value)?;
offset = start + pattern.len();
}
self.push(&mut output, &input[offset..])?;
Ok(output)
}
}
fn string(metadata: &Map<String, Value>, key: &str) -> String {
match metadata.get(key) {
Some(Value::String(value)) => value.trim().into(),
Some(Value::Number(value)) => value
.as_i64()
.map(|value| value.to_string())
.unwrap_or_else(|| (value.as_f64().unwrap_or(0.0) as isize).to_string()),
_ => String::new(),
}
}
fn integer(metadata: &Map<String, Value>, key: &str) -> isize {
let aliases: &[&str] = match key {
"track" => &["track_number"],
"disc" => &["disc_number"],
"playlist_position" => &["playlistPosition", "playlist position", "position"],
_ => &[],
};
std::iter::once(key)
.chain(aliases.iter().copied())
.find_map(|key| match metadata.get(key) {
Some(Value::String(value)) => value.trim().parse::<isize>().ok(),
Some(Value::Number(value)) => Some(
value
.as_i64()
.map(|value| value as isize)
.unwrap_or_else(|| value.as_f64().unwrap_or(0.0) as isize),
),
_ => None,
})
.unwrap_or(0)
}
fn padded(number: isize, width: isize) -> String {
if number <= 0 || width <= 0 {
return String::new();
}
// Go's fmt accepts widths through one million; Rust's formatter has a lower limit.
if width > 1_000_000 {
return format!("%!(BADWIDTH){number}");
}
let digits = number.to_string();
let width = width as usize;
let mut output = String::with_capacity(width.max(digits.len()));
output.extend(std::iter::repeat_n('0', width.saturating_sub(digits.len())));
output.push_str(&digits);
output
}
+385
View File
@@ -0,0 +1,385 @@
use crate::matching::uppercase;
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, TryLockError, Weak};
use std::time::{Duration, Instant};
type Check<'a> = &'a (dyn Fn() -> Result<(), String> + Sync);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileStamp {
pub path: String,
pub size: u64,
pub modified_ns: i128,
pub directory: bool,
}
/// The native owner supplies filesystem access. A cache hit never grants access
/// to a path: adapters must validate directory authority before each operation.
pub trait IndexFiles: Sync {
/// Return entries in lexical depth-first walk order, preserving directory
/// boundaries rather than sorting the complete path strings afterward.
fn list(&self, directory: &str, check: Check<'_>) -> Result<Vec<FileStamp>, String>;
fn read(&self, path: &str, check: Check<'_>) -> Result<String, String>;
fn stat(&self, path: &str) -> Result<Option<FileStamp>, String>;
}
#[derive(Clone)]
struct FileEntry {
stamp: FileStamp,
isrc: String,
}
struct Index {
entries: BTreeMap<String, String>,
files: BTreeMap<String, FileEntry>,
built_at: Instant,
}
pub struct IndexCache {
indexes: Mutex<BTreeMap<String, Arc<Mutex<Index>>>>,
builders: Mutex<BTreeMap<String, Weak<Mutex<()>>>>,
ttl: Duration,
}
impl Default for IndexCache {
fn default() -> Self {
Self::with_ttl(Duration::from_secs(5 * 60))
}
}
impl IndexCache {
pub fn with_ttl(ttl: Duration) -> Self {
Self {
indexes: Mutex::default(),
builders: Mutex::default(),
ttl,
}
}
fn cached(&self, directory: &str) -> Option<Arc<Mutex<Index>>> {
self.indexes
.lock()
.expect("ISRC cache lock")
.get(directory)
.cloned()
}
fn fresh(&self, directory: &str) -> Option<Arc<Mutex<Index>>> {
self.cached(directory)
.filter(|index| index.lock().expect("ISRC index lock").built_at.elapsed() < self.ttl)
}
fn index(
&self,
directory: &str,
files: &dyn IndexFiles,
force: bool,
check: Check<'_>,
) -> Result<Arc<Mutex<Index>>, String> {
check()?;
if !force && let Some(index) = self.fresh(directory) {
return Ok(index);
}
let builder = {
let mut builders = self.builders.lock().expect("ISRC builders lock");
let entry = builders.entry(directory.into()).or_default();
entry.upgrade().unwrap_or_else(|| {
let lock = Arc::new(Mutex::new(()));
*entry = Arc::downgrade(&lock);
lock
})
};
let _building = wait_for_builder(&builder, check)?;
if !force && let Some(index) = self.fresh(directory) {
return Ok(index);
}
let previous = self
.cached(directory)
.map(|index| index.lock().expect("ISRC index lock").files.clone())
.unwrap_or_default();
let mut index = Index {
entries: BTreeMap::new(),
files: BTreeMap::new(),
built_at: Instant::now(),
};
let mut changed = Vec::new();
if !directory.is_empty() {
let listed = files.list(directory, check)?;
for stamp in listed {
check()?;
if stamp.directory || !super::supported(&stamp.path) {
continue;
}
if let Some(entry) = previous.get(&stamp.path)
&& entry.stamp == stamp
{
if !entry.isrc.is_empty() {
index.entries.insert(entry.isrc.clone(), stamp.path.clone());
}
index.files.insert(stamp.path.clone(), entry.clone());
} else {
changed.push(stamp);
}
}
}
let next = AtomicUsize::new(0);
let values = Mutex::new(vec![String::new(); changed.len()]);
std::thread::scope(|scope| {
let handles = (0..changed.len().min(4))
.map(|_| {
scope.spawn(|| {
loop {
check()?;
let position = next.fetch_add(1, Ordering::Relaxed);
let Some(stamp) = changed.get(position) else {
break;
};
let isrc = uppercase(&files.read(&stamp.path, check)?);
values.lock().expect("ISRC parse results lock")[position] = isrc;
}
Ok::<_, String>(())
})
})
.collect::<Vec<_>>();
for handle in handles {
handle
.join()
.map_err(|_| "ISRC reader worker panicked".to_owned())??;
}
Ok::<_, String>(())
})?;
// Changed files are applied after reused entries, in walk order, just
// as Go does after joining its four parsing workers.
for (stamp, isrc) in changed
.into_iter()
.zip(values.into_inner().expect("ISRC parse results lock"))
{
check()?;
if !isrc.is_empty() {
index.entries.insert(isrc.clone(), stamp.path.clone());
}
index
.files
.insert(stamp.path.clone(), FileEntry { stamp, isrc });
}
check()?;
let index = Arc::new(Mutex::new(index));
if !directory.is_empty() {
self.indexes
.lock()
.expect("ISRC cache lock")
.insert(directory.into(), Arc::clone(&index));
}
Ok(index)
}
pub fn check(
&self,
directory: &str,
isrc: &str,
files: &dyn IndexFiles,
check: Check<'_>,
) -> Result<String, String> {
check()?;
if directory.is_empty() || isrc.is_empty() {
return Ok(String::new());
}
let index = self.index(directory, files, false, check)?;
let key = uppercase(isrc);
let path = index
.lock()
.expect("ISRC index lock")
.entries
.get(&key)
.cloned();
let Some(path) = path else {
return Ok(String::new());
};
check()?;
let exists = files
.stat(&path)?
.is_some_and(|stamp| !stamp.directory && stamp.size > 0);
check()?;
if exists {
Ok(path)
} else {
index.lock().expect("ISRC index lock").entries.remove(&key);
Ok(String::new())
}
}
pub fn add(
&self,
directory: &str,
isrc: &str,
path: &str,
files: &dyn IndexFiles,
check: Check<'_>,
) -> Result<(), String> {
check()?;
if directory.is_empty() || isrc.is_empty() || path.is_empty() {
return Ok(());
}
if let Some(index) = self.cached(directory) {
let stamp = files.stat(path)?;
check()?;
let mut index = index.lock().expect("ISRC index lock");
let isrc = uppercase(isrc);
index.entries.insert(isrc.clone(), path.into());
if let Some(stamp) = stamp {
index.files.insert(path.into(), FileEntry { stamp, isrc });
}
index.built_at = Instant::now();
}
Ok(())
}
pub fn invalidate(&self, directory: &str) {
self.indexes
.lock()
.expect("ISRC cache lock")
.remove(directory);
}
pub fn clear(&self) {
self.indexes.lock().expect("ISRC cache lock").clear();
}
pub fn prebuild(
&self,
directory: &str,
files: &dyn IndexFiles,
check: Check<'_>,
) -> Result<(), String> {
if directory.is_empty() {
return Err("output directory is required".into());
}
self.index(directory, files, true, check).map(|_| ())
}
pub fn check_batch(
&self,
directory: &str,
tracks: &[TrackQuery],
files: &dyn IndexFiles,
check: Check<'_>,
) -> Result<Vec<TrackExistence>, String> {
let index = self.index(directory, files, false, check)?;
let index = index.lock().expect("ISRC index lock");
tracks
.iter()
.map(|track| {
check()?;
// Go's batch API deliberately trusts the cache without stat calls.
let path = index
.entries
.get(&uppercase(&track.isrc))
.filter(|_| !track.isrc.is_empty());
Ok(TrackExistence {
isrc: track.isrc.clone(),
exists: path.is_some(),
file_path: path.cloned().unwrap_or_default(),
track_name: track.track_name.clone(),
artist_name: track.artist_name.clone(),
})
})
.collect()
}
}
fn wait_for_builder<'a>(
builder: &'a Mutex<()>,
check: Check<'_>,
) -> Result<MutexGuard<'a, ()>, String> {
loop {
check()?;
match builder.try_lock() {
Ok(guard) => return Ok(guard),
Err(TryLockError::Poisoned(error)) => return Ok(error.into_inner()),
Err(TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(5)),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct TrackQuery {
pub isrc: String,
pub track_name: String,
pub artist_name: String,
}
impl<'de> Deserialize<'de> for TrackQuery {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = TrackQuery;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a track object")
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> Result<TrackQuery, M::Error> {
let mut track = TrackQuery::default();
while let Some(key) = map.next_key::<String>()? {
let field = if equal_field(&key, "isrc") {
Some(&mut track.isrc)
} else if equal_field(&key, "track_name") {
Some(&mut track.track_name)
} else if equal_field(&key, "artist_name") {
Some(&mut track.artist_name)
} else {
None
};
if let Some(field) = field {
if let Some(value) = map.next_value::<Option<String>>()? {
*field = value;
}
} else {
map.next_value::<serde::de::IgnoredAny>()?;
}
}
Ok(track)
}
}
deserializer.deserialize_map(Visitor)
}
}
pub fn parse_tracks(json: &str) -> Result<Vec<TrackQuery>, String> {
// Go matches the snake_case JSON tags, folds field-name case, and ignores
// null string values without overwriting an earlier duplicate field.
serde_json::from_str::<Option<Vec<Option<TrackQuery>>>>(&crate::text::json_surrogates(json))
.map(|tracks| {
tracks
.unwrap_or_default()
.into_iter()
.map(Option::unwrap_or_default)
.collect()
})
.map_err(|error| format!("failed to parse tracks JSON: {error}"))
}
fn equal_field(value: &str, target: &str) -> bool {
value.eq_ignore_ascii_case(target)
|| (value.chars().count() == target.len()
&& value.chars().zip(target.chars()).all(|(actual, expected)| {
actual.eq_ignore_ascii_case(&expected)
|| (actual == 'ſ' && expected == 's')
|| (actual == 'K' && expected == 'k')
}))
}
#[derive(Debug, Serialize)]
pub struct TrackExistence {
pub isrc: String,
pub exists: bool,
#[serde(skip_serializing_if = "String::is_empty")]
pub file_path: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub track_name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub artist_name: String,
}
+117
View File
@@ -0,0 +1,117 @@
//! Duplicate detection retains Go's cache and format-specific ISRC contracts.
mod cache;
mod native_files;
pub use cache::{FileStamp, IndexCache, IndexFiles, TrackExistence, TrackQuery, parse_tracks};
pub use native_files::NativeFiles;
use std::io::{Read, Seek, SeekFrom};
pub fn supported(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
matches!(
extension.to_ascii_lowercase().as_str(),
"flac" | "mp3" | "m4a" | "ogg" | "opus"
)
})
}
pub fn read_isrc(
reader: &mut (impl Read + Seek),
format: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<String, String> {
let interrupted = std::cell::RefCell::new(None);
let checked = || {
let result = check();
if let Err(error) = &result {
*interrupted.borrow_mut() = Some(error.clone());
}
result
};
checked()?;
let value = if format == "flac" {
read_flac(reader, &checked).unwrap_or_default()
} else if matches!(format, "mp3" | "m4a" | "ogg" | "opus") {
crate::tags::read_audio_tags(reader, format, &checked)
.map(|metadata| metadata.isrc.trim().to_owned())
.unwrap_or_default()
} else {
String::new()
};
if let Some(error) = interrupted.into_inner() {
return Err(error);
}
check()?;
Ok(value)
}
fn read_flac(
reader: &mut (impl Read + Seek),
check: &dyn Fn() -> Result<(), String>,
) -> Result<String, String> {
reader.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?;
let mut header = [0; 4];
reader.read_exact(&mut header).map_err(|e| e.to_string())?;
if &header != b"fLaC" {
return Ok(String::new());
}
loop {
check()?;
reader.read_exact(&mut header).map_err(|e| e.to_string())?;
let length = u32::from_be_bytes([0, header[1], header[2], header[3]]);
if header[0] & 0x7f == 4 {
let mut payload = vec![0; length as usize];
for chunk in payload.chunks_mut(64 * 1024) {
check()?;
reader.read_exact(chunk).map_err(|e| e.to_string())?;
}
return Ok(comment_isrc(&payload, check));
}
if header[0] & 0x80 != 0 {
return Ok(String::new());
}
reader
.seek(SeekFrom::Current(i64::from(length)))
.map_err(|e| e.to_string())?;
}
}
fn comment_isrc(mut payload: &[u8], check: &dyn Fn() -> Result<(), String>) -> String {
fn integer(payload: &mut &[u8]) -> Option<usize> {
let (value, rest) = payload.split_at_checked(4)?;
*payload = rest;
Some(u32::from_le_bytes(value.try_into().ok()?) as usize)
}
let mut parse = || {
let vendor = integer(&mut payload)?;
payload = payload.get(vendor..)?;
let count = integer(&mut payload)?;
for _ in 0..count {
check().ok()?;
let length = integer(&mut payload)?;
let (comment, rest) = payload.split_at_checked(length)?;
payload = rest;
let Some(equal) = comment.iter().position(|byte| *byte == b'=') else {
continue;
};
let key = std::str::from_utf8(&comment[..equal]).ok();
if key.is_some_and(isrc_key) {
return Some(crate::text::utf8(&comment[equal + 1..]).trim().to_owned());
}
}
None
};
parse().unwrap_or_default()
}
fn isrc_key(key: &str) -> bool {
// EqualFold accepts long s but not dotless i; uppercasing alone differs.
key.chars().count() == 4
&& key.chars().zip("ISRC".chars()).all(|(actual, expected)| {
actual.eq_ignore_ascii_case(&expected) || (actual == 'ſ' && expected == 'S')
})
}
@@ -0,0 +1,110 @@
use super::{FileStamp, IndexFiles};
use std::fs::{self, OpenOptions};
use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
/// Ambient filesystem access for the trusted native application. JavaScript
/// hosts must use their scoped adapter instead.
pub struct NativeFiles;
impl IndexFiles for NativeFiles {
fn list(
&self,
directory: &str,
check: &(dyn Fn() -> Result<(), String> + Sync),
) -> Result<Vec<FileStamp>, String> {
let mut pending = vec![PathBuf::from(directory)];
let mut result = Vec::new();
while let Some(path) = pending.pop() {
check()?;
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if !metadata.is_dir() {
result.push(stamp(&path, &metadata));
continue;
}
let Ok(entries) = fs::read_dir(&path) else {
continue;
};
let mut names = Vec::new();
for entry in entries {
check()?;
if let Ok(entry) = entry {
names.push(entry.file_name());
}
}
names.sort();
pending.extend(names.into_iter().rev().map(|name| clean(&path.join(name))));
}
check()?;
Ok(result)
}
fn read(
&self,
path: &str,
check: &(dyn Fn() -> Result<(), String> + Sync),
) -> Result<String, String> {
check()?;
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32);
}
let Ok(mut file) = options.open(path) else {
return Ok(String::new());
};
if !file.metadata().is_ok_and(|metadata| metadata.is_file()) {
return Ok(String::new());
}
let format = Path::new(path)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
super::read_isrc(&mut file, &format, check)
}
fn stat(&self, path: &str) -> Result<Option<FileStamp>, String> {
Ok(fs::metadata(path)
.ok()
.map(|metadata| stamp(Path::new(path), &metadata)))
}
}
fn stamp(path: &Path, metadata: &fs::Metadata) -> FileStamp {
let modified_ns = metadata
.modified()
.map(|time| match time.duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_nanos() as i128,
Err(error) => -(error.duration().as_nanos() as i128),
})
.unwrap_or_default();
FileStamp {
path: path.to_string_lossy().into_owned(),
size: metadata.len(),
modified_ns,
directory: metadata.is_dir(),
}
}
fn clean(path: &Path) -> PathBuf {
let mut output = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir if output.file_name().is_some_and(|name| name != "..") => {
output.pop();
}
Component::ParentDir if output.has_root() => {}
other => output.push(other.as_os_str()),
}
}
if output.as_os_str().is_empty() {
output.push(".");
}
output
}
+19
View File
@@ -0,0 +1,19 @@
//! Shared backend logic, independent of mobile bindings and UI runtimes.
pub mod app_version;
pub mod cancellation;
pub mod clock;
pub mod cover;
pub mod cue;
pub mod downloads;
pub mod filename;
pub mod isrc;
pub mod lyrics;
pub mod matching;
pub mod media;
pub mod metadata;
pub mod progress;
pub mod resolver;
pub mod tags;
mod text;
pub use text::json_surrogates as normalize_json_surrogates;
+110
View File
@@ -0,0 +1,110 @@
//! Lyrics contracts shared by built-in providers, extension hosts and exports.
pub mod config;
pub mod errors;
pub mod file;
pub(crate) mod json;
pub mod lrc;
pub mod lrclib;
pub mod matching;
pub mod models;
pub mod payloads;
use serde::{Deserialize, Serialize};
/// HTTP providers use Go's streaming JSON decoder: decode the first value,
/// replacing invalid UTF-8 and unpaired UTF-16 escapes before typed decoding.
pub fn decode_response<T: serde::de::DeserializeOwned>(
bytes: &[u8],
) -> Result<T, serde_json::Error> {
let text = crate::text::utf8(bytes);
let normalized = crate::text::json_surrogates(&text);
// Go scans a complete JSON value before decoding typed fields. Otherwise a
// wrong root type could hide truncated JSON and change provider cooldowns.
let raw = <&serde_json::value::RawValue>::deserialize(
&mut serde_json::Deserializer::from_str(&normalized),
)?;
serde_json::from_str(raw.get())
}
/// Complete-document counterpart to the streaming HTTP decoder.
pub fn decode_document<T: serde::de::DeserializeOwned>(
bytes: &[u8],
) -> Result<T, serde_json::Error> {
let text = crate::text::utf8(bytes);
let normalized = crate::text::json_surrogates(&text);
let raw: &serde_json::value::RawValue = serde_json::from_str(&normalized)?;
serde_json::from_str(raw.get())
}
pub fn text_from_bytes(bytes: &[u8]) -> String {
crate::text::utf8(bytes)
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LyricsLine {
pub start_time_ms: i64,
pub words: String,
pub end_time_ms: i64,
}
json::go_deserialize!(LyricsLine {
"starttimems" => start_time_ms,
"words" => words,
"endtimems" => end_time_ms,
});
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LyricsResponse {
// Go distinguishes an uninitialized slice (null) from an explicit [].
pub lines: Option<Vec<LyricsLine>>,
pub sync_type: String,
pub instrumental: bool,
pub plain_lyrics: String,
pub provider: String,
pub source: String,
}
json::go_deserialize!(LyricsResponse {
"lines" => lines,
"synctype" => sync_type,
"instrumental" => instrumental,
"plainlyrics" => plain_lyrics,
"provider" => provider,
"source" => source,
});
impl LyricsResponse {
pub fn lines(&self) -> &[LyricsLine] {
self.lines.as_deref().unwrap_or_default()
}
pub fn has_usable_text(&self) -> bool {
self.instrumental
|| !self.plain_lyrics.trim().is_empty()
|| self
.lines()
.iter()
.any(|line| !line.words.trim().is_empty())
}
pub fn from_text(text: &str, provider: &str, source: &str) -> Self {
let mut result = Self {
provider: provider.into(),
source: source.into(),
..Self::default()
};
if let Some(lines) = lrc::parse_synced(text) {
result.plain_lyrics = lrc::plain_from_timed_lines(&lines);
result.lines = Some(lines);
result.sync_type = "LINE_SYNCED".into();
} else if let Some(lines) = lrc::plain_text_lines(text) {
result.lines = Some(lines);
result.sync_type = "UNSYNCED".into();
result.plain_lyrics = text.into();
}
result
}
}
@@ -0,0 +1,202 @@
use crate::matching::lowercase;
use serde::Serialize;
use std::collections::HashSet;
pub const DEFAULT_PROVIDERS: &[&str] = &["lrclib", "apple_music"];
pub const BUILTIN_PROVIDERS: &[&str] = &[
"lrclib",
"netease",
"musixmatch",
"apple_music",
"qqmusic",
"spotify",
"deezer",
"youtube",
"kugou",
"genius",
"lyricsplus",
];
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct FetchOptions {
pub include_translation_netease: bool,
pub include_romanization_netease: bool,
pub multi_person_word_by_word: bool,
pub apple_elrc_word_sync: bool,
#[serde(skip_serializing_if = "String::is_empty")]
pub musixmatch_language: String,
}
super::json::go_deserialize!(FetchOptions {
"include_translation_netease" => include_translation_netease,
"include_romanization_netease" => include_romanization_netease,
"multi_person_word_by_word" => multi_person_word_by_word,
"apple_elrc_word_sync" => apple_elrc_word_sync,
"musixmatch_language" => musixmatch_language,
});
impl Default for FetchOptions {
fn default() -> Self {
Self {
include_translation_netease: false,
include_romanization_netease: false,
multi_person_word_by_word: true,
apple_elrc_word_sync: false,
musixmatch_language: String::new(),
}
}
}
impl FetchOptions {
/// Go unmarshals partial settings into the current options. Commit only a
/// fully valid document, including duplicate fields and null scalar values.
pub fn update_json(&mut self, raw: &str) -> Result<(), serde_json::Error> {
use super::json::Update;
if raw.trim().is_empty() {
return Ok(());
}
let document = super::decode_document::<Box<serde_json::value::RawValue>>(raw.as_bytes())?;
let mut next = self.clone();
next.update(&mut serde_json::Deserializer::from_str(document.get()))?;
*self = next;
Ok(())
}
pub fn normalize(&mut self) {
self.musixmatch_language = lowercase(self.musixmatch_language.trim())
.chars()
.filter(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_'))
.take(16)
.collect();
}
}
pub fn decode_providers(raw: &str) -> Result<Vec<String>, serde_json::Error> {
Ok(
super::decode_document::<Option<Vec<Option<String>>>>(raw.as_bytes())?
.unwrap_or_default()
.into_iter()
.map(Option::unwrap_or_default)
.collect(),
)
}
#[derive(Serialize)]
pub struct AvailableProvider {
pub id: &'static str,
pub name: &'static str,
pub has_proxy_dependency: bool,
pub description: &'static str,
}
pub fn available_providers() -> Vec<AvailableProvider> {
[
(
"lrclib",
"LRCLIB",
false,
"Open-source synced lyrics database",
),
("netease", "Netease", true, "NetEase Cloud Music lyrics"),
("musixmatch", "Musixmatch", true, "Musixmatch lyrics"),
(
"apple_music",
"Apple Music",
true,
"Apple Music synced lyrics",
),
(
"qqmusic",
"QQ Music",
false,
"Direct QQ Music line-synced lyrics",
),
("spotify", "Spotify", true, "Spotify synced lyrics"),
("deezer", "Deezer", true, "Deezer lyrics"),
("youtube", "YouTube", true, "YouTube lyrics"),
("kugou", "Kugou", false, "Direct Kugou synced lyrics"),
("genius", "Genius", false, "Direct Genius lyrics"),
(
"lyricsplus",
"LyricsPlus",
true,
"Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)",
),
]
.into_iter()
.map(
|(id, name, has_proxy_dependency, description)| AvailableProvider {
id,
name,
has_proxy_dependency,
description,
},
)
.collect()
}
/// Empty/invalid selections use defaults when read, matching Go's getter.
pub fn provider_order(providers: &[String]) -> Vec<String> {
let mut result = normalize_provider_order(providers);
if result.is_empty() {
result = DEFAULT_PROVIDERS
.iter()
.map(|name| (*name).into())
.collect();
}
result
}
pub fn normalize_provider_order(providers: &[String]) -> Vec<String> {
let mut seen = HashSet::new();
providers
.iter()
.map(|name| lowercase(name.trim()))
.filter(|name| {
BUILTIN_PROVIDERS.contains(&name.as_str())
|| name
.strip_prefix("extension:")
.is_some_and(|id| !id.trim().is_empty())
})
.filter(|name| seen.insert(name.clone()))
.collect()
}
pub fn resolve_order(configured: &[String], extensions: &[String]) -> Vec<String> {
let available: HashSet<_> = extensions
.iter()
.map(|id| format!("extension:{}", lowercase(id.trim())))
.collect();
configured
.iter()
.filter(|name| BUILTIN_PROVIDERS.contains(&name.as_str()) || available.contains(*name))
.cloned()
.collect()
}
pub fn cache_key(
spotify_id: &str,
track: &str,
artist: &str,
duration: f64,
providers: &[String],
extensions: &[String],
options: &FetchOptions,
) -> String {
let mut extensions: Vec<_> = extensions.iter().map(|id| lowercase(id.trim())).collect();
extensions.sort();
format!(
"{}|{}|{}|{:.0}|{}|{}|{}|{}|{}|{}|{}",
spotify_id.trim(),
lowercase(artist.trim()),
lowercase(track.trim()),
(duration / 10.0).round() * 10.0,
providers.join(","),
extensions.join(","),
options.include_translation_netease,
options.include_romanization_netease,
options.multi_person_word_by_word,
options.apple_elrc_word_sync,
options.musixmatch_language
)
}
@@ -0,0 +1,102 @@
use crate::matching::lowercase;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
NotFound,
Unavailable,
Other,
}
pub fn http_status(status: u16) -> ErrorKind {
if status == 429 || status >= 500 {
ErrorKind::Unavailable
} else {
ErrorKind::Other
}
}
pub fn payload_not_found(message: &str) -> bool {
let message = lowercase(message);
[
"lyrics not found",
"no lyrics found",
"no songs found",
"not found",
]
.iter()
.any(|signal| message.contains(signal))
}
pub fn classify_payload(status: u16, message: &str) -> ErrorKind {
if payload_not_found(message) {
return ErrorKind::NotFound;
}
let message = lowercase(message);
if [
"rate limit",
"too many requests",
"operation too frequent",
"操作频繁",
"missing required parameters",
]
.iter()
.any(|signal| message.contains(signal))
{
return ErrorKind::Unavailable;
}
http_status(status)
}
pub fn detect_payload(raw: &str) -> Option<String> {
let raw = raw.trim();
if !raw.starts_with('{') {
return None;
}
let value: Value = serde_json::from_str(&crate::text::json_surrogates(raw)).ok()?;
let payload = value.as_object()?;
if [
"lyrics",
"lyric",
"lrc",
"content",
"lines",
"syncedLyrics",
"unsyncedLyrics",
]
.iter()
.any(|key| payload.contains_key(*key))
{
return None;
}
for key in ["message", "error", "detail", "reason"] {
if let Some(message) = value[key]
.as_str()
.map(str::trim)
.filter(|text| !text.is_empty())
{
return Some(message.into());
}
}
if value["success"] == false || value["isError"] == true {
return Some("request unsuccessful".into());
}
if let Some(code) = value["code"].as_f64()
&& code != 0.0
&& code != 200.0
{
for key in ["message", "msg"] {
if let Some(message) = value[key]
.as_str()
.map(str::trim)
.filter(|text| !text.is_empty())
{
return Some(message.into());
}
}
return Some(format!("unexpected response code {code:.0}"));
}
None
}
@@ -0,0 +1,81 @@
//! Existing-file lyrics with caller-owned file access and cancellation.
use super::text_from_bytes;
use std::io::{Read, Seek};
const MAX_SIDECAR_BYTES: usize = 8 * 1024 * 1024;
pub fn extract<R: Read + Seek>(
path: &str,
open: &impl Fn(&str) -> Result<R, String>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<String, String> {
check()?;
// Match filepath.Ext, including dotfiles and trailing dots on Unix.
let suffix = path
.rfind('.')
.filter(|index| !path[*index..].contains('/'));
let format = suffix.map_or_else(String::new, |index| path[index + 1..].to_ascii_lowercase());
if matches!(
format.as_str(),
"flac" | "m4a" | "mp4" | "aac" | "mp3" | "opus" | "ogg" | "wav" | "aiff" | "aif" | "aifc"
) && let Ok(metadata) =
open(path).and_then(|mut file| crate::tags::read_audio_tags(&mut file, &format, check))
{
check()?;
if !metadata.lyrics.trim().is_empty() {
return Ok(metadata.lyrics);
}
if !matches!(format.as_str(), "flac" | "m4a" | "mp4" | "aac")
&& looks_like_lyrics(&metadata.comment)
{
return Ok(metadata.comment);
}
}
check()?;
sidecar(path, open, check)
}
pub fn sidecar<R: Read>(
path: &str,
open: &impl Fn(&str) -> Result<R, String>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<String, String> {
check()?;
let suffix = path
.rfind('.')
.filter(|index| !path[*index..].contains('/'));
let base = &path[..suffix.unwrap_or(path.len())];
if base.trim().is_empty() {
return Err("no lyrics found in file".into());
}
let mut file = open(&format!("{base}.lrc"))?;
let mut bytes = Vec::new();
let mut buffer = [0; 16 * 1024];
loop {
check()?;
let count = file.read(&mut buffer).map_err(|error| error.to_string())?;
if count == 0 {
break;
}
if bytes.len() + count > MAX_SIDECAR_BYTES {
return Err("lyrics sidecar exceeds size limit".into());
}
bytes.extend_from_slice(&buffer[..count]);
}
check()?;
let text = text_from_bytes(&bytes).trim().to_owned();
if text.is_empty() {
Err("no lyrics found in file".into())
} else {
Ok(text)
}
}
fn looks_like_lyrics(text: &str) -> bool {
let text = text.trim();
let lower = text.to_ascii_lowercase();
lower.contains("[ar:")
|| lower.contains("[ti:")
|| (text.contains('\n') && text.contains('[') && text.contains(']'))
}
+163
View File
@@ -0,0 +1,163 @@
//! Go's typed provider JSON accepts case-insensitive fields and null scalars.
use serde::de::{DeserializeOwned, DeserializeSeed, Visitor};
use serde::{Deserialize, Deserializer};
use std::borrow::Cow;
pub(super) fn decode<T: DeserializeOwned>(raw: &str) -> Result<T, serde_json::Error> {
serde_json::from_str(&crate::text::json_surrogates(raw))
}
pub(crate) fn field_name(name: &str) -> String {
name.chars()
.map(|ch| match ch {
'ſ' => 's',
'K' => 'k',
_ => ch.to_ascii_lowercase(),
})
.collect()
}
struct KeyVisitor;
impl<'de> Visitor<'de> for KeyVisitor {
type Value = Cow<'de, str>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a string key")
}
fn visit_borrowed_str<E: serde::de::Error>(self, value: &'de str) -> Result<Self::Value, E> {
Ok(Cow::Borrowed(value))
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
Ok(Cow::Owned(value.into()))
}
fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
Ok(Cow::Owned(value))
}
}
pub(crate) struct KeySeed;
impl<'de> DeserializeSeed<'de> for KeySeed {
type Value = Cow<'de, str>;
fn deserialize<D: Deserializer<'de>>(self, decoder: D) -> Result<Self::Value, D::Error> {
decoder.deserialize_str(KeyVisitor)
}
}
pub(crate) trait Update<'de> {
fn update<D: Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error>;
}
macro_rules! scalar {
($($type:ty),*) => {$(
impl<'de> Update<'de> for $type {
fn update<D: Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error> {
// A later null does not overwrite an already decoded scalar.
if let Some(value) = Option::<Self>::deserialize(decoder)? { *self = value; }
Ok(())
}
}
)*};
}
scalar!(String, bool, i64, isize, f64);
impl<'de, T: Deserialize<'de>> Update<'de> for Option<T> {
fn update<D: Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error> {
*self = Self::deserialize(decoder)?;
Ok(())
}
}
pub(crate) struct Field<'a, T>(pub &'a mut T);
impl<'de, T: Update<'de>> DeserializeSeed<'de> for Field<'_, T> {
type Value = ();
fn deserialize<D: Deserializer<'de>>(self, decoder: D) -> Result<(), D::Error> {
self.0.update(decoder)
}
}
macro_rules! go_deserialize {
($name:ident { $($key:literal => $field:ident,)* }) => {
impl<'de> serde::Deserialize<'de> for $name {
fn deserialize<D: serde::Deserializer<'de>>(decoder: D) -> Result<Self, D::Error> {
let mut result = Self::default();
$crate::lyrics::json::Update::update(&mut result, decoder)?;
Ok(result)
}
}
impl<'de> $crate::lyrics::json::Update<'de> for $name {
fn update<D: serde::Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error> {
struct Visitor<'a>(&'a mut $name);
impl<'de> serde::de::Visitor<'de> for Visitor<'_> {
type Value = ();
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a lyrics object or null")
}
fn visit_unit<E: serde::de::Error>(self) -> Result<(), E> {
Ok(())
}
fn visit_map<M: serde::de::MapAccess<'de>>(self, mut map: M) -> Result<(), M::Error> {
while let Some(key) = map.next_key_seed($crate::lyrics::json::KeySeed)? {
let raw_key = key.as_ref();
let folded;
let lookup = match raw_key {
$($key => raw_key,)*
_ => {
folded = $crate::lyrics::json::field_name(raw_key);
folded.as_str()
}
};
match lookup {
$($key => map.next_value_seed($crate::lyrics::json::Field(&mut self.0.$field))?,)*
_ => { map.next_value::<serde::de::IgnoredAny>()?; }
}
}
Ok(())
}
}
decoder.deserialize_any(Visitor(self))
}
}
};
}
pub(crate) use go_deserialize;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_seed_borrows_canonical_keys_and_owns_escaped_keys() {
let mut decoder = serde_json::Deserializer::from_str(r#""source""#);
assert!(matches!(
KeySeed.deserialize(&mut decoder).unwrap(),
Cow::Borrowed("source")
));
let mut decoder = serde_json::Deserializer::from_str(r#""so\u0075rce""#);
assert!(matches!(
KeySeed.deserialize(&mut decoder).unwrap(),
Cow::Owned(value) if value == "source"
));
}
#[test]
fn field_folding_preserves_special_letters_and_null_duplicates() {
assert_eq!(field_name("ſOURCE"), "source");
assert_eq!(field_name("K"), "k");
let response: crate::lyrics::LyricsResponse = serde_json::from_str(
r#"{"plainlyrics":"kept","PLAINLYRICS":null,"ſource":"old","SOURCE":"new"}"#,
)
.unwrap();
assert_eq!(response.plain_lyrics, "kept");
assert_eq!(response.source, "new");
}
}
+189
View File
@@ -0,0 +1,189 @@
use super::{LyricsLine, LyricsResponse};
use crate::matching::lowercase;
use regex::Regex;
use std::sync::LazyLock;
static TIMED_LINE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([0-9]{2}):([0-9]{2})\.([0-9]{2,3})\](.*)").unwrap());
static METADATA: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^\[[a-z][a-z0-9_]*:.*\]$").unwrap());
static BACKGROUND: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^\[bg:(.*)\]$").unwrap());
static LEADING_TIME: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\[[0-9]{1,3}:[0-9]{1,2}(?:[.:][0-9]{1,3})?\]").unwrap());
static INLINE_TIME: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<[0-9]{1,3}:[0-9]{1,2}(?:[.:][0-9]{1,3})?>").unwrap());
static INSTRUMENTAL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^\[instrumental:true\]$").unwrap());
pub fn is_instrumental_marker(raw: &str) -> bool {
INSTRUMENTAL.is_match(raw.trim())
}
/// Headers, empty timestamps and empty vocal markers are not embedded lyrics.
pub fn has_usable_content(raw: &str) -> bool {
if is_instrumental_marker(raw) {
return true;
}
for line in raw.split('\n') {
let mut cleaned = line.trim();
if cleaned.is_empty() {
continue;
}
if let Some(background) = BACKGROUND.captures(cleaned) {
cleaned = background.get(1).unwrap().as_str().trim();
} else if METADATA.is_match(cleaned) {
continue;
}
while let Some(timestamp) = LEADING_TIME.find(cleaned) {
cleaned = cleaned[timestamp.end()..].trim();
}
let without_inline = INLINE_TIME.replace_all(cleaned, "");
cleaned = without_inline.trim();
let lower = lowercase(cleaned);
if lower.starts_with("v1:") || lower.starts_with("v2:") {
cleaned = cleaned[3..].trim();
}
if !cleaned.is_empty() {
return true;
}
}
false
}
pub fn parse_synced(raw: &str) -> Option<Vec<LyricsLine>> {
let mut lines: Vec<LyricsLine> = Vec::new();
for line in raw.split('\n').map(str::trim) {
if line.is_empty() {
continue;
}
if line.starts_with("[bg:")
&& let Some(previous) = lines.last_mut()
{
previous.words.push('\n');
previous.words.push_str(line);
continue;
}
if let Some(captures) = TIMED_LINE.captures(line) {
let words = captures[4].trim();
if words.is_empty() {
continue;
}
let minutes = captures[1].parse::<i64>().unwrap();
let seconds = captures[2].parse::<i64>().unwrap();
let mut fraction = captures[3].parse::<i64>().unwrap();
if captures[3].len() == 2 {
fraction *= 10;
}
lines.push(LyricsLine {
start_time_ms: minutes * 60_000 + seconds * 1000 + fraction,
words: words.into(),
end_time_ms: 0,
});
}
}
for index in 1..lines.len() {
lines[index - 1].end_time_ms = lines[index].start_time_ms;
}
if let Some(last) = lines.last_mut() {
last.end_time_ms = last.start_time_ms + 5000;
}
(!lines.is_empty()).then_some(lines)
}
pub fn plain_text_lines(raw: &str) -> Option<Vec<LyricsLine>> {
let lines: Vec<_> = raw
.split('\n')
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| LyricsLine {
words: line.into(),
..LyricsLine::default()
})
.collect();
(!lines.is_empty()).then_some(lines)
}
pub fn plain_from_timed_lines(lines: &[LyricsLine]) -> String {
lines
.iter()
.map(|line| line.words.trim())
.filter(|words| !words.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
pub fn timestamp_inline(ms: i64) -> String {
let seconds = ms / 1000;
format!(
"{:02}:{:02}.{:02}",
seconds / 60,
seconds % 60,
ms % 1000 / 10
)
}
pub fn timestamp(ms: i64) -> String {
format!("[{}]", timestamp_inline(ms))
}
pub fn source_uses_proxy(source: &str) -> bool {
let source = lowercase(source.trim());
!source.is_empty()
&& ![
"lrclib",
"kugou direct",
"qq music direct",
"genius direct",
"extension:",
"heuristic",
]
.iter()
.any(|prefix| source.starts_with(prefix))
}
pub fn extract_source(raw: &str) -> String {
for line in raw.split('\n').map(str::trim) {
if !lowercase(line).starts_with("[by:") {
continue;
}
let Some((_, source)) = line.split_once("(source: ") else {
return String::new();
};
let source = source.trim();
let source = source.strip_suffix(']').unwrap_or(source);
return source.strip_suffix(')').unwrap_or(source).trim().into();
}
String::new()
}
pub fn with_metadata(lyrics: &LyricsResponse, track: &str, artist: &str) -> String {
if lyrics.lines().is_empty() {
return String::new();
}
let source = if lyrics.source.trim().is_empty() {
lyrics.provider.trim()
} else {
lyrics.source.trim()
};
let credit = if source_uses_proxy(source) {
"SpotiFLAC-Mobile via Paxsenix API"
} else {
"SpotiFLAC-Mobile"
};
let mut output = format!("[ti:{track}]\n[ar:{artist}]\n[by:{credit}");
if !source.is_empty() {
output.push_str(&format!(" (source: {source})"));
}
output.push_str("]\n\n");
for line in lyrics.lines() {
if line.words.is_empty() {
continue;
}
if lyrics.sync_type == "LINE_SYNCED" {
output.push_str(&timestamp(line.start_time_ms));
}
output.push_str(&line.words);
output.push('\n');
}
output
}
@@ -0,0 +1,95 @@
use super::{LyricsLine, LyricsResponse, lrc, matching};
use serde::Serialize;
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LrcLibResponse {
pub id: isize,
pub name: String,
pub track_name: String,
pub artist_name: String,
pub album_name: String,
pub duration: f64,
pub instrumental: bool,
pub plain_lyrics: String,
pub synced_lyrics: String,
}
super::json::go_deserialize!(LrcLibResponse {
"id" => id, "name" => name, "trackname" => track_name,
"artistname" => artist_name, "albumname" => album_name, "duration" => duration,
"instrumental" => instrumental, "plainlyrics" => plain_lyrics,
"syncedlyrics" => synced_lyrics,
});
impl LrcLibResponse {
pub fn track_name(&self) -> &str {
if self.track_name.trim().is_empty() {
self.name.trim()
} else {
self.track_name.trim()
}
}
pub fn matches(&self, query: &str, track: &str, artist: &str, duration: f64) -> bool {
if !matching::duration_matches(self.duration, duration) {
return false;
}
if !track.trim().is_empty() || !artist.trim().is_empty() {
return matching::titles_match(self.track_name(), track, false)
&& matching::artists_match(&self.artist_name, artist);
}
let query = matching::normalize_loose_artist(query);
let track = matching::normalize_loose_artist(&matching::simplify_track(self.track_name()));
let artist = matching::normalize_loose_artist(&matching::primary_artist(&self.artist_name));
matching::contains_words(&query, &track) && matching::contains_words(&query, &artist)
}
pub fn into_lyrics(&self) -> LyricsResponse {
let mut result = LyricsResponse {
instrumental: self.instrumental,
plain_lyrics: self.plain_lyrics.clone(),
provider: "LRCLIB".into(),
..LyricsResponse::default()
};
if !self.synced_lyrics.is_empty() {
result.lines = lrc::parse_synced(&self.synced_lyrics);
result.sync_type = "LINE_SYNCED".into();
} else if !self.plain_lyrics.is_empty() {
result.sync_type = "UNSYNCED".into();
let lines: Vec<_> = self
.plain_lyrics
.split('\n')
.filter(|line| !line.trim().is_empty())
.map(|line| LyricsLine {
words: line.into(),
..LyricsLine::default()
})
.collect();
result.lines = (!lines.is_empty()).then_some(lines);
}
result
}
}
pub fn select<'a>(
results: &'a [LrcLibResponse],
query: &str,
track: &str,
artist: &str,
duration: f64,
) -> Option<&'a LrcLibResponse> {
let mut synced = None;
let mut plain = None;
for result in results {
if !result.matches(query, track, artist, duration) {
continue;
}
if !result.synced_lyrics.is_empty() && synced.is_none() {
synced = Some(result);
} else if !result.plain_lyrics.is_empty() && plain.is_none() {
plain = Some(result);
}
}
synced.or(plain)
}
@@ -0,0 +1,223 @@
use crate::matching::lowercase;
use regex::Regex;
use std::borrow::Cow;
use std::sync::LazyLock;
use unicode_general_category::{GeneralCategory, get_general_category};
use unicode_normalization::UnicodeNormalization;
static SIMPLIFY: LazyLock<Vec<Regex>> = LazyLock::new(|| {
[
r"\s*\(feat\.?.*?\)",
r"\s*\(ft\.?.*?\)",
r"\s*\(featuring.*?\)",
r"\s*\(with.*?\)",
r"\s*-\s*Remaster(ed)?.*$",
r"\s*-\s*\d{4}\s*Remaster.*$",
r"\s*\(Remaster(ed)?.*?\)",
r"\s*\(Deluxe.*?\)",
r"\s*\(Bonus.*?\)",
r"\s*\(Live.*?\)",
r"\s*\(Acoustic.*?\)",
r"\s*\(Radio Edit\)",
r"\s*\(Single Version\)",
]
.iter()
.map(|pattern| {
// Go's Perl character classes are ASCII even with Unicode case folding.
Regex::new(&format!(
"(?i){}",
pattern
.replace(r"\s", r"[\t\n\f\r ]")
.replace(r"\d", "[0-9]")
))
.unwrap()
})
.collect()
});
static INSTRUMENTAL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(?:^|[\t\n\f\r \[(\-])(?:instrumental|inst\.?)(?:[\t\n\f\r \])]|$)").unwrap()
});
fn normalized(value: &str, artist: bool) -> String {
use GeneralCategory::*;
let lower = lowercase(value.trim());
let mut output = String::with_capacity(lower.len());
let characters: Box<dyn Iterator<Item = char>> = if artist {
Box::new(lower.nfd())
} else {
Box::new(lower.chars())
};
for ch in characters {
match get_general_category(ch) {
UppercaseLetter | LowercaseLetter | TitlecaseLetter | ModifierLetter | OtherLetter
| DecimalNumber | LetterNumber | OtherNumber => match (artist, ch) {
(true, 'đ') => output.push_str("dj"),
(true, 'ß') => output.push_str("ss"),
(true, 'æ') => output.push_str("ae"),
(true, 'œ') => output.push_str("oe"),
_ => output.push(ch),
},
_ if ch.is_whitespace()
|| matches!(ch, '/' | '\\' | '_' | '-' | '|' | '.' | '&' | '+') =>
{
output.push(' ')
}
_ => {}
}
}
output.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn normalize_title(value: &str) -> String {
normalized(value, false)
}
pub fn normalize_loose_artist(value: &str) -> String {
normalized(value, true)
}
pub fn simplify_track(value: &str) -> String {
let mut output = value.to_owned();
for pattern in SIMPLIFY.iter() {
if let Cow::Owned(replaced) = pattern.replace_all(&output, "") {
output = replaced;
}
}
let normalized = normalize_title(output.trim());
if normalized.is_empty() {
output.trim().into()
} else {
normalized
}
}
pub fn primary_artist(value: &str) -> String {
let lowered = lowercase(value);
for separator in [
", ",
"; ",
" & ",
" feat. ",
" ft. ",
" featuring ",
" with ",
] {
if let Some(index) = lowered.find(separator)
&& index > 0
{
// Go slices the original by the lowercased byte offset. Preserve
// replacement bytes if a lowercase mapping changed its UTF-8 width.
return crate::text::utf8(&value.as_bytes()[..index.min(value.len())])
.trim()
.into();
}
}
value.trim().into()
}
pub fn contains_words(value: &str, sequence: &str) -> bool {
let value: Vec<_> = value.split_whitespace().collect();
let sequence: Vec<_> = sequence.split_whitespace().collect();
!sequence.is_empty() && value.windows(sequence.len()).any(|words| words == sequence)
}
pub fn titles_match(candidate: &str, expected: &str, decorated: bool) -> bool {
let candidate = lowercase(simplify_track(candidate).trim());
let expected = lowercase(simplify_track(expected).trim());
!candidate.is_empty()
&& !expected.is_empty()
&& (candidate == expected || (decorated && contains_words(&candidate, &expected)))
}
pub fn artists_match(candidate: &str, expected: &str) -> bool {
let expected = normalize_loose_artist(&primary_artist(expected));
if expected.is_empty() {
return true;
}
let candidate = normalize_loose_artist(&primary_artist(candidate));
let mut candidate_words: Vec<_> = candidate.split_whitespace().collect();
let mut expected_words: Vec<_> = expected.split_whitespace().collect();
candidate_words.sort_unstable();
expected_words.sort_unstable();
candidate_words == expected_words
}
pub fn duration_matches(candidate: f64, expected: f64) -> bool {
candidate <= 0.0 || expected <= 0.0 || (candidate - expected).abs() <= 10.0
}
pub fn artist_in_title(candidate: &str, artist: &str) -> bool {
let expected = normalize_loose_artist(&primary_artist(artist));
contains_words(&normalize_loose_artist(candidate), &expected)
}
pub fn is_likely_instrumental(title: &str) -> bool {
INSTRUMENTAL.is_match(title.trim())
}
pub fn score(
candidate_title: &str,
candidate_artist: &str,
candidate_duration: f64,
title: &str,
artist: &str,
duration: f64,
) -> i32 {
let title = lowercase(simplify_track(title).trim());
let candidate_title = lowercase(simplify_track(candidate_title).trim());
let artist = lowercase(primary_artist(artist).trim());
let candidate_artist = lowercase(primary_artist(candidate_artist).trim());
let component = |candidate: &str, expected: &str, weight| {
if candidate == expected {
weight
} else if candidate.contains(expected) || expected.contains(candidate) {
weight / 2
} else {
0
}
};
component(&candidate_title, &title, 50)
+ component(&candidate_artist, &artist, 60)
+ if duration > 0.0
&& candidate_duration > 0.0
&& (candidate_duration - duration).abs() <= 10.0
{
20
} else {
0
}
}
pub fn clock_duration(value: &str) -> f64 {
let mut total = 0_isize;
if value.trim().is_empty() {
return 0.0;
}
for part in value.trim().split(':') {
let Ok(value) = part.trim().parse::<isize>() else {
return 0.0;
};
total = total.wrapping_mul(60).wrapping_add(value);
}
total as f64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simplify_and_primary_artist_keep_go_order_and_offsets() {
assert_eq!(simplify_track("Song (feat. Guest) (Live)"), "song");
assert_eq!(
primary_artist("First with Guest, Second"),
"First with Guest"
);
// '&' wins before 'with'; Go applies its lowercased byte offset to the
// original string even when lowercasing changes the UTF-8 width.
assert_eq!(
primary_artist("İ Artist with Guest & Other"),
"İ Artist with Gues"
);
}
}
@@ -0,0 +1,192 @@
//! Typed built-in search schemas, retaining Go field folding and null behavior.
use super::json::go_deserialize;
use std::collections::BTreeMap;
#[derive(Clone, Debug, Default)]
pub struct Artist {
pub name: String,
}
go_deserialize!(Artist { "name" => name, });
#[derive(Clone, Debug, Default)]
pub struct SpotifySong {
pub track_id: String,
pub name: String,
pub artist_name: String,
pub duration: String,
}
go_deserialize!(SpotifySong { "trackid" => track_id, "name" => name, "artistname" => artist_name, "duration" => duration, });
#[derive(Clone, Debug, Default)]
pub struct YouTubeSong {
pub video_id: String,
pub title: String,
pub author: String,
pub duration: String,
}
go_deserialize!(YouTubeSong { "videoid" => video_id, "title" => title, "author" => author, "duration" => duration, });
#[derive(Clone, Debug, Default)]
pub struct NeteaseSong {
pub name: String,
pub id: i64,
pub artists: Option<Vec<Artist>>,
}
go_deserialize!(NeteaseSong { "name" => name, "id" => id, "artists" => artists, });
#[derive(Clone, Debug, Default)]
pub struct NeteaseResults {
pub songs: Option<Vec<NeteaseSong>>,
pub song_count: isize,
}
go_deserialize!(NeteaseResults { "songs" => songs, "songcount" => song_count, });
#[derive(Clone, Debug, Default)]
pub struct NeteaseSearch {
pub result: NeteaseResults,
pub code: isize,
pub message: String,
pub msg: String,
}
go_deserialize!(NeteaseSearch { "result" => result, "code" => code, "message" => message, "msg" => msg, });
#[derive(Clone, Debug, Default)]
pub struct LyricField {
pub lyric: String,
}
go_deserialize!(LyricField { "lyric" => lyric, });
#[derive(Clone, Debug, Default)]
pub struct NeteaseLyrics {
pub lrc: Option<LyricField>,
pub tlyric: Option<LyricField>,
pub romalrc: Option<LyricField>,
pub code: isize,
}
go_deserialize!(NeteaseLyrics { "lrc" => lrc, "tlyric" => tlyric, "romalrc" => romalrc, "code" => code, });
#[derive(Clone, Debug, Default)]
pub struct AppleSongId {
pub id: String,
}
go_deserialize!(AppleSongId { "id" => id, });
#[derive(Clone, Debug, Default)]
pub struct AppleSongs {
pub data: Option<Vec<AppleSongId>>,
}
go_deserialize!(AppleSongs { "data" => data, });
#[derive(Clone, Debug, Default)]
pub struct AppleResults {
pub songs: Option<AppleSongs>,
}
go_deserialize!(AppleResults { "songs" => songs, });
#[derive(Clone, Debug, Default)]
pub struct AppleAttributes {
pub name: String,
pub artist_name: String,
pub album_name: String,
pub duration_in_millis: isize,
}
go_deserialize!(AppleAttributes { "name" => name, "artistname" => artist_name, "albumname" => album_name, "durationinmillis" => duration_in_millis, });
#[derive(Clone, Debug, Default)]
pub struct AppleSong {
pub attributes: AppleAttributes,
}
go_deserialize!(AppleSong { "attributes" => attributes, });
#[derive(Clone, Debug, Default)]
pub struct AppleResources {
pub songs: Option<BTreeMap<String, AppleSong>>,
}
go_deserialize!(AppleResources { "songs" => songs, });
#[derive(Clone, Debug, Default)]
pub struct AppleSearch {
pub results: AppleResults,
pub resources: Option<AppleResources>,
}
go_deserialize!(AppleSearch { "results" => results, "resources" => resources, });
#[derive(Clone, Debug, Default)]
pub struct QqSong {
pub mid: String,
pub id: i64,
pub name: String,
pub interval: isize,
pub singer: Option<Vec<Artist>>,
}
go_deserialize!(QqSong { "mid" => mid, "id" => id, "name" => name, "interval" => interval, "singer" => singer, });
#[derive(Clone, Debug, Default)]
pub struct QqSongs {
pub list: Option<Vec<QqSong>>,
}
go_deserialize!(QqSongs { "list" => list, });
#[derive(Clone, Debug, Default)]
pub struct QqData {
pub song: QqSongs,
}
go_deserialize!(QqData { "song" => song, });
#[derive(Clone, Debug, Default)]
pub struct QqSearch {
pub code: isize,
pub data: QqData,
}
go_deserialize!(QqSearch { "code" => code, "data" => data, });
#[derive(Clone, Debug, Default)]
pub struct QqLyrics {
pub retcode: isize,
pub code: isize,
pub lyric: String,
}
go_deserialize!(QqLyrics { "retcode" => retcode, "code" => code, "lyric" => lyric, });
#[derive(Clone, Debug, Default)]
pub struct KugouSong {
pub id: String,
pub accesskey: String,
pub song: String,
pub singer: String,
pub duration: f64,
}
go_deserialize!(KugouSong { "id" => id, "accesskey" => accesskey, "song" => song, "singer" => singer, "duration" => duration, });
#[derive(Clone, Debug, Default)]
pub struct KugouSearch {
pub status: isize,
pub errcode: isize,
pub errmsg: String,
pub candidates: Option<Vec<KugouSong>>,
}
go_deserialize!(KugouSearch { "status" => status, "errcode" => errcode, "errmsg" => errmsg, "candidates" => candidates, });
#[derive(Clone, Debug, Default)]
pub struct KugouLyrics {
pub status: isize,
pub error_code: isize,
pub info: String,
pub content: String,
}
go_deserialize!(KugouLyrics { "status" => status, "error_code" => error_code, "info" => info, "content" => content, });
#[derive(Clone, Debug, Default)]
pub struct GeniusSong {
pub title: String,
pub artist_names: String,
pub primary_artist_names: String,
pub url: String,
}
go_deserialize!(GeniusSong { "title" => title, "artist_names" => artist_names, "primary_artist_names" => primary_artist_names, "url" => url, });
#[derive(Clone, Debug, Default)]
pub struct GeniusHit {
pub kind: String,
pub result: GeniusSong,
}
go_deserialize!(GeniusHit { "type" => kind, "result" => result, });
#[derive(Clone, Debug, Default)]
pub struct GeniusSection {
pub hits: Option<Vec<GeniusHit>>,
}
go_deserialize!(GeniusSection { "hits" => hits, });
#[derive(Clone, Debug, Default)]
pub struct GeniusResults {
pub sections: Option<Vec<GeniusSection>>,
}
go_deserialize!(GeniusResults { "sections" => sections, });
#[derive(Clone, Debug, Default)]
pub struct GeniusSearch {
pub response: GeniusResults,
}
go_deserialize!(GeniusSearch { "response" => response, });
@@ -0,0 +1,312 @@
use super::{LyricsResponse, json, lrc};
use serde::Serialize;
use serde_json::value::RawValue;
use std::collections::BTreeMap;
#[derive(Clone, Debug, Default, Serialize)]
pub struct PaxDetail {
pub text: String,
pub part: bool,
pub timestamp: Option<isize>,
pub endtime: Option<isize>,
}
json::go_deserialize!(PaxDetail {
"text" => text, "part" => part, "timestamp" => timestamp, "endtime" => endtime,
});
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PaxLine {
pub text: Option<Vec<PaxDetail>>,
pub timestamp: isize,
pub opposite_turn: bool,
pub background: bool,
pub background_text: Option<Vec<PaxDetail>>,
pub endtime: isize,
}
json::go_deserialize!(PaxLine {
"text" => text, "timestamp" => timestamp, "oppositeturn" => opposite_turn,
"background" => background, "backgroundtext" => background_text, "endtime" => endtime,
});
#[derive(Default)]
struct ApplePayload {
kind: String,
content: Option<Vec<PaxLine>>,
elrc: String,
elrc_multi_person: String,
plain: String,
ttml_content: String,
}
json::go_deserialize!(ApplePayload {
"type" => kind, "content" => content, "elrc" => elrc,
"elrcmultiperson" => elrc_multi_person, "plain" => plain, "ttmlcontent" => ttml_content,
});
#[derive(Default)]
struct ProxyPayload {
kind: String,
content: Option<Vec<PaxLine>>,
lyrics: Option<Vec<PaxLine>>,
lyrics_text: String,
plain_lyrics: String,
}
json::go_deserialize!(ProxyPayload {
"type" => kind, "content" => content, "lyrics" => lyrics,
"lyrics_text" => lyrics_text, "plain_lyrics" => plain_lyrics,
});
fn append_detail(output: &mut String, details: &[PaxDetail], word_timing: bool) {
let mut last_start = String::new();
for detail in details {
if word_timing && let Some(time) = detail.timestamp {
let start = format!("<{}>", lrc::timestamp_inline(time as i64));
if start != last_start {
output.push_str(&start);
last_start = start;
}
}
output.push_str(&detail.text);
if !detail.part {
output.push(' ');
}
if word_timing && let Some(time) = detail.endtime {
output.push_str(&format!("<{}>", lrc::timestamp_inline(time as i64)));
}
}
}
pub fn format_pax_content(
kind: &str,
lines: &[PaxLine],
multi_person: bool,
word_timing: bool,
) -> String {
let mut output = String::new();
for (index, line) in lines.iter().enumerate() {
if index > 0 {
output.push('\n');
}
if json::field_name(kind) == "syllable" {
output.push_str(&lrc::timestamp(line.timestamp as i64));
if multi_person {
output.push_str(if line.opposite_turn { "v2:" } else { "v1:" });
}
append_detail(
&mut output,
line.text.as_deref().unwrap_or_default(),
word_timing,
);
if line.background
&& multi_person
&& let Some(background) = line
.background_text
.as_ref()
.filter(|lines| !lines.is_empty())
{
output.push_str("\n[bg:");
append_detail(&mut output, background, word_timing);
output.push(']');
}
} else if let Some(text) = line.text.as_ref().and_then(|text| text.first()) {
output.push_str(&lrc::timestamp(line.timestamp as i64));
output.push_str(&text.text);
}
}
output.trim().into()
}
pub fn format_apple(raw: &str, multi_person: bool, word_timing: bool) -> Result<String, String> {
if let Ok(Some(value)) = json::decode::<Option<String>>(raw)
&& !value.trim().is_empty()
{
return Ok(value.trim().into());
}
if let Ok(value) = json::decode::<ApplePayload>(raw)
&& (value.content.is_some()
|| [
&value.elrc_multi_person,
&value.elrc,
&value.plain,
&value.ttml_content,
]
.iter()
.any(|value| !value.trim().is_empty()))
{
if word_timing && multi_person && !value.elrc_multi_person.trim().is_empty() {
return Ok(value.elrc_multi_person.trim().into());
}
if word_timing && !value.elrc.trim().is_empty() {
return Ok(value.elrc.trim().into());
}
let content = value.content.as_deref().unwrap_or_default();
if !value.plain.trim().is_empty() && content.is_empty() {
return Ok(value.plain.trim().into());
}
if content.is_empty() {
return Err("unsupported apple music lyrics payload".into());
}
return Ok(format_pax_content(
&value.kind,
content,
multi_person,
word_timing,
));
}
if let Ok(Some(lines)) = json::decode::<Option<Vec<PaxLine>>>(raw)
&& !lines.is_empty()
{
return Ok(format_pax_content(
"Syllable",
&lines,
multi_person,
word_timing,
));
}
Err("failed to parse pax lyrics response".into())
}
/// Failure strings describe unavailable proxy payloads, not an absent track.
pub fn parse_proxy(
raw: &str,
provider: &str,
multi_person: bool,
) -> Result<LyricsResponse, String> {
if let Ok(value) = json::decode::<Option<String>>(raw) {
let value = value.unwrap_or_default();
if value.trim().is_empty() {
return Err(format!("{provider} returned empty lyrics"));
}
return Ok(LyricsResponse::from_text(value.trim(), provider, provider));
}
// Go checks these aliases through json.RawMessage. An unused number may
// exceed float64's range without invalidating an otherwise usable payload.
if let Ok(value) = json::decode::<BTreeMap<String, Box<RawValue>>>(raw) {
for key in ["lyrics", "lyric", "lyrics_text", "plain_lyrics"] {
if let Some(text) = value
.get(key)
.and_then(|value| json::decode::<Option<String>>(value.get()).ok().flatten())
.filter(|value| !value.trim().is_empty())
{
return Ok(LyricsResponse::from_text(text.trim(), provider, provider));
}
}
}
if let Ok(value) = json::decode::<ProxyPayload>(raw) {
let content = value.content.as_deref().unwrap_or_default();
let lyrics = value.lyrics.as_deref().unwrap_or_default();
let text = if !value.lyrics_text.trim().is_empty() {
Some(value.lyrics_text)
} else if !lyrics.is_empty() {
Some(format_pax_content("Syllable", lyrics, multi_person, true))
} else if !content.is_empty() {
Some(format_pax_content(
if value.kind.is_empty() {
"Syllable"
} else {
&value.kind
},
content,
multi_person,
true,
))
} else if !value.plain_lyrics.trim().is_empty() {
Some(value.plain_lyrics)
} else {
None
};
if let Some(text) = text {
return Ok(LyricsResponse::from_text(&text, provider, provider));
}
}
let raw = raw.trim();
if !raw.is_empty() && !raw.starts_with(['{', '[']) {
return Ok(LyricsResponse::from_text(raw, provider, provider));
}
if json::decode::<Box<RawValue>>(raw).is_ok() {
return Err(format!(
"{provider} returned a response without usable lyrics"
));
}
Err(format!("failed to decode {provider} lyrics response"))
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KpoeSyllable {
pub text: String,
pub time: f64,
pub duration: f64,
pub is_background: bool,
}
json::go_deserialize!(KpoeSyllable {
"text" => text, "time" => time, "duration" => duration, "isbackground" => is_background,
});
#[derive(Clone, Debug, Default, Serialize)]
pub struct KpoeLine {
pub time: f64,
pub duration: f64,
pub text: String,
pub syllabus: Option<Vec<KpoeSyllable>>,
}
json::go_deserialize!(KpoeLine {
"time" => time, "duration" => duration, "text" => text, "syllabus" => syllabus,
});
#[derive(Clone, Debug, Default, Serialize)]
pub struct KpoeResponse {
#[serde(rename = "type")]
pub kind: String,
pub lyrics: Option<Vec<KpoeLine>>,
}
json::go_deserialize!(KpoeResponse { "type" => kind, "lyrics" => lyrics, });
pub fn format_kpoe(response: &KpoeResponse, multi_person: bool, word_timing: bool) -> String {
let words = matches!(
json::field_name(&response.kind).as_str(),
"word" | "syllable"
);
let mut lines = Vec::new();
let append = |details: &[&KpoeSyllable]| {
let mut result = String::new();
for detail in details {
result.push_str(&format!(
"<{}>{}",
lrc::timestamp_inline(detail.time as i64),
detail.text
));
}
result
};
for line in response.lyrics.as_deref().unwrap_or_default() {
let syllabus = line.syllabus.as_deref().unwrap_or_default();
let timestamp = lrc::timestamp(line.time as i64);
if words && word_timing && !syllabus.is_empty() {
let (mut main, mut background): (Vec<_>, Vec<_>) =
syllabus.iter().partition(|detail| !detail.is_background);
if main.is_empty() {
main = syllabus.iter().collect();
background.clear();
}
let mut output = format!("{timestamp}{}", append(&main));
if multi_person && !background.is_empty() {
output.push_str(&format!("\n[bg:{}]", append(&background)));
}
lines.push(output);
} else {
let text = if line.text.trim().is_empty() && !syllabus.is_empty() {
syllabus
.iter()
.map(|detail| detail.text.as_str())
.collect::<String>()
} else {
line.text.clone()
};
if !text.trim().is_empty() {
lines.push(format!("{timestamp}{}", text.trim()));
}
}
}
lines.join("\n").trim().into()
}
+147
View File
@@ -0,0 +1,147 @@
//! Matching compatibility uses Go's simple case mapping and UTF-8 byte distance.
/// Unlike String::to_lowercase, Go's unicode.ToLower maps one rune to one rune.
pub fn lowercase(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
// Go 1.26 uses Unicode 15.0; these lowercase mappings were added
// in Unicode 16/17, used by the pinned Rust toolchain.
'\u{1c89}'
| '\u{a7cb}'
| '\u{a7cc}'
| '\u{a7ce}'
| '\u{a7d2}'
| '\u{a7d4}'
| '\u{a7da}'
| '\u{a7dc}'
| '\u{10d50}'..='\u{10d65}'
| '\u{16ea0}'..='\u{16eb8}' => ch,
_ => ch.to_lowercase().next().expect("lowercase character"),
})
.collect()
}
/// Go's Unicode 15 simple uppercase does not expand characters such as ß.
pub fn uppercase(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
'\u{019b}'
| '\u{0264}'
| '\u{1c8a}'
| '\u{a7cd}'
| '\u{a7cf}'
| '\u{a7d3}'
| '\u{a7d5}'
| '\u{a7db}'
| '\u{10d70}'..='\u{10d85}'
| '\u{16ebb}'..='\u{16ed3}' => ch,
// Full uppercase expands these Greek letters; simple uppercase uses
// the corresponding single precomposed capital instead.
'\u{1f80}'..='\u{1f87}' | '\u{1f90}'..='\u{1f97}' | '\u{1fa0}'..='\u{1fa7}' => {
char::from_u32(ch as u32 + 8).expect("Greek capital")
}
'\u{1fb3}' => '\u{1fbc}',
'\u{1fc3}' => '\u{1fcc}',
'\u{1ff3}' => '\u{1ffc}',
_ => {
let mut mapped = ch.to_uppercase();
let first = mapped.next().expect("uppercase character");
if mapped.next().is_none() { first } else { ch }
}
})
.collect()
}
pub fn normalize(value: &str) -> String {
let mut value = lowercase(value);
for suffix in [
" (remastered)",
" (remaster)",
" - remastered",
" - remaster",
" (deluxe)",
" (deluxe edition)",
" - deluxe",
" - deluxe edition",
" (explicit)",
" (clean)",
" [explicit]",
" [clean]",
" (album version)",
" (single version)",
" (radio edit)",
" (feat.",
" (ft.",
" feat.",
" ft.",
] {
if let Some(index) = value.find(suffix) {
value.truncate(index);
}
}
let filtered: String = value
.chars()
.filter(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || *ch == ' ')
.collect();
filtered.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn compare_strings(
first: &str,
second: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<f64, String> {
check()?;
let first = lowercase(first.trim());
let second = lowercase(second.trim());
if first == second {
return Ok(1.0);
}
let maximum = first.len().max(second.len());
if first.is_empty() || second.is_empty() {
return Ok(0.0);
}
let mut first = first.as_bytes();
let mut second = second.as_bytes();
let prefix = first.iter().zip(second).take_while(|(a, b)| a == b).count();
first = &first[prefix..];
second = &second[prefix..];
let suffix = first
.iter()
.rev()
.zip(second.iter().rev())
.take_while(|(a, b)| a == b)
.count();
first = &first[..first.len() - suffix];
second = &second[..second.len() - suffix];
if first.len() < second.len() {
std::mem::swap(&mut first, &mut second);
}
let mut row: Vec<_> = (0..=second.len()).collect();
for (i, a) in first.iter().enumerate() {
check()?;
let mut diagonal = row[0];
row[0] = i + 1;
for (j, b) in second.iter().enumerate() {
if j % 4096 == 0 {
check()?;
}
let old = row[j + 1];
row[j + 1] = (old + 1)
.min(row[j] + 1)
.min(diagonal + usize::from(a != b));
diagonal = old;
}
}
Ok(1.0 - row[second.len()] as f64 / maximum as f64)
}
/// Go's `int` is native-width and arithmetic wraps, including abs(MIN).
pub fn compare_duration(first: i64, second: i64, tolerance: i64) -> bool {
let difference = (first as isize)
.wrapping_sub(second as isize)
.wrapping_abs();
difference <= tolerance as isize
}
+292
View File
@@ -0,0 +1,292 @@
//! Quality probes used by the file metadata export. Callers supply a checked
//! reader; large audio payloads are skipped instead of buffered.
use super::AudioQuality;
use crate::tags::{ogg_packets, ogg_stream_is_opus};
use std::io::{BufReader, Read, Seek, SeekFrom};
pub(crate) fn mp3_quality(
file: &mut (impl Read + Seek),
size: i64,
) -> Result<AudioQuality, String> {
let mut quality = AudioQuality::default();
let header = read::<10>(file)?;
let start = if &header[..3] == b"ID3" {
10 + ((i64::from(header[6]) << 21)
| (i64::from(header[7]) << 14)
| (i64::from(header[8]) << 7)
| i64::from(header[9]))
} else {
0
};
seek(file, SeekFrom::Start(start as u64))?;
let Ok(header) = read::<4>(file) else {
return Ok(quality);
};
let frame = if header[0] == 0xff && header[1] & 0xe0 == 0xe0 {
Some((header, seek(file, SeekFrom::Current(0))? - 4))
} else {
// Allocate only for resynchronization; normal MP3s need no read-ahead.
let mut buffered = BufReader::new(&mut *file);
let mut frame = None;
for _ in 1..10000 {
buffered
.seek_relative(-3)
.map_err(|error| error.to_string())?;
let Ok(header) = read::<4>(&mut buffered) else {
break;
};
if header[0] == 0xff && header[1] & 0xe0 == 0xe0 {
frame = Some((header, seek(&mut buffered, SeekFrom::Current(0))? - 4));
break;
}
}
frame
};
let Some((header, frame_start)) = frame else {
return Ok(quality);
};
let version = (header[1] >> 3) & 3;
let layer = (header[1] >> 1) & 3;
let bitrate_index = (header[2] >> 4) as usize;
let rate_index = ((header[2] >> 2) & 3) as usize;
let mono = (header[3] >> 6) == 3;
let rates = [
[11025, 12000, 8000],
[0, 0, 0],
[22050, 24000, 16000],
[44100, 48000, 32000],
];
if rate_index < 3 {
quality.sample_rate = rates[version as usize][rate_index];
}
if layer == 1 {
let rates = if version == 3 {
[
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0,
]
} else if version == 0 || version == 2 {
[
0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0,
]
} else {
[0; 16]
};
quality.bitrate = rates[bitrate_index] * 1000;
}
let samples_per_frame = if version == 0 || version == 2 {
576
} else {
1152
};
let xing_offset = match (version == 3, mono) {
(true, false) => 32,
(false, true) => 9,
_ => 17,
};
seek(file, SeekFrom::Start(frame_start + 4))?;
let mut xing = [0; 200];
let mut length = 0;
while length < xing.len() {
match file.read(&mut xing[length..]) {
Ok(0) | Err(_) => break,
Ok(n) => length += n,
}
}
let mut frames = 0;
let mut bytes = 0;
if xing_offset + 8 <= length && matches!(&xing[xing_offset..xing_offset + 4], b"Xing" | b"Info")
{
let flags = be32(&xing[xing_offset + 4..xing_offset + 8]);
let mut offset = xing_offset + 8;
if flags & 1 != 0 && offset + 4 <= length {
frames = be32(&xing[offset..offset + 4]);
offset += 4;
}
if flags & 2 != 0 && offset + 4 <= length {
bytes = be32(&xing[offset..offset + 4]);
}
}
if frames == 0 && length >= 62 && &xing[32..36] == b"VBRI" {
bytes = be32(&xing[42..46]);
frames = be32(&xing[46..50]);
}
if frames > 0 && quality.sample_rate > 0 {
quality.duration = frames * samples_per_frame / quality.sample_rate;
if quality.duration > 0 {
quality.bitrate = if bytes > 0 { bytes } else { size - start } * 8 / quality.duration;
}
} else if quality.bitrate > 0 && size - start - 128 > 0 {
quality.duration = (size - start - 128) * 8 / quality.bitrate;
}
Ok(quality)
}
pub(crate) fn ogg_quality(
file: &mut (impl Read + Seek),
size: i64,
path: &str,
) -> Result<AudioQuality, String> {
let packets = ogg_packets(file, 5, 10)?;
let opus =
ogg_stream_is_opus(&packets).unwrap_or_else(|| path.to_lowercase().ends_with(".opus"));
let mut quality = AudioQuality::default();
let mut pre_skip = 0;
for packet in packets {
if opus && packet.len() >= 19 && packet.starts_with(b"OpusHead") {
quality.sample_rate = le32(&packet[12..16]);
if quality.sample_rate == 0 {
quality.sample_rate = 48000;
}
pre_skip = i64::from(u16::from_le_bytes(packet[10..12].try_into().unwrap()));
break;
}
if !opus && packet.len() > 29 && packet.starts_with(b"\x01vorbis") {
quality.sample_rate = le32(&packet[12..16]);
break;
}
}
let length = size.clamp(0, 65536) as usize;
seek(file, SeekFrom::Start((size - length as i64) as u64))?;
let mut tail = vec![0; length];
file.read_exact(&mut tail).map_err(|e| e.to_string())?;
let granule = (0..length.saturating_sub(3))
.rev()
.find_map(|offset| {
let data = &tail[offset..];
if data.len() < 27 || &data[..4] != b"OggS" || data[4] != 0 || data[5] > 7 {
return None;
}
let header_length = 27 + data[26] as usize;
if data.len() < header_length {
return None;
}
let payload: usize = data[27..header_length].iter().map(|v| *v as usize).sum();
(data.len() >= header_length + payload)
.then(|| u64::from_le_bytes(data[6..14].try_into().unwrap()) as i64)
})
.unwrap_or(0);
let seconds = if granule <= 0 {
0.0
} else if opus {
(granule - pre_skip) as f64 / 48000.0
} else if quality.sample_rate > 0 {
granule as f64 / quality.sample_rate as f64
} else {
0.0
};
if seconds > 0.0 {
quality.duration = seconds.round() as i64;
quality.bitrate = (size as f64 * 8.0 / seconds) as i64;
}
if quality.bitrate <= 0 && quality.duration > 0 {
quality.bitrate = size * 8 / quality.duration;
}
if quality.duration > 86400 {
quality.duration = 0;
quality.bitrate = 0;
}
if quality.bitrate > 0 && quality.bitrate < 8000 {
quality.bitrate = 0;
}
Ok(quality)
}
pub(crate) fn riff_quality(
file: &mut (impl Read + Seek),
size: i64,
aiff: bool,
) -> Result<AudioQuality, String> {
let header = read::<12>(file)?;
if if aiff {
&header[..4] != b"FORM" || !matches!(&header[8..], b"AIFF" | b"AIFC")
} else {
&header[..4] != b"RIFF" || &header[8..] != b"WAVE"
} {
return Err("invalid audio container".into());
}
let mut quality = AudioQuality::default();
let (mut channels, mut byte_rate, mut data_size, mut frames) = (0, 0, 0, 0);
while let Ok(header) = read::<8>(file) {
let length = if aiff {
be32(&header[4..])
} else {
le32(&header[4..])
};
let start = seek(file, SeekFrom::Current(0))?;
if (!aiff && &header[..4] == b"fmt ") || (aiff && &header[..4] == b"COMM") {
// Go returns the previous probe on a truncated format chunk.
if start + length as u64 > size as u64 {
break;
}
let mut data = [0; 26];
let count = length.min(data.len() as i64) as usize;
if file.read_exact(&mut data[..count]).is_err() {
break;
}
if aiff && length >= 18 {
channels = i64::from(u16::from_be_bytes(data[..2].try_into().unwrap()));
frames = be32(&data[2..6]);
quality.bit_depth = i64::from(u16::from_be_bytes(data[6..8].try_into().unwrap()));
let exponent = (i32::from(data[8] & 0x7f) << 8) | i32::from(data[9]);
let mantissa = u64::from_be_bytes(data[10..18].try_into().unwrap());
let sign = if data[8] & 0x80 == 0 { 1.0 } else { -1.0 };
quality.sample_rate =
(sign * mantissa as f64 * 2.0_f64.powi(exponent - 16446) + 0.5) as i64;
} else if !aiff && length >= 16 {
channels = i64::from(u16::from_le_bytes(data[2..4].try_into().unwrap()));
quality.sample_rate = le32(&data[4..8]);
byte_rate = le32(&data[8..12]);
quality.bit_depth = i64::from(u16::from_le_bytes(data[14..16].try_into().unwrap()));
if data[..2] == [0xfe, 0xff] && length >= 26 {
let valid_bits =
i64::from(u16::from_le_bytes(data[18..20].try_into().unwrap()));
if valid_bits > 0 {
quality.bit_depth = valid_bits;
}
}
}
} else if !aiff && &header[..4] == b"data" {
data_size = length;
}
seek(
file,
SeekFrom::Start(start + length as u64 + (length & 1) as u64),
)?;
}
if aiff {
if quality.sample_rate > 0 && frames > 0 {
quality.duration = frames / quality.sample_rate;
}
} else if byte_rate > 0 && data_size > 0 {
quality.duration = data_size / byte_rate;
} else if quality.sample_rate > 0 && channels > 0 && quality.bit_depth > 0 && data_size > 0 {
let bytes_per_second = quality
.sample_rate
.wrapping_mul(channels)
.wrapping_mul(quality.bit_depth)
/ 8;
if bytes_per_second > 0 {
quality.duration = data_size / bytes_per_second;
}
}
Ok(quality)
}
fn read<const N: usize>(file: &mut impl Read) -> Result<[u8; N], String> {
let mut data = [0; N];
file.read_exact(&mut data).map_err(|e| e.to_string())?;
Ok(data)
}
fn seek(file: &mut impl Seek, position: SeekFrom) -> Result<u64, String> {
file.seek(position).map_err(|e| e.to_string())
}
fn be32(data: &[u8]) -> i64 {
i64::from(u32::from_be_bytes(data.try_into().unwrap()))
}
fn le32(data: &[u8]) -> i64 {
i64::from(u32::from_le_bytes(data.try_into().unwrap()))
}
+95
View File
@@ -0,0 +1,95 @@
//! Bounded, seek-based implementation of Go's FLAC/MP4 quality probe.
mod audio;
pub(crate) mod mp4;
pub(crate) use audio::{mp3_quality, ogg_quality, riff_quality};
use serde::Serialize;
use std::io::{Read, Seek, SeekFrom};
#[derive(Debug, Default, PartialEq, Serialize)]
pub struct AudioQuality {
pub bit_depth: i64,
pub sample_rate: i64,
pub total_samples: i64,
pub duration: i64,
#[serde(skip_serializing_if = "is_zero")]
pub bitrate: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub codec: String,
}
fn is_zero(value: &i64) -> bool {
*value == 0
}
pub fn probe_quality(
file: &mut (impl Read + Seek),
check: &dyn Fn() -> Result<(), String>,
) -> Result<AudioQuality, String> {
check()?;
file.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?;
let marker = read_go::<4>(file).map_err(|e| format!("failed to read marker: {e}"))?;
if &marker == b"fLaC" {
let header = read_go::<4>(file).map_err(|e| format!("failed to read header: {e}"))?;
if header[0] & 0x7f != 0 {
return Err("first block is not STREAMINFO".into());
}
let info = read_go::<34>(file).map_err(|e| format!("failed to read STREAMINFO: {e}"))?;
return Ok(flac_quality(&info));
}
file.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?;
let header = read_go::<8>(file).map_err(|e| format!("failed to read header: {e}"))?;
if &header[4..] == b"ftyp" {
return probe_mp4_quality(file, check);
}
Err("unsupported file format (not FLAC or M4A)".into())
}
/// MP4-family descriptor paths may lack an ftyp box, as in Go's GetM4AQuality.
pub fn probe_mp4_quality(
file: &mut (impl Read + Seek),
check: &dyn Fn() -> Result<(), String>,
) -> Result<AudioQuality, String> {
mp4::probe(file, check)
}
fn flac_quality(info: &[u8; 34]) -> AudioQuality {
let packed = u64::from_be_bytes(info[10..18].try_into().expect("STREAMINFO fields"));
let rate = (packed >> 44) as i64;
let samples = (packed & ((1 << 36) - 1)) as i64;
AudioQuality {
bit_depth: ((packed >> 36) & 31) as i64 + 1,
sample_rate: rate,
total_samples: samples,
duration: if rate > 0 { samples / rate } else { 0 },
codec: "flac".into(),
..AudioQuality::default()
}
}
// Go's marker/STREAMINFO reader accepts a short successful Read and leaves the
// rest of its zero-initialized buffer untouched. Preserve that legacy behavior.
fn read_go<const N: usize>(file: &mut impl Read) -> Result<[u8; N], String> {
let mut bytes = [0; N];
match file.read(&mut bytes) {
Ok(0) => Err("EOF".into()),
Ok(_) => Ok(bytes),
Err(error) => Err(error.to_string()),
}
}
fn read_at<const N: usize>(file: &mut (impl Read + Seek), offset: u64) -> Result<[u8; N], String> {
file.seek(SeekFrom::Start(offset))
.map_err(|e| e.to_string())?;
let mut bytes = [0; N];
file.read_exact(&mut bytes).map_err(|e| {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
"EOF".into()
} else {
e.to_string()
}
})?;
Ok(bytes)
}
+271
View File
@@ -0,0 +1,271 @@
use super::{AudioQuality, flac_quality, read_at};
use std::io::{Read, Seek, SeekFrom};
pub(crate) struct Reader<'a, R> {
pub(crate) file: &'a mut R,
pub(crate) size: u64,
pub(crate) check: &'a dyn Fn() -> Result<(), String>,
}
#[derive(Clone, Copy)]
pub(crate) struct Atom {
pub(crate) start: u64,
pub(crate) end: u64,
pub(crate) payload: u64,
pub(crate) kind: [u8; 4],
}
pub(super) fn probe(
file: &mut (impl Read + Seek),
check: &dyn Fn() -> Result<(), String>,
) -> Result<AudioQuality, String> {
let size = file.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?;
let mut reader = Reader { file, size, check };
let moov = reader
.find(0, size, b"moov")
.map_err(|e| format!("failed to find moov atom: {e}"))?
.ok_or("moov atom not found")?;
let mut duration = reader
.find(moov.payload, moov.end, b"mvhd")
.ok()
.flatten()
.map_or(0, |atom| reader.duration(atom).unwrap_or(0));
if duration <= 0 {
let _ = reader.track_duration(moov.payload, moov.end, 0, &mut duration);
}
let (offset, kind) = reader.sample_entry(moov)?;
let bytes = read_at::<32>(reader.file, offset)
.map_err(|e| format!("failed to read audio sample entry: {e}"))?;
let mut rate = u16::from_be_bytes(bytes[28..30].try_into().unwrap()) as i64;
let mut depth = 0;
if &kind == b"alac" || &kind == b"fLaC" {
depth = u16::from_be_bytes(bytes[22..24].try_into().unwrap()) as i64;
if let Some((bits, hz, samples)) = reader.specific_config(offset, &kind) {
if bits > 0 {
depth = bits;
}
if hz > 0 {
rate = hz;
}
if samples > 0 && rate > 0 && duration <= 0 {
duration = samples / rate;
}
}
}
(reader.check)()?;
let bitrate = if duration > 0 {
(size as f64 * 8.0 / duration as f64 / 1000.0).round() as i64
} else {
0
};
Ok(AudioQuality {
bit_depth: depth,
sample_rate: rate,
duration,
bitrate: if bitrate < 16 { 0 } else { bitrate },
codec: match &kind {
b"mp4a" => "aac",
b"alac" => "alac",
b"fLaC" => "flac",
b"ec-3" => "eac3",
b"ac-3" => "ac3",
b"ac-4" => "ac4",
b"Opus" => "opus",
_ => unreachable!("recognized sample entry"),
}
.into(),
// Go leaves total_samples zero for MP4, even when dfLa contains it.
total_samples: 0,
})
}
impl<R: Read + Seek> Reader<'_, R> {
pub(crate) fn atom(&mut self, offset: u64, end: u64) -> Result<Atom, String> {
(self.check)()?;
if offset.checked_add(8).is_none_or(|value| value > self.size) {
return Err("unexpected EOF".into());
}
let bytes = read_at::<8>(self.file, offset)?;
let kind: [u8; 4] = bytes[4..8].try_into().unwrap();
let mut size = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as u64;
let mut header = 8;
if size == 1 {
if offset.checked_add(16).is_none_or(|value| value > self.size) {
return Err("unexpected EOF".into());
}
size = u64::from_be_bytes(read_at::<8>(self.file, offset + 8)?);
header = 16;
} else if size == 0 {
size = end.saturating_sub(offset);
}
if size < header || offset.checked_add(size).is_none() || size > i64::MAX as u64 {
return Err(format!(
"invalid atom size for {}",
String::from_utf8_lossy(&kind)
));
}
Ok(Atom {
start: offset,
end: offset + size,
payload: offset + header,
kind,
})
}
fn find(&mut self, mut start: u64, end: u64, kind: &[u8; 4]) -> Result<Option<Atom>, String> {
while start.checked_add(8).is_some_and(|value| value <= end) {
let atom = self.atom(start, end)?;
if &atom.kind == kind {
return Ok(Some(atom));
}
start = atom.end;
}
Ok(None)
}
fn duration(&mut self, atom: Atom) -> Result<i64, String> {
let version = read_at::<1>(self.file, atom.payload)?[0];
let (scale, duration) = if version == 1 {
let bytes = read_at::<32>(self.file, atom.payload)?;
(
u32::from_be_bytes(bytes[20..24].try_into().unwrap()),
u64::from_be_bytes(bytes[24..32].try_into().unwrap()),
)
} else {
let bytes = read_at::<20>(self.file, atom.payload)?;
(
u32::from_be_bytes(bytes[12..16].try_into().unwrap()),
u32::from_be_bytes(bytes[16..20].try_into().unwrap()) as u64,
)
};
Ok(if scale > 0 {
(duration as f64 / scale as f64).round() as i64
} else {
0
})
}
fn track_duration(
&mut self,
mut start: u64,
end: u64,
depth: usize,
best: &mut i64,
) -> Result<(), String> {
if depth >= 32 {
return Err("MP4 nesting exceeds 32 levels".into());
}
while start.checked_add(8).is_some_and(|value| value <= end) {
let atom = self.atom(start, end)?;
match &atom.kind {
b"mdhd" => *best = (*best).max(self.duration(atom).unwrap_or(0)),
b"trak" | b"mdia" => {
self.track_duration(atom.payload, atom.end, depth + 1, best)?
}
_ => {}
}
start = atom.end;
}
Ok(())
}
// Match Go's earliest recognized byte pattern, including chunk boundaries.
// Do not load moov/mdat in full: some files put large covers in moov.
fn sample_entry(&mut self, atom: Atom) -> Result<(u64, [u8; 4]), String> {
let mut offset = atom.start;
let mut data = vec![0; 65536 + 3];
let mut tail = 0;
while offset < atom.end {
(self.check)()?;
let length = (atom.end - offset).min(65536) as usize;
self.file
.seek(SeekFrom::Start(offset))
.map_err(|e| e.to_string())?;
let n = self
.file
.read(&mut data[tail..tail + length])
.map_err(|e| format!("failed to read M4A atom data: {e}"))?;
if n == 0 {
break;
}
for (index, bytes) in data[..tail + n].windows(4).enumerate() {
if matches!(
bytes,
b"mp4a" | b"alac" | b"fLaC" | b"ec-3" | b"ac-3" | b"ac-4" | b"Opus"
) {
let absolute = offset - tail as u64 + index as u64;
if absolute
.checked_add(32)
.is_none_or(|value| value > self.size)
{
return Err("audio info not found in M4A file".into());
}
return Ok((absolute, bytes.try_into().unwrap()));
}
}
let end = tail + n;
tail = end.min(3);
data.copy_within(end - tail..end, 0);
offset += n as u64;
}
Err("audio info not found in M4A file".into())
}
fn specific_config(&mut self, offset: u64, kind: &[u8; 4]) -> Option<(i64, i64, i64)> {
let entry = self.atom(offset.checked_sub(4)?, self.size).ok()?;
let config = self
.find(
offset + 32,
entry.end,
if kind == b"alac" { b"alac" } else { b"dfLa" },
)
.ok()??;
let size = config.end.saturating_sub(config.payload);
// Go reads the entire config. Only these fixed fields are needed, and
// malformed size declarations must not cause file-sized allocations.
if config.end > self.size {
return None;
}
if kind == b"alac" {
if size < 24 {
return None;
}
let bytes = read_at::<24>(self.file, config.payload).ok()?;
let bits = bytes[5] as i64;
let rate = u32::from_be_bytes(bytes[20..24].try_into().unwrap()) as i64;
if bits > 0 && rate > 0 {
return Some((bits, rate, 0));
}
if size < 28 {
return None;
}
let bytes = read_at::<24>(self.file, config.payload + 4).ok()?;
let bits = bytes[5] as i64;
let rate = u32::from_be_bytes(bytes[20..24].try_into().unwrap()) as i64;
return (bits > 0 && rate > 0).then_some((bits, rate, 0));
}
let mut position = config.payload + 4;
while position
.checked_add(4)
.is_some_and(|value| value <= config.end)
{
(self.check)().ok()?;
let bytes = read_at::<4>(self.file, position).ok()?;
let length = u32::from_be_bytes([0, bytes[1], bytes[2], bytes[3]]) as u64;
let next = position.checked_add(4 + length)?;
if next > config.end {
return None;
}
if bytes[0] & 0x7f == 0 && length >= 34 {
let quality = flac_quality(&read_at::<34>(self.file, position + 4).ok()?);
return Some((
quality.bit_depth,
quality.sample_rate,
quality.total_samples,
));
}
position = next;
}
None
}
}
+47
View File
@@ -0,0 +1,47 @@
//! Application metadata response schemas and built-in provider conversion.
mod catalog;
pub mod deezer;
pub mod musicbrainz;
pub mod reenrich;
pub mod share;
pub use catalog::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TrackMetadata {
#[serde(skip_serializing_if = "String::is_empty")]
pub spotify_id: String,
pub artists: String,
pub name: String,
pub album_name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub album_artist: String,
pub duration_ms: isize,
pub images: String,
pub release_date: String,
pub track_number: isize,
#[serde(skip_serializing_if = "zero")]
pub total_tracks: isize,
#[serde(skip_serializing_if = "zero")]
pub disc_number: isize,
#[serde(skip_serializing_if = "zero")]
pub total_discs: isize,
pub external_urls: String,
pub isrc: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub album_id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub artist_id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub album_type: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub composer: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub explicit: bool,
}
fn zero(value: &isize) -> bool {
*value == 0
}
@@ -0,0 +1,164 @@
use super::{TrackMetadata, zero};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AlbumTrackMetadata {
#[serde(skip_serializing_if = "String::is_empty")]
pub spotify_id: String,
pub artists: String,
pub name: String,
pub album_name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub album_artist: String,
pub duration_ms: isize,
pub images: String,
pub release_date: String,
pub track_number: isize,
#[serde(skip_serializing_if = "zero")]
pub total_tracks: isize,
#[serde(skip_serializing_if = "zero")]
pub disc_number: isize,
#[serde(skip_serializing_if = "zero")]
pub total_discs: isize,
pub external_urls: String,
pub isrc: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub album_id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub album_url: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub album_type: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub composer: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub explicit: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AlbumInfoMetadata {
pub total_tracks: isize,
pub name: String,
pub release_date: String,
pub artists: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub artist_id: String,
pub images: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub genre: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub label: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub copyright: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AlbumResponsePayload {
pub album_info: AlbumInfoMetadata,
pub track_list: Vec<AlbumTrackMetadata>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PlaylistTrackCount {
pub total: isize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PlaylistOwner {
pub display_name: String,
pub name: String,
pub images: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PlaylistInfoMetadata {
#[serde(skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub images: String,
pub tracks: PlaylistTrackCount,
pub owner: PlaylistOwner,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PlaylistResponsePayload {
pub playlist_info: PlaylistInfoMetadata,
pub track_list: Vec<AlbumTrackMetadata>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ArtistInfoMetadata {
pub id: String,
pub name: String,
pub images: String,
pub followers: isize,
pub popularity: isize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ArtistAlbumMetadata {
pub id: String,
pub name: String,
pub release_date: String,
pub total_tracks: isize,
pub images: String,
pub album_type: String,
pub artists: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ArtistResponsePayload {
pub artist_info: ArtistInfoMetadata,
pub albums: Vec<ArtistAlbumMetadata>,
}
pub type SearchArtistResult = ArtistInfoMetadata;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchAlbumResult {
pub id: String,
pub name: String,
pub artists: String,
pub images: String,
pub release_date: String,
pub total_tracks: isize,
pub album_type: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchPlaylistResult {
pub id: String,
pub name: String,
pub owner: String,
pub images: String,
pub total_tracks: isize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchAllResult {
pub tracks: Vec<TrackMetadata>,
pub artists: Vec<SearchArtistResult>,
pub albums: Vec<SearchAlbumResult>,
pub playlists: Vec<SearchPlaylistResult>,
}
// Go's internal extended-metadata response has exported, untagged fields.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "PascalCase")]
pub struct AlbumExtendedMetadata {
pub genre: String,
pub label: String,
pub copyright: String,
}
@@ -0,0 +1,146 @@
use super::TrackMetadata;
use crate::lyrics::json::go_deserialize;
mod catalog;
pub use catalog::*;
#[derive(Clone, Debug, Default)]
pub struct Track {
pub id: i64,
pub title: String,
pub duration: isize,
pub track_position: isize,
pub disk_number: isize,
pub isrc: String,
pub link: String,
pub release_date: String,
pub explicit_lyrics: bool,
pub explicit_content_lyrics: isize,
pub artist: Artist,
pub album: Album,
pub contributors: Option<Vec<Artist>>,
}
go_deserialize!(Track {
"id" => id, "title" => title, "duration" => duration, "track_position" => track_position,
"disk_number" => disk_number, "isrc" => isrc, "link" => link, "release_date" => release_date,
"explicit_lyrics" => explicit_lyrics, "explicit_content_lyrics" => explicit_content_lyrics,
"artist" => artist, "album" => album, "contributors" => contributors,
});
#[derive(Clone, Debug, Default)]
pub struct Artist {
pub id: i64,
pub name: String,
pub picture: String,
pub picture_medium: String,
pub picture_big: String,
pub picture_xl: String,
pub nb_fan: isize,
}
go_deserialize!(Artist {
"id" => id, "name" => name, "picture" => picture, "picture_medium" => picture_medium,
"picture_big" => picture_big, "picture_xl" => picture_xl, "nb_fan" => nb_fan,
});
#[derive(Clone, Debug, Default)]
pub struct Album {
pub id: i64,
pub title: String,
pub cover: String,
pub cover_medium: String,
pub cover_big: String,
pub cover_xl: String,
pub release_date: String,
pub record_type: String,
}
go_deserialize!(Album {
"id" => id, "title" => title, "cover" => cover, "cover_medium" => cover_medium,
"cover_big" => cover_big, "cover_xl" => cover_xl, "release_date" => release_date, "record_type" => record_type,
});
#[derive(Clone, Debug, Default)]
pub struct Tracks {
pub data: Option<Vec<Track>>,
}
go_deserialize!(Tracks { "data" => data, });
impl Track {
pub fn artist_display(&self) -> String {
match self.contributors.as_deref() {
Some(artists) if !artists.is_empty() => artists
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", "),
_ => self.artist.name.clone(),
}
}
pub fn is_explicit(&self) -> bool {
self.explicit_lyrics || self.explicit_content_lyrics == 1
}
pub fn metadata(&self) -> TrackMetadata {
TrackMetadata {
spotify_id: format!("deezer:{}", self.id),
artists: self.artist_display(),
name: self.title.clone(),
album_name: self.album.title.clone(),
album_artist: self.artist.name.clone(),
duration_ms: self.duration.wrapping_mul(1000),
images: self.album.image(),
release_date: if self.release_date.is_empty() {
self.album.release_date.clone()
} else {
self.release_date.clone()
},
track_number: self.track_position,
disc_number: self.disk_number,
external_urls: self.link.clone(),
isrc: self.isrc.clone(),
album_id: format!("deezer:{}", self.album.id),
artist_id: format!("deezer:{}", self.artist.id),
explicit: self.is_explicit(),
..TrackMetadata::default()
}
}
}
pub fn best_image<'a>(images: impl IntoIterator<Item = &'a str>) -> String {
images
.into_iter()
.find(|image| !image.is_empty())
.unwrap_or_default()
.into()
}
pub fn album_type(record_type: &str) -> String {
if record_type == "compile" {
"compilation"
} else {
record_type
}
.into()
}
impl Album {
pub fn image(&self) -> String {
best_image([
self.cover_xl.as_str(),
&self.cover_big,
&self.cover_medium,
&self.cover,
])
}
}
impl Artist {
pub fn image(&self) -> String {
best_image([
self.picture_xl.as_str(),
&self.picture_big,
&self.picture_medium,
&self.picture,
])
}
}
@@ -0,0 +1,241 @@
use super::{Artist, Track, Tracks, best_image};
use crate::lyrics::json::{Update, go_deserialize};
use serde::Deserialize;
#[derive(Clone, Debug, Default)]
pub struct ApiError {
pub error_type: String,
pub message: String,
pub code: isize,
}
go_deserialize!(ApiError { "type" => error_type, "message" => message, "code" => code, });
/// A repeated non-null Go pointer field merges into the existing object.
#[derive(Clone, Debug, Default)]
pub struct OptionalApiError(pub Option<ApiError>);
impl<'de> Update<'de> for OptionalApiError {
fn update<D: serde::Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error> {
match Option::<&serde_json::value::RawValue>::deserialize(decoder)? {
None => self.0 = None,
Some(raw) => {
self.0
.get_or_insert_default()
.update(&mut serde_json::Deserializer::from_str(raw.get()))
.map_err(serde::de::Error::custom)?;
}
}
Ok(())
}
}
macro_rules! search_page {
($name:ident, $item:ty) => {
#[derive(Clone, Debug, Default)]
pub struct $name {
pub data: Option<Vec<$item>>,
pub error: OptionalApiError,
}
go_deserialize!($name { "data" => data, "error" => error, });
};
}
search_page!(TrackSearch, Track);
search_page!(ArtistSearch, Artist);
search_page!(AlbumSearch, SearchAlbum);
search_page!(PlaylistSearch, SearchPlaylist);
#[derive(Clone, Debug, Default)]
pub struct TrackPage {
pub data: Option<Vec<Track>>,
pub next: String,
}
go_deserialize!(TrackPage { "data" => data, "next" => next, });
#[derive(Clone, Debug, Default)]
pub struct Genre {
pub id: isize,
pub name: String,
}
go_deserialize!(Genre { "id" => id, "name" => name, });
#[derive(Clone, Debug, Default)]
pub struct Genres {
pub data: Option<Vec<Genre>>,
}
go_deserialize!(Genres { "data" => data, });
impl Genres {
pub fn display(&self) -> String {
self.data
.iter()
.flatten()
.map(|genre| genre.name.as_str())
.filter(|name| !name.is_empty())
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Clone, Debug, Default)]
pub struct FullAlbum {
pub id: i64,
pub title: String,
pub cover: String,
pub cover_medium: String,
pub cover_big: String,
pub cover_xl: String,
pub release_date: String,
pub nb_tracks: isize,
pub record_type: String,
pub label: String,
pub copyright: String,
pub genres: Genres,
pub artist: Artist,
pub contributors: Option<Vec<Artist>>,
pub tracks: Tracks,
}
go_deserialize!(FullAlbum {
"id" => id, "title" => title, "cover" => cover, "cover_medium" => cover_medium,
"cover_big" => cover_big, "cover_xl" => cover_xl, "release_date" => release_date,
"nb_tracks" => nb_tracks, "record_type" => record_type, "label" => label,
"copyright" => copyright, "genres" => genres, "artist" => artist,
"contributors" => contributors, "tracks" => tracks,
});
#[derive(Clone, Debug, Default)]
pub struct FullArtist {
pub id: i64,
pub name: String,
pub picture: String,
pub picture_medium: String,
pub picture_big: String,
pub picture_xl: String,
pub nb_fan: isize,
pub nb_album: isize,
}
go_deserialize!(FullArtist {
"id" => id, "name" => name, "picture" => picture, "picture_medium" => picture_medium,
"picture_big" => picture_big, "picture_xl" => picture_xl, "nb_fan" => nb_fan, "nb_album" => nb_album,
});
#[derive(Clone, Debug, Default)]
pub struct Name {
pub name: String,
}
go_deserialize!(Name { "name" => name, });
#[derive(Clone, Debug, Default)]
pub struct FullPlaylist {
pub id: i64,
pub title: String,
pub picture: String,
pub picture_medium: String,
pub picture_big: String,
pub picture_xl: String,
pub nb_tracks: isize,
pub creator: Name,
pub tracks: Tracks,
}
go_deserialize!(FullPlaylist {
"id" => id, "title" => title, "picture" => picture, "picture_medium" => picture_medium,
"picture_big" => picture_big, "picture_xl" => picture_xl, "nb_tracks" => nb_tracks,
"creator" => creator, "tracks" => tracks,
});
#[derive(Clone, Debug, Default)]
pub struct SearchAlbum {
pub id: i64,
pub title: String,
pub cover: String,
pub cover_medium: String,
pub cover_big: String,
pub cover_xl: String,
pub nb_tracks: isize,
pub release_date: String,
pub record_type: String,
pub artist: Artist,
}
go_deserialize!(SearchAlbum {
"id" => id, "title" => title, "cover" => cover, "cover_medium" => cover_medium,
"cover_big" => cover_big, "cover_xl" => cover_xl, "nb_tracks" => nb_tracks,
"release_date" => release_date, "record_type" => record_type, "artist" => artist,
});
#[derive(Clone, Debug, Default)]
pub struct ArtistAlbum {
pub id: i64,
pub title: String,
pub release_date: String,
pub nb_tracks: isize,
pub cover: String,
pub cover_medium: String,
pub cover_big: String,
pub cover_xl: String,
pub record_type: String,
}
go_deserialize!(ArtistAlbum {
"id" => id, "title" => title, "release_date" => release_date, "nb_tracks" => nb_tracks,
"cover" => cover, "cover_medium" => cover_medium, "cover_big" => cover_big,
"cover_xl" => cover_xl, "record_type" => record_type,
});
#[derive(Clone, Debug, Default)]
pub struct ArtistAlbums {
pub data: Option<Vec<ArtistAlbum>>,
}
go_deserialize!(ArtistAlbums { "data" => data, });
#[derive(Clone, Debug, Default)]
pub struct AlbumTrackCount {
pub nb_tracks: isize,
}
go_deserialize!(AlbumTrackCount { "nb_tracks" => nb_tracks, });
#[derive(Clone, Debug, Default)]
pub struct SearchPlaylist {
pub id: i64,
pub title: String,
pub picture: String,
pub picture_medium: String,
pub picture_big: String,
pub picture_xl: String,
pub nb_tracks: isize,
pub user: Name,
}
go_deserialize!(SearchPlaylist {
"id" => id, "title" => title, "picture" => picture, "picture_medium" => picture_medium,
"picture_big" => picture_big, "picture_xl" => picture_xl, "nb_tracks" => nb_tracks, "user" => user,
});
macro_rules! cover_image {
($($name:ty),*) => {$(
impl $name {
pub fn image(&self) -> String {
best_image([self.cover_xl.as_str(), &self.cover_big, &self.cover_medium, &self.cover])
}
}
)*};
}
cover_image!(FullAlbum, SearchAlbum, ArtistAlbum);
impl FullArtist {
pub fn image(&self) -> String {
best_image([
self.picture_xl.as_str(),
&self.picture_big,
&self.picture_medium,
&self.picture,
])
}
}
impl SearchPlaylist {
pub fn image(&self) -> String {
best_image([
self.picture_xl.as_str(),
&self.picture_big,
&self.picture_medium,
&self.picture,
])
}
}
@@ -0,0 +1,117 @@
//! Recording metadata and formatting used by the native MusicBrainz getters.
mod case_data;
mod casing;
use crate::lyrics::json::go_deserialize;
use crate::matching::lowercase;
use std::collections::BTreeSet;
#[derive(Clone, Debug, Default)]
pub struct Tag {
pub count: isize,
pub name: String,
}
go_deserialize!(Tag { "count" => count, "name" => name, });
#[derive(Clone, Debug, Default)]
pub struct ArtistCredit {
pub name: String,
pub joinphrase: String,
}
go_deserialize!(ArtistCredit { "name" => name, "joinphrase" => joinphrase, });
#[derive(Clone, Debug, Default)]
pub struct Release {
pub title: String,
pub artist_credit: Option<Vec<ArtistCredit>>,
}
go_deserialize!(Release { "title" => title, "artist-credit" => artist_credit, });
#[derive(Clone, Debug, Default)]
pub struct Recording {
pub tags: Option<Vec<Tag>>,
pub releases: Option<Vec<Release>>,
}
go_deserialize!(Recording { "tags" => tags, "releases" => releases, });
#[derive(Clone, Debug, Default)]
pub struct Response {
pub recordings: Option<Vec<Recording>>,
}
go_deserialize!(Response { "recordings" => recordings, });
pub fn genre(tags: &[Tag]) -> String {
let mut seen = BTreeSet::new();
let mut max_count = -1;
let mut best = String::new();
for tag in tags {
let name = tag.name.trim();
if name.is_empty() || !seen.insert(lowercase(name)) {
continue;
}
if tag.count > max_count {
max_count = tag.count;
best = casing::title(name);
}
}
best
}
pub fn artist_credit(credits: &[ArtistCredit]) -> String {
let mut output = String::new();
for credit in credits {
let name = credit.name.trim();
if !name.is_empty() {
output.push_str(name);
output.push_str(&credit.joinphrase);
}
}
output.trim().into()
}
pub fn album_artist(releases: &[Release], album_name: &str) -> String {
let album = lowercase(album_name.trim());
if !album.is_empty() {
for release in releases {
if lowercase(release.title.trim()) == album {
let artist = artist_credit(release.artist_credit.as_deref().unwrap_or_default());
if !artist.is_empty() {
return artist;
}
}
}
}
releases
.iter()
.map(|release| artist_credit(release.artist_credit.as_deref().unwrap_or_default()))
.find(|artist| !artist.is_empty())
.unwrap_or_default()
}
impl Response {
pub fn genre(&self, isrc: &str) -> Result<String, String> {
let first = self
.recordings
.as_deref()
.unwrap_or_default()
.first()
.ok_or_else(|| format!("no recordings found for ISRC: {isrc}"))?;
let value = genre(first.tags.as_deref().unwrap_or_default());
if value.is_empty() {
Err(format!("no MusicBrainz genre tags found for ISRC: {isrc}"))
} else {
Ok(value)
}
}
pub fn album_artist(&self, isrc: &str, album: &str) -> Result<String, String> {
self.recordings
.as_deref()
.unwrap_or_default()
.iter()
.map(|recording| album_artist(recording.releases.as_deref().unwrap_or_default(), album))
.find(|artist| !artist.is_empty())
.ok_or_else(|| format!("no MusicBrainz album artist found for ISRC: {isrc}"))
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,93 @@
// English title-case control flow follows golang.org/x/text/cases (Go Authors).
// See rust_backend/NOTICE for the BSD license.
use super::case_data::{MAPPINGS, RANGES};
fn flags(ch: char) -> u8 {
let code = ch as u32;
let index = RANGES.partition_point(|(_, end, _)| *end < code);
RANGES
.get(index)
.filter(|(start, _, _)| *start <= code)
.map_or(4, |(_, _, flags)| *flags)
}
fn mapped(output: &mut String, ch: char, first: bool) {
if let Ok(index) = MAPPINGS.binary_search_by_key(&(ch as u32), |(code, _, _)| *code) {
let (_, title, lower) = MAPPINGS[index];
let mapping = if first { title } else { lower };
if !mapping.is_empty() {
output.push_str(mapping);
return;
}
}
output.push(ch);
}
pub(super) fn title(value: &str) -> String {
let chars: Vec<_> = value.chars().collect();
let mut output = String::with_capacity(value.len());
let mut mid_word = false;
let mut index = 0;
while let Some(&ch) = chars.get(index) {
let properties = flags(ch);
index += 1;
if properties & 1 != 0 {
if !mid_word {
mapped(&mut output, ch, true);
mid_word = true;
} else if ch == 'Σ' {
// Go caps this lookahead at 30 ignorables plus one following
// rune. Ignorables are copied, even if they are also cased.
let sigma = output.len();
output.push('ς');
let mut was_mid = false;
for _ in 0..31 {
let Some(&next) = chars.get(index) else { break };
let next_flags = flags(next);
if next_flags & 2 == 0 {
if next_flags & 1 != 0 {
output.replace_range(sigma..sigma + 'ς'.len_utf8(), "σ");
}
break;
}
let is_mid = next_flags & 8 != 0;
if (was_mid && is_mid) || next_flags & 4 != 0 {
mid_word = false;
}
was_mid = is_mid;
output.push(next);
index += 1;
}
} else {
mapped(&mut output, ch, false);
}
} else {
output.push(ch);
if properties & 4 != 0 {
mid_word = false;
}
}
if properties & 8 != 0 && chars.get(index).is_some_and(|next| flags(*next) & 8 != 0) {
mid_word = false;
}
}
output
}
#[cfg(test)]
mod tests {
use super::title;
#[test]
fn sigma_context_does_not_depend_on_transform_buffer_boundaries() {
// Go's transform.String can lose mid-word state at its 128-byte
// buffer boundary while looking beyond sigma. Preserve the same
// contextual casing regardless of the length of an earlier prefix.
for size in 0..300 {
let prefix = "a|".repeat(size);
let value = format!("{prefix}ΟΣ\u{1aff1}A'ß_\u{1aff1}dz|");
let expected = format!("{}Οσ\u{1aff1}A'ß_\u{1aff1}Dz|", "A|".repeat(size));
assert_eq!(title(&value), expected, "prefix length {size}");
}
}
}
@@ -0,0 +1,466 @@
//! Re-enrichment input, candidate policy and preview metadata. No file access.
use crate::lyrics::json::go_deserialize;
use crate::matching::lowercase;
use crate::resolver::{artists_match, titles_match, track_identity_title, track_titles_match};
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeMap;
#[derive(Clone, Default, Serialize)]
pub struct Request {
pub file_path: String,
pub cover_url: String,
pub cover_max_dimension: isize,
pub embed_lyrics: bool,
pub lyrics_mode: String,
pub artist_tag_mode: String,
pub spotify_id: String,
pub track_name: String,
pub artist_name: String,
pub album_name: String,
pub album_artist: String,
pub track_number: isize,
pub disc_number: isize,
pub total_tracks: isize,
pub total_discs: isize,
pub release_date: String,
pub isrc: String,
pub genre: String,
pub label: String,
pub copyright: String,
pub composer: String,
pub duration_ms: i64,
pub search_online: bool,
pub update_fields: Option<Vec<Option<String>>>,
pub preview_only: bool,
pub replace_release_metadata: bool,
}
go_deserialize!(Request {
"file_path" => file_path, "cover_url" => cover_url,
"cover_max_dimension" => cover_max_dimension, "embed_lyrics" => embed_lyrics,
"lyrics_mode" => lyrics_mode, "artist_tag_mode" => artist_tag_mode,
"spotify_id" => spotify_id, "track_name" => track_name, "artist_name" => artist_name,
"album_name" => album_name, "album_artist" => album_artist,
"track_number" => track_number, "disc_number" => disc_number,
"total_tracks" => total_tracks, "total_discs" => total_discs,
"release_date" => release_date, "isrc" => isrc, "genre" => genre,
"label" => label, "copyright" => copyright, "composer" => composer,
"duration_ms" => duration_ms, "search_online" => search_online,
"update_fields" => update_fields, "preview_only" => preview_only,
"replace_release_metadata" => replace_release_metadata,
});
pub fn text<'a>(value: &'a Value, key: &str) -> &'a str {
value[key].as_str().unwrap_or_default()
}
fn number(value: &Value, key: &str) -> i64 {
value[key].as_i64().unwrap_or_default()
}
pub fn placeholder(value: &str) -> bool {
matches!(
lowercase(value.trim()).as_str(),
"" | "unknown" | "unknown artist" | "unknown title" | "unknown album"
)
}
impl Request {
pub fn parse(raw: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(&crate::text::json_surrogates(raw))
}
pub fn selected(&self, group: &str, tag: &str) -> bool {
selected(&self.update_fields, group, tag)
}
pub fn any_selected(&self, group: &str, tags: &[&str]) -> bool {
tags.iter().any(|tag| self.selected(group, tag))
}
/// Canonical keys shared by native FLAC enrichment and the FFmpeg plan.
/// Empty values preserve existing tags, unlike an explicit editor clear.
pub fn write_metadata(&self, lyrics: &str) -> BTreeMap<String, String> {
let mut fields = BTreeMap::new();
for (group, tag, key, value) in [
("basic_tags", "track_name", "TITLE", &self.track_name),
("basic_tags", "artist_name", "ARTIST", &self.artist_name),
("basic_tags", "album_name", "ALBUM", &self.album_name),
(
"basic_tags",
"album_artist",
"ALBUMARTIST",
&self.album_artist,
),
("release_info", "release_date", "DATE", &self.release_date),
("release_info", "isrc", "ISRC", &self.isrc),
("extra", "genre", "GENRE", &self.genre),
("extra", "label", "ORGANIZATION", &self.label),
("extra", "copyright", "COPYRIGHT", &self.copyright),
("extra", "composer", "COMPOSER", &self.composer),
] {
if self.selected(group, tag) && !value.is_empty() {
fields.insert(key.into(), value.clone());
}
}
for (tag, total_tag, key, number, total) in [
(
"track_number",
"total_tracks",
"TRACKNUMBER",
self.track_number,
self.total_tracks,
),
(
"disc_number",
"total_discs",
"DISCNUMBER",
self.disc_number,
self.total_discs,
),
] {
if self.any_selected("track_info", &[tag, total_tag]) && number > 0 {
fields.insert(
key.into(),
if total > 0 {
format!("{number}/{total}")
} else {
number.to_string()
},
);
}
}
if self.selected("lyrics", "lyrics")
&& !self.lyrics_mode.trim().eq_ignore_ascii_case("external")
&& !lyrics.is_empty()
{
fields.insert("LYRICS".into(), lyrics.into());
fields.insert("UNSYNCEDLYRICS".into(), lyrics.into());
}
fields
}
pub fn write_external_lrc(&self, lyrics: &str) -> bool {
self.embed_lyrics
&& self.selected("lyrics", "lyrics")
&& !lyrics.trim().is_empty()
&& matches!(
self.lyrics_mode.trim().to_ascii_lowercase().as_str(),
"external" | "both"
)
}
pub fn query(&self) -> String {
let mut parts: Vec<_> = [&self.track_name, &self.artist_name]
.into_iter()
.filter(|value| !placeholder(value))
.map(|value| value.trim())
.collect();
if parts.is_empty() && !placeholder(&self.album_name) {
parts.push(self.album_name.trim());
}
parts.join(" ").trim().into()
}
pub fn apply(&mut self, track: &Value) {
let same_release = self.replace_release_metadata
|| placeholder(&self.album_name)
|| text(track, "album_name").trim().is_empty()
|| titles_match(&self.album_name, text(track, "album_name"));
for (key, prefix) in [
("spotify_id", ""),
("deezer_id", "deezer:"),
("qobuz_id", "qobuz:"),
("tidal_id", "tidal:"),
("id", ""),
] {
if !text(track, key).is_empty() {
self.spotify_id = format!("{prefix}{}", text(track, key));
break;
}
}
let selected = |group, tag| selected(&self.update_fields, group, tag);
for (group, tag, source, target, release_only) in [
(
"basic_tags",
"track_name",
"name",
&mut self.track_name,
false,
),
(
"basic_tags",
"artist_name",
"artists",
&mut self.artist_name,
false,
),
(
"basic_tags",
"album_name",
"album_name",
&mut self.album_name,
true,
),
(
"basic_tags",
"album_artist",
"album_artist",
&mut self.album_artist,
true,
),
(
"release_info",
"release_date",
"release_date",
&mut self.release_date,
true,
),
("release_info", "isrc", "isrc", &mut self.isrc, false),
("extra", "genre", "genre", &mut self.genre, false),
("extra", "label", "label", &mut self.label, false),
(
"extra",
"copyright",
"copyright",
&mut self.copyright,
false,
),
("extra", "composer", "composer", &mut self.composer, false),
] {
let value = text(track, source);
if (!release_only || same_release) && selected(group, tag) && !value.is_empty() {
*target = value.into();
}
}
for (tag, target) in [
("track_number", &mut self.track_number),
("total_tracks", &mut self.total_tracks),
("disc_number", &mut self.disc_number),
("total_discs", &mut self.total_discs),
] {
let value = number(track, tag);
if same_release && selected("track_info", tag) && value > 0 {
*target = value as isize;
}
}
if same_release
&& selected("cover", "cover")
&& let Some(cover) = [text(track, "cover_url"), text(track, "images")]
.into_iter()
.find(|value| !value.is_empty())
{
self.cover_url = cover.into();
}
if number(track, "duration_ms") > 0 {
self.duration_ms = number(track, "duration_ms");
}
}
pub fn result_metadata(&self) -> Value {
let mut result = json!({"spotify_id":self.spotify_id,"duration_ms":self.duration_ms});
for (group, tag, key, value) in [
(
"basic_tags",
"track_name",
"track_name",
json!(self.track_name),
),
(
"basic_tags",
"artist_name",
"artist_name",
json!(self.artist_name),
),
(
"basic_tags",
"album_name",
"album_name",
json!(self.album_name),
),
(
"basic_tags",
"album_artist",
"album_artist",
json!(self.album_artist),
),
(
"track_info",
"track_number",
"track_number",
json!(self.track_number),
),
(
"track_info",
"total_tracks",
"total_tracks",
json!(self.total_tracks),
),
(
"track_info",
"disc_number",
"disc_number",
json!(self.disc_number),
),
(
"track_info",
"total_discs",
"total_discs",
json!(self.total_discs),
),
(
"release_info",
"release_date",
"release_date",
json!(self.release_date),
),
("release_info", "isrc", "isrc", json!(self.isrc)),
("cover", "cover", "cover_url", json!(self.cover_url)),
("extra", "genre", "genre", json!(self.genre)),
("extra", "label", "label", json!(self.label)),
("extra", "copyright", "copyright", json!(self.copyright)),
("extra", "composer", "composer", json!(self.composer)),
] {
if self.selected(group, tag) {
result[key] = value;
}
}
result
}
/// ReEnrich passes no candidate album to trackMatchesRequest in Go.
pub fn verified(&self, track: &Value) -> bool {
let (title, artist, isrc) = (
text(track, "name"),
text(track, "artists"),
text(track, "isrc"),
);
let exact = !self.isrc.is_empty()
&& !isrc.is_empty()
&& self.isrc.trim().eq_ignore_ascii_case(isrc.trim());
if !exact
&& ((!self.artist_name.is_empty()
&& !artist.is_empty()
&& !artists_match(&self.artist_name, artist))
|| (!self.track_name.is_empty()
&& !title.is_empty()
&& !track_titles_match(&self.track_name, title)))
{
return false;
}
let (expected, duration) = (self.duration_ms / 1000, number(track, "duration_ms") / 1000);
if expected > 0 && duration > 0 && expected.abs_diff(duration) > 10 {
let identity = track_identity_title(&self.track_name);
let candidate_identity = track_identity_title(title);
let identity_matches = if !identity.is_empty() && !candidate_identity.is_empty() {
identity == candidate_identity
} else {
lowercase(self.track_name.trim()) == lowercase(title.trim())
};
return exact
&& !self.track_name.is_empty()
&& !title.is_empty()
&& identity_matches
&& !self.artist_name.is_empty()
&& !artist.is_empty()
&& artists_match(&self.artist_name, artist)
&& !(duration <= 35 && expected > 45);
}
true
}
pub fn select<'a>(&self, tracks: &'a [Value]) -> Option<&'a Value> {
let isrc = self.isrc.trim();
let album = self.album_name.trim();
let title = if placeholder(&self.track_name) {
""
} else {
&self.track_name
};
let artist = if placeholder(&self.artist_name) {
""
} else {
&self.artist_name
};
let mut best = None;
let mut best_score = i32::MIN;
for track in tracks {
let exact = !isrc.is_empty() && isrc.eq_ignore_ascii_case(text(track, "isrc").trim());
let title_matches = !title.is_empty()
&& !text(track, "name").is_empty()
&& titles_match(title, text(track, "name"));
let artist_matches = !artist.is_empty()
&& !text(track, "artists").is_empty()
&& artists_match(artist, text(track, "artists"));
let album_matches = !album.is_empty()
&& !text(track, "album_name").is_empty()
&& titles_match(album, text(track, "album_name"));
let verified = self.verified(track);
if !exact
&& ((!title.is_empty() && !title_matches)
|| (!artist.is_empty() && !artist_matches)
|| (title.is_empty()
&& artist.is_empty()
&& ((!album.is_empty() && !album_matches)
|| (album.is_empty() && !verified))))
{
continue;
}
let mut score = i32::from(verified) * 2000
+ i32::from(exact) * 10000
+ i32::from(title_matches) * 400
+ i32::from(artist_matches) * 320;
if !album.is_empty() && !text(track, "album_name").is_empty() {
if album_matches {
score += 120;
} else if lowercase(album).contains(&lowercase(text(track, "album_name")))
|| lowercase(text(track, "album_name")).contains(&lowercase(album))
{
score += 50;
}
}
if self.duration_ms > 0
&& number(track, "duration_ms") > 0
&& (self.duration_ms / 1000).abs_diff(number(track, "duration_ms") / 1000) <= 10
{
score += 80;
}
score += i32::from(!text(track, "release_date").is_empty()) * 70
+ i32::from(number(track, "track_number") > 0) * 20
+ i32::from(number(track, "disc_number") > 0) * 10
+ i32::from(!text(track, "isrc").is_empty()) * 40;
if score > best_score {
best_score = score;
best = Some(track);
}
}
best
}
}
fn selected(fields: &Option<Vec<Option<String>>>, group: &str, tag: &str) -> bool {
fields.as_ref().is_none_or(|fields| {
fields.is_empty()
|| fields.iter().any(|field| {
field
.as_deref()
.is_some_and(|field| field == group || field == tag)
})
})
}
pub fn from_catalog(track: &super::TrackMetadata) -> Value {
let mut result = serde_json::to_value(track).expect("catalog metadata serialization");
result["id"] = json!(track.spotify_id);
result["deezer_id"] = json!(
track
.spotify_id
.strip_prefix("deezer:")
.unwrap_or(&track.spotify_id)
.trim()
);
result["cover_url"] = json!(track.images);
result["provider_id"] = json!("deezer");
result
}
@@ -0,0 +1,271 @@
//! Collection matching and share links from already-decoded extension tracks.
use crate::lyrics::json::go_deserialize;
use crate::lyrics::matching::{normalize_loose_artist, normalize_title};
use crate::matching::lowercase;
use serde::Serialize;
use serde_json::Value;
#[derive(Default)]
pub struct Request {
pub name: String,
pub artists: String,
pub kind: String,
pub source_extension_id: String,
}
go_deserialize!(Request {
"name" => name, "artists" => artists, "type" => kind,
"source_extension_id" => source_extension_id,
});
impl Request {
pub fn parse(raw: &str) -> Result<Self, serde_json::Error> {
let mut request: Self = serde_json::from_str(&crate::text::json_surrogates(raw))?;
request.name = request.name.trim().into();
request.artists = request.artists.trim().into();
request.source_extension_id = request.source_extension_id.trim().into();
request.kind = lowercase(request.kind.trim());
if request.kind.is_empty() {
request.kind = "album".into();
}
Ok(request)
}
pub fn query(&self) -> String {
if self.artists.is_empty() {
self.name.clone()
} else {
format!("{} {}", self.name, self.artists)
}
}
pub fn cache_key(&self, providers: &[Provider]) -> String {
let mut identities: Vec<_> = providers
.iter()
.map(|provider| {
[
provider.id.trim(),
provider.display_name.trim(),
provider.source_dir.trim(),
]
.join("\x1f")
})
.collect();
identities.sort();
[
normalize_title(&self.kind),
normalize_title(&self.name),
normalize_loose_artist(&self.artists),
self.source_extension_id.clone(),
identities.join("\x1e"),
]
.join("\x1d")
}
}
pub struct Provider {
pub id: String,
pub display_name: String,
pub source_dir: String,
pub capabilities: Value,
}
#[derive(Serialize)]
pub struct ShareResult {
pub extension_id: String,
pub display_name: String,
pub found: bool,
#[serde(skip_serializing_if = "String::is_empty")]
pub url: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub item_name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub item_artists: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub error: String,
}
impl ShareResult {
pub fn new(provider: &Provider) -> Self {
Self {
extension_id: provider.id.clone(),
display_name: if provider.display_name.is_empty() {
provider.id.clone()
} else {
provider.display_name.clone()
},
found: false,
url: String::new(),
item_name: String::new(),
item_artists: String::new(),
error: String::new(),
}
}
pub fn cacheable(&self) -> bool {
let error = lowercase(self.error.trim());
self.found
|| error.is_empty()
|| matches!(error.as_str(), "no results" | "unsupported collection type")
|| error.ends_with(" not found")
|| error.contains("found without shareable link")
}
}
fn text<'a>(value: &'a Value, key: &str) -> &'a str {
value[key].as_str().unwrap_or_default()
}
fn is_collection(track: &Value, kind: &str) -> bool {
// Go's simple folding also accepts long s in the fixed ASCII kind "artist".
text(track, "item_type")
.trim()
.chars()
.map(|ch| {
if ch == 'ſ' {
's'
} else {
ch.to_ascii_lowercase()
}
})
.eq(kind.chars())
}
fn name<'a>(track: &'a Value, kind: &str) -> &'a str {
text(
track,
if is_collection(track, kind) {
"name"
} else if kind == "album" {
"album_name"
} else {
"artists"
},
)
}
fn share_url(provider: &Provider, track: &Value, kind: &str) -> String {
let direct = |value: &str| {
let value = value.trim();
(value.starts_with("http://") || value.starts_with("https://")).then_some(value.to_owned())
};
if is_collection(track, kind)
&& let Some(url) = direct(text(track, "external_urls"))
{
return url;
}
let field = if kind == "album" {
"album_url"
} else {
"artist_url"
};
if let Some(url) = direct(text(track, field)) {
return url;
}
if let Some(links) = track["external_links"].as_object() {
for (key, value) in links {
if lowercase(key).contains(kind)
&& let Some(url) = direct(value.as_str().unwrap_or_default())
{
return url;
}
}
}
let id_field = if kind == "album" {
"album_id"
} else {
"artist_id"
};
let id = [
text(track, id_field),
if is_collection(track, kind) {
text(track, "id")
} else {
""
},
if kind == "album" {
text(track, "album_url")
} else {
""
},
]
.into_iter()
.map(str::trim)
.find(|id| !id.is_empty())
.unwrap_or_default();
if id.is_empty() {
return String::new();
}
let id = id
.split_once(':')
.filter(|(prefix, suffix)| !prefix.is_empty() && !suffix.is_empty())
.map_or(id, |(_, suffix)| suffix);
provider.capabilities["shareUrlTemplates"][kind]
.as_str()
.unwrap_or_default()
.trim()
.replace("{id}", id)
}
pub fn select(provider: &Provider, request: &Request, tracks: &[Value]) -> ShareResult {
let mut result = ShareResult::new(provider);
if tracks.is_empty() {
result.error = "no results".into();
return result;
}
let kind = request.kind.as_str();
if !matches!(kind, "album" | "artist") {
result.error = "unsupported collection type".into();
return result;
}
let normalize = if kind == "album" {
normalize_title
} else {
normalize_loose_artist
};
let target = normalize(&request.name);
let artists = normalize_loose_artist(&request.artists);
let mut best = None;
let mut best_score = 0;
for track in tracks {
let candidate = normalize(name(track, kind));
let mut score = if is_collection(track, kind) { 25 } else { 0 };
if candidate == target {
score += 100;
} else if !candidate.is_empty()
&& !target.is_empty()
&& (candidate.contains(&target) || target.contains(&candidate))
{
score += if kind == "album" { 50 } else { 60 };
}
if kind == "album" && !artists.is_empty() {
let candidate_artists = normalize_loose_artist(&format!(
"{} {}",
text(track, "artists"),
text(track, "album_artist")
));
if candidate_artists.contains(&artists) || artists.contains(&candidate_artists) {
score += 30;
}
}
if score > best_score {
best_score = score;
best = Some(track);
}
}
let Some(best) = best.filter(|_| best_score >= if kind == "album" { 50 } else { 60 }) else {
result.error = format!("{kind} not found");
return result;
};
result.url = share_url(provider, best, kind);
if result.url.is_empty() {
result.error = format!("{kind} found without shareable link");
return result;
}
result.found = true;
result.item_name = name(best, kind).into();
if kind == "album" {
result.item_artists = text(best, "artists").into();
}
result
}
@@ -0,0 +1,429 @@
//! Go-compatible sparse download progress, shared by every native consumer.
mod reporter;
mod subscription;
pub use reporter::{ItemProgressWriter, ItemTransferProgressReporter};
pub use subscription::ProgressSubscription;
use serde::Serialize;
use std::collections::BTreeMap;
use std::fmt;
use std::sync::{Arc, Condvar, Mutex};
pub const UPDATE_BYTES: i64 = 128 * 1024;
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ItemProgress {
pub item_id: String,
pub bytes_total: i64,
pub bytes_received: i64,
pub progress: f64,
pub speed_mbps: f64,
pub is_downloading: bool,
pub status: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub stage: String,
#[serde(skip)]
revision: i64,
}
#[derive(PartialEq)]
struct BridgeState {
bytes_bucket: i64,
total: i64,
percent: i64,
speed_tenths: i64,
downloading: bool,
status: String,
stage: String,
}
impl ItemProgress {
fn bridge(&self) -> BridgeState {
let progress = if self.progress.is_nan() || self.progress <= 0.0 {
0.0
} else {
self.progress.min(1.0)
};
let speed = if self.speed_mbps.is_nan() || self.speed_mbps <= 0.0 {
0.0
} else {
self.speed_mbps
};
BridgeState {
bytes_bucket: self.bytes_received / UPDATE_BYTES,
total: self.bytes_total,
percent: (progress * 100.0).round() as i64,
speed_tenths: rounded_speed(speed * 10.0),
downloading: self.is_downloading,
status: self.status.clone(),
stage: self.stage.clone(),
}
}
fn finite(&self) -> bool {
self.progress.is_finite() && self.speed_mbps.is_finite()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProgressError {
Closed,
SubscriptionClosed,
}
impl fmt::Display for ProgressError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Closed => "download progress registry closed",
Self::SubscriptionClosed => "download progress subscription closed",
})
}
}
impl std::error::Error for ProgressError {}
struct State {
items: BTreeMap<String, ItemProgress>,
removed: BTreeMap<String, i64>,
seq: i64,
reset: i64,
dirty: bool,
cached: String,
closed: bool,
}
impl Default for State {
fn default() -> Self {
Self {
items: BTreeMap::new(),
removed: BTreeMap::new(),
seq: 0,
reset: 0,
dirty: true,
cached: "{\"items\":{}}".into(),
closed: false,
}
}
}
impl State {
fn check(&self) -> Result<(), ProgressError> {
if self.closed {
Err(ProgressError::Closed)
} else {
Ok(())
}
}
fn next(&mut self, changed: &Condvar) -> i64 {
self.seq = self.seq.wrapping_add(1);
self.dirty = true;
changed.notify_all();
self.seq
}
fn delta(&self, since: i64) -> String {
if since >= self.seq {
return String::new();
}
#[derive(Serialize)]
struct Delta<'a> {
seq: i64,
#[serde(skip_serializing_if = "std::ops::Not::not")]
reset: bool,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
items: BTreeMap<&'a String, &'a ItemProgress>,
#[serde(skip_serializing_if = "Vec::is_empty")]
removed: Vec<&'a String>,
}
let reset = since <= 0 || since < self.reset;
let items: BTreeMap<_, _> = self
.items
.iter()
.filter(|(_, item)| reset || item.revision > since)
.collect();
// serde_json represents non-finite floats as null; encoding/json fails
// the whole document instead. Preserve Go's empty/fallback responses.
if items.values().any(|item| !item.finite()) {
return String::new();
}
let removed = if reset {
Vec::new()
} else {
self.removed
.iter()
.filter(|(_, revision)| **revision > since)
.map(|(id, _)| id)
.collect()
};
serde_json::to_string(&Delta {
seq: self.seq,
reset,
items,
removed,
})
.unwrap_or_default()
}
}
#[derive(Default)]
struct Shared {
state: Mutex<State>,
changed: Condvar,
}
#[derive(Default)]
pub struct ProgressRegistry {
shared: Arc<Shared>,
}
impl ProgressRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn start(&self, id: &str) -> Result<(), ProgressError> {
let mut state = self.shared.state.lock().expect("progress state lock");
state.check()?;
let revision = state.next(&self.shared.changed);
state.items.insert(
id.to_owned(),
ItemProgress {
item_id: id.to_owned(),
bytes_total: 0,
bytes_received: 0,
progress: 0.0,
speed_mbps: 0.0,
is_downloading: false,
status: "preparing".into(),
stage: String::new(),
revision,
},
);
state.removed.remove(id);
Ok(())
}
fn update(
&self,
id: &str,
update: impl FnOnce(&mut ItemProgress),
) -> Result<(), ProgressError> {
let mut state = self.shared.state.lock().expect("progress state lock");
state.check()?;
if let Some(item) = state.items.get_mut(id) {
let before = item.bridge();
update(item);
if item.bridge() != before {
let revision = state.next(&self.shared.changed);
state.items.get_mut(id).expect("existing progress").revision = revision;
}
}
Ok(())
}
pub fn preparing(&self, id: &str, stage: &str) -> Result<(), ProgressError> {
self.update(id, |item| {
item.progress = 0.0;
item.bytes_received = 0;
item.bytes_total = 0;
item.speed_mbps = 0.0;
item.is_downloading = true;
item.status = "preparing".into();
item.stage = stage.to_owned();
})
}
pub fn downloading(&self, id: &str) -> Result<(), ProgressError> {
self.update(id, mark_downloading)
}
pub fn set_total(&self, id: &str, total: i64) -> Result<(), ProgressError> {
self.update(id, |item| item.bytes_total = total)
}
pub fn set_received(&self, id: &str, received: i64) -> Result<(), ProgressError> {
self.update(id, |item| set_received(item, received))
}
pub fn set_received_with_speed(
&self,
id: &str,
received: i64,
speed: f64,
) -> Result<(), ProgressError> {
self.update(id, |item| {
item.speed_mbps = speed;
set_received(item, received);
})
}
pub fn set_progress(
&self,
id: &str,
progress: f64,
received: i64,
total: i64,
) -> Result<(), ProgressError> {
self.update(id, |item| {
let has_bytes = received > 0 || total > 0;
item.progress = if item.status != "preparing" || has_bytes || progress >= 1.0 {
progress
} else {
0.0
};
if received > 0 {
item.bytes_received = received;
}
if total > 0 {
item.bytes_total = total;
}
if has_bytes || progress >= 1.0 || item.status == "downloading" {
mark_downloading(item);
}
})
}
pub fn finalizing(&self, id: &str) -> Result<(), ProgressError> {
self.update(id, |item| {
item.progress = 1.0;
item.status = "finalizing".into();
item.stage.clear();
})
}
pub fn complete(&self, id: &str) -> Result<(), ProgressError> {
self.update(id, |item| {
item.progress = 1.0;
item.is_downloading = false;
item.status = "completed".into();
item.stage.clear();
})
}
pub fn remove(&self, id: &str) -> Result<(), ProgressError> {
let mut state = self.shared.state.lock().expect("progress state lock");
state.check()?;
if state.items.remove(id).is_some() {
let seq = state.next(&self.shared.changed);
state.removed.insert(id.to_owned(), seq);
if state.removed.len() > 512 {
let mut revisions: Vec<_> = state.removed.values().copied().collect();
revisions.sort_unstable();
let cutoff = revisions[revisions.len() / 2];
state.removed.retain(|_, revision| *revision > cutoff);
state.reset = state.reset.max(cutoff);
}
}
// Even removing a missing ID invalidates the snapshot cache in Go.
state.dirty = true;
Ok(())
}
pub fn clear(&self) -> Result<(), ProgressError> {
let mut state = self.shared.state.lock().expect("progress state lock");
state.check()?;
state.items.clear();
state.removed.clear();
state.reset = state.next(&self.shared.changed);
Ok(())
}
pub fn item(&self, id: &str) -> Result<String, ProgressError> {
let state = self.shared.state.lock().expect("progress state lock");
state.check()?;
Ok(match state.items.get(id) {
Some(item) if item.finite() => serde_json::to_string(item).unwrap_or_default(),
Some(_) => String::new(),
None => "{}".into(),
})
}
pub fn snapshot(&self) -> Result<String, ProgressError> {
let mut state = self.shared.state.lock().expect("progress state lock");
state.check()?;
if state.dirty {
if state.items.values().any(|item| !item.finite()) {
return Ok("{\"items\":{}}".into());
}
#[derive(Serialize)]
struct Snapshot<'a> {
items: &'a BTreeMap<String, ItemProgress>,
}
state.cached = serde_json::to_string(&Snapshot {
items: &state.items,
})
.expect("finite progress JSON");
state.dirty = false;
}
Ok(state.cached.clone())
}
pub fn delta(&self, since: i64) -> Result<String, ProgressError> {
let state = self.shared.state.lock().expect("progress state lock");
state.check()?;
Ok(state.delta(since))
}
pub fn subscribe(&self) -> Result<ProgressSubscription, ProgressError> {
self.shared
.state
.lock()
.expect("progress state lock")
.check()?;
Ok(ProgressSubscription::new(Arc::clone(&self.shared)))
}
pub fn wait_delta(&self, since: i64, timeout_ms: i64) -> Result<String, ProgressError> {
self.subscribe()?.wait_delta(since, timeout_ms)
}
pub fn shutdown(&self) {
let mut state = self.shared.state.lock().expect("progress state lock");
state.closed = true;
state.items.clear();
state.removed.clear();
state.cached.clear();
self.shared.changed.notify_all();
}
}
impl Drop for ProgressRegistry {
fn drop(&mut self) {
self.shutdown();
}
}
fn mark_downloading(item: &mut ItemProgress) {
item.is_downloading = true;
item.status = "downloading".into();
item.stage.clear();
}
fn set_received(item: &mut ItemProgress, received: i64) {
item.bytes_received = received;
if item.bytes_total > 0 {
item.progress = received as f64 / item.bytes_total as f64;
}
if received > 0 {
mark_downloading(item);
}
}
fn rounded_speed(value: f64) -> i64 {
let value = value.round();
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
if value >= 9_223_372_036_854_775_808.0 {
return i64::MIN;
}
#[cfg(target_arch = "arm")]
if value >= 18_446_744_073_709_551_616.0 || !value.is_finite() {
// Go ARM32's int64 conversion delegates out-of-range values to _d2v.
return (u64::from(value as u32) << 32) as i64;
} else if value >= 9_223_372_036_854_775_808.0 {
return value as u64 as i64;
}
value as i64
}
@@ -0,0 +1,162 @@
use super::{ProgressRegistry, UPDATE_BYTES};
use crate::cancellation::CancellationRegistry;
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub struct ItemTransferProgressReporter {
progress: Arc<ProgressRegistry>,
id: String,
state: Mutex<ReportState>,
}
struct ReportState {
received: i64,
total: i64,
at: Instant,
}
impl ItemTransferProgressReporter {
pub fn new(progress: Arc<ProgressRegistry>, id: &str, received: i64, total: i64) -> Self {
Self {
progress,
id: id.to_owned(),
state: Mutex::new(ReportState {
received,
total,
at: Instant::now(),
}),
}
}
pub fn report(&self, received: i64, total: i64) {
self.report_at(received, total, Instant::now());
}
fn report_at(&self, received: i64, total: i64, now: Instant) {
if self.id.is_empty() {
return;
}
let mut state = self.state.lock().expect("progress reporter lock");
let delta = received.wrapping_sub(state.received);
if (0..UPDATE_BYTES).contains(&delta)
&& total == state.total
&& now.saturating_duration_since(state.at) < Duration::from_millis(250)
{
return;
}
*state = ReportState {
received,
total,
at: now,
};
if total > 0 {
let _ = self.progress.set_progress(
&self.id,
received as f64 / total as f64,
received,
total,
);
} else {
let _ = self.progress.set_received(&self.id, received);
}
}
}
/// Adapter for media writers. Network transfer reporters deliberately do not
/// synthesize speed: the existing Go writer owns that separate behavior.
pub struct ItemProgressWriter<W> {
writer: W,
id: String,
progress: Arc<ProgressRegistry>,
cancellation: Arc<CancellationRegistry>,
current: i64,
last_reported: i64,
last_time: Instant,
last_bytes: i64,
}
impl<W> ItemProgressWriter<W> {
pub fn new(
writer: W,
id: &str,
progress: Arc<ProgressRegistry>,
cancellation: Arc<CancellationRegistry>,
) -> Self {
Self {
writer,
id: id.to_owned(),
progress,
cancellation,
current: 0,
last_reported: 0,
last_time: Instant::now(),
last_bytes: 0,
}
}
pub fn into_inner(self) -> W {
self.writer
}
}
impl<W: Write> Write for ItemProgressWriter<W> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if !self.id.is_empty() {
match self.cancellation.is_cancelled(&self.id) {
Ok(true) => return Err(io::Error::other("download cancelled")),
Err(error) => return Err(io::Error::other(error)),
Ok(false) => {}
}
}
let count = self.writer.write(bytes)?;
self.current = self.current.wrapping_add(count as i64);
if self.last_reported == 0 || self.current.wrapping_sub(self.last_reported) >= UPDATE_BYTES
{
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_time).as_secs_f64();
let speed = if elapsed > 0.0 {
self.current.wrapping_sub(self.last_bytes) as f64 / (1024.0 * 1024.0) / elapsed
} else {
0.0
};
let _ = self
.progress
.set_received_with_speed(&self.id, self.current, speed);
self.last_reported = self.current;
self.last_bytes = self.current;
self.last_time = now;
}
Ok(count)
}
fn flush(&mut self) -> io::Result<()> {
self.writer.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coalescing_flushes_threshold_interval_total_changes_and_rollbacks() {
let progress = Arc::new(ProgressRegistry::new());
progress.start("example").unwrap();
let reporter =
ItemTransferProgressReporter::new(Arc::clone(&progress), "example", 0, 1 << 20);
let now = reporter.state.lock().unwrap().at;
let received = || {
serde_json::from_str::<serde_json::Value>(&progress.item("example").unwrap()).unwrap()["bytes_received"].as_i64().unwrap()
};
reporter.report_at(65536, 1 << 20, now);
assert_eq!(received(), 0);
reporter.report_at(UPDATE_BYTES, 1 << 20, now);
assert_eq!(received(), UPDATE_BYTES);
reporter.report_at(UPDATE_BYTES + 1, 1 << 20, now + Duration::from_millis(250));
assert_eq!(received(), UPDATE_BYTES + 1);
reporter.report_at(UPDATE_BYTES + 2, 2 << 20, now + Duration::from_millis(250));
assert_eq!(received(), UPDATE_BYTES + 2);
reporter.report_at(1, 2 << 20, now + Duration::from_millis(250));
assert_eq!(received(), 1);
}
}
@@ -0,0 +1,61 @@
use super::{ProgressError, Shared};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
pub struct ProgressSubscription {
shared: Arc<Shared>,
closed: AtomicBool,
}
impl ProgressSubscription {
pub(super) fn new(shared: Arc<Shared>) -> Self {
Self {
shared,
closed: AtomicBool::new(false),
}
}
/// No event is consumed: independent UI/background listeners use their own
/// sequence, and every waiter sees the same revision. Close wakes only this
/// subscription semantically; other waiters continue against their deadline.
pub fn wait_delta(&self, since: i64, timeout_ms: i64) -> Result<String, ProgressError> {
let timeout = Duration::from_millis(if timeout_ms <= 0 {
15_000
} else {
timeout_ms.min(60_000)
} as u64);
let started = Instant::now();
let mut state = self.shared.state.lock().expect("progress state lock");
loop {
state.check()?;
if self.closed.load(Ordering::Acquire) {
return Err(ProgressError::SubscriptionClosed);
}
if since < state.seq {
return Ok(state.delta(since));
}
let remaining = timeout.saturating_sub(started.elapsed());
if remaining.is_zero() {
return Ok(String::new());
}
(state, _) = self
.shared
.changed
.wait_timeout(state, remaining)
.expect("progress wait lock");
}
}
pub fn close(&self) {
let _state = self.shared.state.lock().expect("progress state lock");
self.closed.store(true, Ordering::Release);
self.shared.changed.notify_all();
}
}
impl Drop for ProgressSubscription {
fn drop(&mut self) {
self.close();
}
}
+339
View File
@@ -0,0 +1,339 @@
//! Typed platform-resolver payloads and metadata matching shared with Go.
use crate::lyrics::json::{Update, go_deserialize};
use crate::lyrics::matching::normalize_loose_artist;
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Metadata {
pub title: String,
pub artist: String,
}
/// Go reuses a non-nil map across repeated JSON fields, but resets it on null.
#[derive(Clone, Debug)]
pub struct Map<T>(pub BTreeMap<String, T>);
impl<T> Default for Map<T> {
fn default() -> Self {
Self(BTreeMap::new())
}
}
impl<'de, T: Deserialize<'de>> Update<'de> for Map<T> {
fn update<D: Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error> {
match Option::<BTreeMap<String, T>>::deserialize(decoder)? {
Some(values) => self.0.extend(values),
None => self.0.clear(),
}
Ok(())
}
}
#[derive(Clone, Debug, Default)]
pub struct Link {
pub url: String,
}
go_deserialize!(Link { "url" => url, });
#[derive(Clone, Debug, Default)]
pub struct Entity {
pub title: String,
pub artist: String,
}
go_deserialize!(Entity { "title" => title, "artistname" => artist, });
#[derive(Clone, Debug, Default)]
pub struct UniTune {
pub entity_id: String,
pub links: Map<Link>,
pub entities: Map<Entity>,
}
go_deserialize!(UniTune {
"entityuniqueid" => entity_id,
"linksbyplatform" => links,
"entitiesbyuniqueid" => entities,
});
#[derive(Clone, Debug, Default)]
pub struct Artist {
pub name: String,
}
go_deserialize!(Artist { "name" => name, });
#[derive(Clone, Debug, Default)]
pub struct Recording {
pub id: String,
pub score: isize,
pub title: String,
pub artists: Option<Vec<Artist>>,
}
go_deserialize!(Recording {
"id" => id, "score" => score, "title" => title, "artist-credit" => artists,
});
#[derive(Clone, Debug, Default)]
pub struct Recordings {
pub recordings: Option<Vec<Recording>>,
}
go_deserialize!(Recordings { "recordings" => recordings, });
#[derive(Clone, Debug, Default)]
pub struct Resource {
pub resource: String,
}
go_deserialize!(Resource { "resource" => resource, });
#[derive(Clone, Debug, Default)]
pub struct Relation {
pub url: Resource,
}
go_deserialize!(Relation { "url" => url, });
#[derive(Clone, Debug, Default)]
pub struct Relations {
pub relations: Option<Vec<Relation>>,
}
go_deserialize!(Relations { "relations" => relations, });
#[derive(Clone, Debug, Default)]
pub struct Created {
pub full_url: String,
pub title: String,
pub artist: String,
}
go_deserialize!(Created { "full_url" => full_url, "title" => title, "artist" => artist, });
#[derive(Clone, Debug, Default)]
pub struct PageData {
pub title: String,
pub artist: String,
pub services: Map<Option<Link>>,
}
go_deserialize!(PageData { "title" => title, "artist" => artist, "services" => services, });
#[derive(Clone, Debug, Default)]
pub struct Page {
pub data: PageData,
}
go_deserialize!(Page { "data" => data, });
/// The platform resolver uses Go's broader metadata artist matcher, rather
/// than the stricter primary-artist equality required for lyrics searches.
pub fn artists_match(expected: &str, found: &str) -> bool {
let first = normalize_loose_artist(expected);
let second = normalize_loose_artist(found);
if first.contains(&second) || second.contains(&first) {
return true;
}
let split = |value: &str| {
let mut value = crate::matching::lowercase(value);
for separator in [
" feat. ", " feat ", " ft. ", " ft ", " & ", " and ", ",", ";", " x ",
] {
value = value.replace(separator, "|");
}
value
.split('|')
.map(normalize_loose_artist)
.filter(|v| !v.is_empty())
.collect::<Vec<_>>()
};
for first in split(expected) {
for second in split(found) {
if first.contains(&second) || second.contains(&first) {
return true;
}
let mut first: Vec<_> = first.split_whitespace().collect();
let mut second: Vec<_> = second.split_whitespace().collect();
first.sort_unstable();
second.sort_unstable();
if first == second {
return true;
}
}
}
let latin = |value: &str| {
!value.chars().any(|ch| {
matches!(ch as u32,
0x4e00..=0x9fff | 0x3040..=0x309f | 0x30a0..=0x30ff |
0xac00..=0xd7af | 0x0600..=0x06ff | 0x0400..=0x04ff)
})
};
latin(expected) != latin(found)
}
/// Album/title matching used by Go's metadata enrichment policy.
pub fn titles_match(expected: &str, found: &str) -> bool {
use crate::lyrics::matching::normalize_title;
use crate::matching::lowercase;
let (expected, found) = (lowercase(expected.trim()), lowercase(found.trim()));
let contains = |a: &str, b: &str| a.contains(b) || b.contains(a);
if contains(&expected, &found) {
return true;
}
let clean = |value: &str| {
let mut value = value.to_owned();
let versions = [
"remaster",
"remastered",
"deluxe",
"bonus",
"single",
"album version",
"radio edit",
"original mix",
"extended",
"club mix",
"remix",
"live",
"acoustic",
"demo",
];
for (open, close) in [('(', ')'), ('[', ']')] {
while let (Some(start), Some(end)) = (value.rfind(open), value.rfind(close)) {
if end <= start
|| !versions
.iter()
.any(|word| value[start + 1..end].contains(word))
{
break;
}
value = format!("{}{}", value[..start].trim(), &value[end + 1..]);
}
}
for suffix in [
" - remaster",
" - remastered",
" - single version",
" - radio edit",
" - live",
" - acoustic",
" - demo",
" - remix",
] {
if value.ends_with(suffix) {
value.truncate(value.len() - suffix.len());
}
}
while value.contains(" ") {
value = value.replace(" ", " ");
}
value.trim().to_owned()
};
let (a, b) = (clean(&expected), clean(&found));
if a == b || (!a.is_empty() && !b.is_empty() && contains(&a, &b)) {
return true;
}
let core = |value: &str| {
let end = [value.find('('), value.find('['), value.find(" - ")]
.into_iter()
.flatten()
.filter(|index| *index > 0)
.min()
.unwrap_or(value.len());
value[..end].trim().to_owned()
};
let (a, b) = (core(&expected), core(&found));
if !a.is_empty() && a == b {
return true;
}
let (a, b) = (normalize_title(&expected), normalize_title(&found));
if !a.is_empty() && !b.is_empty() && contains(&a, &b) {
return true;
}
let symbols = |value: &str| {
use unicode_general_category::{GeneralCategory::*, get_general_category};
value
.chars()
.filter(|ch| {
!ch.is_alphanumeric()
&& !ch.is_whitespace()
&& !matches!(
get_general_category(*ch),
ConnectorPunctuation
| DashPunctuation
| OpenPunctuation
| ClosePunctuation
| InitialPunctuation
| FinalPunctuation
| OtherPunctuation
| NonspacingMark
| SpacingMark
| EnclosingMark
)
})
.collect::<String>()
};
if !expected.chars().any(char::is_alphanumeric) || !found.chars().any(char::is_alphanumeric) {
let a = symbols(&expected);
return !a.is_empty() && a == symbols(&found);
}
false
}
pub fn track_identity_title(value: &str) -> String {
crate::lyrics::matching::normalize_title(&track_title_without_annotations(value))
}
fn track_title_without_annotations(value: &str) -> std::borrow::Cow<'_, str> {
static ANNOTATION: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r#"(?i)[(\[][\t\n\f\r ]*(?:(?:feat\.?|ft\.?|featuring)[\t\n\f\r ]+[^)\]]+|from[\t\n\f\r ]+["“][^)\]]+["”][\t\n\f\r ]*)[)\]]"#).unwrap()
});
ANNOTATION.replace_all(value, " ")
}
pub fn track_titles_match(expected: &str, found: &str) -> bool {
let (expected, found) = (
track_title_without_annotations(expected),
track_title_without_annotations(found),
);
let (a, b) = (
crate::lyrics::matching::normalize_title(&expected),
crate::lyrics::matching::normalize_title(&found),
);
if !a.is_empty() && a == b {
return true;
}
if a.split_whitespace()
.chain(b.split_whitespace())
.any(|word| {
matches!(
word,
"mix"
| "remix"
| "live"
| "acoustic"
| "demo"
| "instrumental"
| "karaoke"
| "edit"
| "extended"
| "slowed"
| "sped"
)
})
{
return false;
}
titles_match(&expected, &found)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repeated_maps_merge_but_null_resets_and_nested_structs_retain_fields() {
let payload: UniTune = crate::lyrics::decode_document(br#"{"linksByPlatform":{"a":{"url":"first"}},"LINKSBYPLATFORM":{"b":{"URL":"second","url":null}},"unused":1e400}"#).unwrap();
assert_eq!(payload.links.0.len(), 2);
assert_eq!(payload.links.0["b"].url, "second");
let payload: Page = crate::lyrics::decode_response(br#"{"data":{"title":"Title","services":{"a":{"url":"old"}}},"data":{"services":null},"data":{"artist":"Artist","services":{"b":null}}}; ignored"#).unwrap();
assert_eq!(payload.data.title, "Title");
assert_eq!(payload.data.artist, "Artist");
assert_eq!(payload.data.services.0.len(), 1);
assert!(payload.data.services.0["b"].is_none());
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Audio tags used by metadata, lyrics and duplicate detection. Readers are
//! supplied by the native owner so descriptor paths need no temporary copy.
mod comments;
mod containers;
mod file;
mod id3;
mod library;
mod mp4;
mod write;
use crate::text::utf8;
pub(crate) use containers::{ogg_packets, ogg_stream_is_opus};
pub use file::{file_metadata_extension, read_file_metadata};
pub use library::{
library_extension, library_id, library_metadata, read_library_metadata,
read_library_metadata_with_cover,
};
use serde::Serialize;
use std::io::{self, BufReader, Read, Seek, SeekFrom};
pub use write::{
embed_flac_metadata, rewrite_ac4_config, rewrite_ac4_metadata, rewrite_audio_tags,
rewrite_m4a_freeform,
};
#[derive(Clone, Debug)]
pub struct CoverArt {
pub data: Vec<u8>,
pub mime: String,
}
/// Extract the embedded bytes without decoding the image or reading audio into
/// memory. Format selection/hints and output publication belong to the owner.
pub fn extract_cover(
reader: &mut (impl Read + Seek),
format: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<CoverArt, String> {
check()?;
reader
.seek(SeekFrom::Start(0))
.map_err(|error| error.to_string())?;
let mut observed = file::ObservedReader {
file: reader,
check,
failure: None,
};
let mut reader = BufReader::new(&mut observed);
let result = match format {
"flac" => containers::flac_cover(&mut reader),
"m4a" | "aac" => mp4::cover(&mut reader),
"mp3" => id3::cover(&mut reader),
"ogg" | "opus" => containers::ogg_cover(&mut reader),
"wav" => containers::riff_cover(&mut reader, false),
"aiff" | "aif" | "aifc" => containers::riff_cover(&mut reader, true),
_ => Err("unsupported audio format for cover extraction".into()),
};
if let Some(error) = observed.failure {
return Err(error);
}
check()?;
result
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct AudioMetadata {
pub title: String,
pub artist: String,
pub album: String,
pub album_artist: String,
pub genre: String,
pub year: String,
pub date: String,
pub track_number: i64,
pub total_tracks: i64,
pub disc_number: i64,
pub total_discs: i64,
pub isrc: String,
pub lyrics: String,
pub label: String,
pub copyright: String,
pub composer: String,
pub comment: String,
pub album_type: String,
pub explicit: bool,
pub upc: String,
pub replay_gain_track_gain: String,
pub replay_gain_track_peak: String,
pub replay_gain_album_gain: String,
pub replay_gain_album_peak: String,
}
struct CheckedReader<'a, R> {
reader: R,
check: &'a dyn Fn() -> Result<(), String>,
}
impl<R: Read> Read for CheckedReader<'_, R> {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
(self.check)().map_err(io::Error::other)?;
self.reader.read(buffer)
}
}
impl<R: Seek> Seek for CheckedReader<'_, R> {
fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
(self.check)().map_err(io::Error::other)?;
self.reader.seek(position)
}
}
struct TagReader<'a, R> {
reader: BufReader<CheckedReader<'a, R>>,
position: u64,
}
impl<R: Read> Read for TagReader<'_, R> {
fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
(self.reader.get_ref().check)().map_err(io::Error::other)?;
let count = self.reader.read(bytes)?;
self.position += count as u64;
Ok(count)
}
}
impl<R: Read + Seek> Seek for TagReader<'_, R> {
fn seek(&mut self, offset: SeekFrom) -> io::Result<u64> {
(self.reader.get_ref().check)().map_err(io::Error::other)?;
let target = match offset {
SeekFrom::Start(position) => position,
SeekFrom::Current(delta) => self
.position
.checked_add_signed(delta)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid tag seek"))?,
SeekFrom::End(_) => {
self.position = self.reader.seek(offset)?;
return Ok(self.position);
}
};
if let Ok(delta) = i64::try_from(i128::from(target) - i128::from(self.position)) {
self.reader.seek_relative(delta)?;
} else {
self.reader.seek(SeekFrom::Start(target))?;
}
self.position = target;
Ok(target)
}
fn stream_position(&mut self) -> io::Result<u64> {
(self.reader.get_ref().check)().map_err(io::Error::other)?;
Ok(self.position)
}
}
pub fn read_audio_tags(
reader: &mut (impl Read + Seek),
format: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<AudioMetadata, String> {
read_tags(reader, format, check, None)
}
fn read_tags(
reader: &mut (impl Read + Seek),
format: &str,
check: &dyn Fn() -> Result<(), String>,
cover: Option<&mut Option<CoverArt>>,
) -> Result<AudioMetadata, String> {
check()?;
reader
.seek(SeekFrom::Start(0))
.map_err(|error| error.to_string())?;
let mut reader = TagReader {
reader: BufReader::new(CheckedReader { reader, check }),
position: 0,
};
let result = match format {
"flac" => match cover {
Some(cover) => containers::flac_with_cover(&mut reader, cover),
None => containers::flac(&mut reader),
},
"m4a" | "mp4" | "aac" => mp4::read(&mut reader),
"mp3" => id3::read(&mut reader),
"ogg" | "opus" => containers::ogg(&mut reader),
"ape" | "wv" | "mpc" => containers::ape(&mut reader),
"wav" => containers::riff(&mut reader, false),
"aiff" | "aif" | "aifc" => containers::riff(&mut reader, true),
_ => Err("unsupported audio tag format".into()),
};
check()?;
result
}
fn exact(reader: &mut impl Read, bytes: &mut [u8]) -> Result<(), String> {
let mut offset = 0;
while offset < bytes.len() {
match reader
.read(&mut bytes[offset..])
.map_err(|error| error.to_string())?
{
0 => return Err(if offset == 0 { "EOF" } else { "unexpected EOF" }.into()),
count => offset += count,
}
}
Ok(())
}
fn bytes(reader: &mut impl Read, length: usize) -> Result<Vec<u8>, String> {
let mut bytes = vec![0; length];
exact(reader, &mut bytes)?;
Ok(bytes)
}
fn seek(reader: &mut impl Seek, position: SeekFrom) -> Result<u64, String> {
reader.seek(position).map_err(|error| error.to_string())
}
fn number(value: &str) -> i64 {
match value.trim().parse::<isize>() {
Ok(value) => value as i64,
Err(error) => match error.kind() {
std::num::IntErrorKind::PosOverflow => isize::MAX as i64,
std::num::IntErrorKind::NegOverflow => isize::MIN as i64,
_ => 0,
},
}
}
fn pair(value: &str) -> (i64, i64) {
let value = value.trim();
match value.find('/').filter(|index| *index > 0) {
Some(index) => (number(&value[..index]), number(&value[index + 1..])),
None => (number(value), 0),
}
}
fn truthy(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "explicit"
)
}
@@ -0,0 +1,242 @@
use super::{AudioMetadata, pair, truthy, utf8};
use crate::matching::{lowercase, uppercase};
pub(super) fn parse(data: &[u8], max_comment: usize) -> Vec<(String, String)> {
let Some(length) = data.get(..4) else {
return vec![];
};
let vendor = u32::from_le_bytes(length.try_into().unwrap()) as usize;
let Some(end) = vendor.checked_add(8) else {
return vec![];
};
let Some(count) = data.get(end - 4..end) else {
return vec![];
};
let count = u32::from_le_bytes(count.try_into().unwrap());
let mut offset = end;
let mut items = Vec::new();
let count = if max_comment == usize::MAX {
count
} else {
count.min(100)
};
for _ in 0..count {
let Some(length) = data.get(offset..offset.saturating_add(4)) else {
break;
};
let length = u32::from_le_bytes(length.try_into().unwrap()) as usize;
offset += 4;
let Some(value) = data.get(offset..offset.saturating_add(length)) else {
break;
};
offset += length;
if length <= max_comment
&& let Some(index) = value.iter().position(|value| *value == b'=')
{
items.push((utf8(&value[..index]), utf8(&value[index + 1..])));
}
}
items
}
fn joined<'a>(items: impl Iterator<Item = &'a str>) -> String {
let mut seen = std::collections::BTreeSet::new();
items
.map(str::trim)
.filter(|value| !value.is_empty() && seen.insert(lowercase(value)))
.collect::<Vec<_>>()
.join(", ")
}
pub(super) fn flac(metadata: &mut AudioMetadata, items: &[(String, String)]) {
let all = |name: &'static str| {
items
.iter()
.filter(move |(key, _)| key.len() == name.len() && uppercase(key) == name)
.map(|(_, value)| value.as_str())
};
let get = |name| all(name).next().unwrap_or("").to_owned();
metadata.title = get("TITLE");
metadata.artist = joined(all("ARTIST"));
metadata.album = get("ALBUM");
metadata.album_artist = ["ALBUMARTIST", "ALBUM ARTIST", "ALBUM_ARTIST"]
.into_iter()
.map(|name| joined(all(name)))
.find(|value| !value.is_empty())
.unwrap_or_default();
metadata.date = get("DATE");
if metadata.date.is_empty() {
metadata.date = get("YEAR");
}
metadata.isrc = get("ISRC");
metadata.lyrics = ["LYRICS", "UNSYNCEDLYRICS", "SYNCEDLYRICS"]
.into_iter()
.map(get)
.find(|value| !value.trim().is_empty())
.unwrap_or_default();
for (names, target, total) in [
(
["TRACKNUMBER", "TRACK"],
&mut metadata.track_number,
&mut metadata.total_tracks,
),
(
["DISCNUMBER", "DISC"],
&mut metadata.disc_number,
&mut metadata.total_discs,
),
] {
for (index, name) in names.into_iter().enumerate() {
if index == 1 && *target != 0 {
break;
}
let value = get(name);
if !value.is_empty() {
(*target, *total) = pair(&value);
}
}
}
metadata.genre = get("GENRE");
metadata.label = ["ORGANIZATION", "LABEL", "PUBLISHER"]
.into_iter()
.map(get)
.find(|value| !value.is_empty())
.unwrap_or_default();
metadata.copyright = get("COPYRIGHT");
metadata.composer = get("COMPOSER");
metadata.comment = get("COMMENT");
metadata.explicit = truthy(&get("ITUNESADVISORY"));
metadata.album_type = get("RELEASETYPE");
if metadata.album_type.is_empty() && truthy(&get("COMPILATION")) {
metadata.album_type = "compilation".into();
}
metadata.upc = get("BARCODE");
if metadata.upc.is_empty() {
metadata.upc = get("UPC");
}
metadata.replay_gain_track_gain = get("REPLAYGAIN_TRACK_GAIN");
metadata.replay_gain_track_peak = get("REPLAYGAIN_TRACK_PEAK");
metadata.replay_gain_album_gain = get("REPLAYGAIN_ALBUM_GAIN");
metadata.replay_gain_album_peak = get("REPLAYGAIN_ALBUM_PEAK");
}
pub(super) fn apply(metadata: &mut AudioMetadata, key: &str, value: &str, ape: bool) {
let key = uppercase(if ape { key.trim() } else { key });
let value = if ape { value.trim() } else { value };
if ape && value.is_empty() {
return;
}
match key.as_str() {
"TITLE" => metadata.title = value.into(),
"ARTIST" => metadata.artist = value.into(),
"ALBUM" => metadata.album = value.into(),
"ALBUMARTIST" | "ALBUM ARTIST" => metadata.album_artist = value.into(),
"ALBUM_ARTIST" if !ape => metadata.album_artist = value.into(),
"DATE" | "YEAR" => {
if ape {
if key == "YEAR" {
metadata.year = value.into();
} else {
metadata.date = value.into();
}
} else {
metadata.date = value.into();
if value.len() >= 4 {
metadata.year = utf8(&value.as_bytes()[..4]);
}
}
}
"GENRE" => metadata.genre = value.into(),
"TRACK" | "TRACKNUMBER" => (metadata.track_number, metadata.total_tracks) = pair(value),
"DISC" | "DISCNUMBER" => (metadata.disc_number, metadata.total_discs) = pair(value),
"ISRC" => metadata.isrc = value.into(),
"COMPOSER" => metadata.composer = value.into(),
"COMMENT" => metadata.comment = value.into(),
"DESCRIPTION" if !ape => metadata.comment = value.into(),
"LYRICS" | "UNSYNCEDLYRICS" | "SYNCEDLYRICS" if metadata.lyrics.is_empty() => {
metadata.lyrics = value.into()
}
"LABEL" | "PUBLISHER" => metadata.label = value.into(),
"ORGANIZATION" if !ape => metadata.label = value.into(),
"COPYRIGHT" => metadata.copyright = value.into(),
"ITUNESADVISORY" => metadata.explicit = truthy(value),
"RELEASETYPE" => metadata.album_type = value.into(),
"BARCODE" | "UPC" => metadata.upc = value.into(),
"COMPILATION" if truthy(value) && metadata.album_type.is_empty() => {
metadata.album_type = "compilation".into()
}
"REPLAYGAIN_TRACK_GAIN" => metadata.replay_gain_track_gain = value.into(),
"REPLAYGAIN_TRACK_PEAK" => metadata.replay_gain_track_peak = value.into(),
"REPLAYGAIN_ALBUM_GAIN" => metadata.replay_gain_album_gain = value.into(),
"REPLAYGAIN_ALBUM_PEAK" => metadata.replay_gain_album_peak = value.into(),
"R128_TRACK_GAIN" | "R128_ALBUM_GAIN" if !ape => {
let target = if key == "R128_TRACK_GAIN" {
&mut metadata.replay_gain_track_gain
} else {
&mut metadata.replay_gain_album_gain
};
if target.is_empty()
&& let Ok(value) = value.trim().parse::<i16>()
{
*target = format!("{:.2} dB", f64::from(value) / 256.0 + 5.0);
}
}
_ => {}
}
}
pub(super) fn ape_key(key: &str) -> bool {
matches!(
uppercase(key.trim()).as_str(),
"TITLE"
| "ARTIST"
| "ALBUM"
| "ALBUMARTIST"
| "ALBUM ARTIST"
| "GENRE"
| "YEAR"
| "DATE"
| "TRACK"
| "TRACKNUMBER"
| "DISC"
| "DISCNUMBER"
| "ISRC"
| "LYRICS"
| "UNSYNCEDLYRICS"
| "SYNCEDLYRICS"
| "LABEL"
| "PUBLISHER"
| "COPYRIGHT"
| "COMPOSER"
| "COMMENT"
| "ITUNESADVISORY"
| "RELEASETYPE"
| "BARCODE"
| "UPC"
| "COMPILATION"
| "REPLAYGAIN_TRACK_GAIN"
| "REPLAYGAIN_TRACK_PEAK"
| "REPLAYGAIN_ALBUM_GAIN"
| "REPLAYGAIN_ALBUM_PEAK"
)
}
pub(super) fn ogg(items: &[(String, String)]) -> AudioMetadata {
let mut metadata = AudioMetadata::default();
let mut artists = Vec::new();
let mut album_artists = Vec::new();
for (key, value) in items {
match uppercase(key).as_str() {
"ARTIST" => artists.push(value.as_str()),
"ALBUMARTIST" | "ALBUM_ARTIST" | "ALBUM ARTIST" => album_artists.push(value.as_str()),
_ => apply(&mut metadata, key, value, false),
}
}
if !artists.is_empty() {
metadata.artist = joined(artists.into_iter());
}
if !album_artists.is_empty() {
metadata.album_artist = joined(album_artists.into_iter());
}
metadata
}
@@ -0,0 +1,552 @@
use super::{AudioMetadata, CoverArt, bytes, comments, exact, id3, seek, utf8};
use base64::Engine;
use std::collections::BTreeMap;
use std::io::{Cursor, Read, Seek, SeekFrom};
pub(super) fn flac(reader: &mut (impl Read + Seek)) -> Result<AudioMetadata, String> {
flac_inner(reader, None).map_err(|error| format!("failed to parse FLAC file: {error}"))
}
pub(super) fn flac_cover(reader: &mut (impl Read + Seek)) -> Result<CoverArt, String> {
let mut cover = None;
flac_with_cover(reader, &mut cover)?;
cover.ok_or_else(|| "no cover art found in file".into())
}
pub(super) fn flac_with_cover(
reader: &mut (impl Read + Seek),
cover: &mut Option<CoverArt>,
) -> Result<AudioMetadata, String> {
let metadata = flac_inner(reader, Some(cover))
.map_err(|error| format!("failed to parse FLAC file: {error}"))?;
if let Some(cover) = cover {
cover.mime = if cover.data.len() > 8 && &cover.data[1..4] == b"PNG" {
"image/png"
} else {
"image/jpeg"
}
.into();
}
Ok(metadata)
}
fn flac_inner(
reader: &mut (impl Read + Seek),
mut cover: Option<&mut Option<CoverArt>>,
) -> Result<AudioMetadata, String> {
let mut signature = [0; 4];
exact(reader, &mut signature)?;
if &signature != b"fLaC" {
return Err("fLaC head incorrect".into());
}
let end = seek(reader, SeekFrom::End(0))?;
seek(reader, SeekFrom::Start(4))?;
let mut metadata = AudioMetadata::default();
let mut found = false;
let mut front_cover = false;
loop {
let mut header = [0; 4];
exact(reader, &mut header)?;
let length = u32::from_be_bytes([0, header[1], header[2], header[3]]);
let position = reader
.stream_position()
.map_err(|error| error.to_string())?;
if position + u64::from(length) > end {
return Err(if position == end {
"EOF"
} else {
"unexpected EOF"
}
.into());
}
if header[0] & 0x7f == 4 && !found {
let data = bytes(reader, length as usize)?;
// FLAC uses the first valid Vorbis block; later blocks do not merge.
if valid_comments(&data) {
comments::flac(&mut metadata, &comments::parse(&data, usize::MAX));
found = true;
}
} else if header[0] & 0x7f == 6 && !front_cover && cover.is_some() {
let data = bytes(reader, length as usize)?;
if let Some((kind, picture)) = picture_block(&data, false)
&& let Some(cover) = cover.as_deref_mut()
&& (kind == 3 || cover.is_none())
{
front_cover = kind == 3;
*cover = Some(picture);
}
} else {
seek(reader, SeekFrom::Current(i64::from(length)))?;
}
if header[0] & 0x80 != 0 {
let mut sync = [0; 2];
exact(reader, &mut sync)?;
if sync[0] != 0xff || sync[1] >> 2 != 0x3e {
return Err("frames do not begin with sync code".into());
}
return Ok(metadata);
}
}
}
fn picture_block(data: &[u8], ogg_limits: bool) -> Option<(u32, CoverArt)> {
let parse = || -> Result<(u32, CoverArt), String> {
let mut input = Cursor::new(data);
let kind = u32::from_be_bytes(bytes(&mut input, 4)?.try_into().unwrap());
let mime_length = u32::from_be_bytes(bytes(&mut input, 4)?.try_into().unwrap()) as usize;
if mime_length > data.len().saturating_sub(8) || ogg_limits && mime_length > 256 {
return Err("invalid picture MIME length".into());
}
let mime = utf8(&bytes(&mut input, mime_length)?);
let description = u32::from_be_bytes(bytes(&mut input, 4)?.try_into().unwrap());
if ogg_limits && description > 10000 {
return Err("invalid picture description length".into());
}
seek(&mut input, SeekFrom::Current(i64::from(description) + 16))?;
let length = u32::from_be_bytes(bytes(&mut input, 4)?.try_into().unwrap()) as usize;
if length == 0
|| length > data.len().saturating_sub(input.position() as usize)
|| ogg_limits && length > 10_000_000
{
return Err("invalid picture data length".into());
}
Ok((
kind,
CoverArt {
data: bytes(&mut input, length)?,
mime,
},
))
};
parse().ok()
}
fn valid_comments(data: &[u8]) -> bool {
let Some(vendor) = data.get(..4) else {
return false;
};
let Some(mut offset) = (u32::from_le_bytes(vendor.try_into().unwrap()) as usize).checked_add(8)
else {
return false;
};
let Some(count) = data.get(offset - 4..offset) else {
return false;
};
for _ in 0..u32::from_le_bytes(count.try_into().unwrap()) {
let Some(length) = data.get(offset..offset.saturating_add(4)) else {
return false;
};
let length = u32::from_le_bytes(length.try_into().unwrap()) as usize;
offset = offset.saturating_add(4).saturating_add(length);
// bytes.Reader.Read reports EOF even for a zero-length comment when
// its length field exhausted the block; Go skips that invalid block.
if offset > data.len() || length == 0 && offset == data.len() {
return false;
}
}
true
}
#[derive(Clone)]
pub(crate) struct OggPage {
pub(crate) header: [u8; 27],
pub(crate) segments: Vec<u8>,
pub(crate) data: Vec<u8>,
}
impl OggPage {
pub(crate) fn read(reader: &mut impl Read) -> Result<Option<Self>, String> {
let mut header = [0; 27];
let mut offset = 0;
while offset < header.len() {
let count = reader
.read(&mut header[offset..])
.map_err(|error| error.to_string())?;
if count == 0 {
return if offset == 0 {
Ok(None)
} else {
Err("unexpected EOF".into())
};
}
offset += count;
}
if &header[..4] != b"OggS" {
return Err("not an Ogg page".into());
}
let segments = bytes(reader, header[26] as usize)?;
let data = bytes(reader, segments.iter().map(|n| *n as usize).sum())?;
Ok(Some(Self {
header,
segments,
data,
}))
}
pub(crate) fn len(&self) -> usize {
27 + self.segments.len() + self.data.len()
}
}
pub(crate) fn ogg_packets(
reader: &mut impl Read,
max_packets: usize,
max_pages: usize,
) -> Result<Vec<Vec<u8>>, String> {
let mut packets = Vec::new();
let mut current = Vec::new();
let mut skip_packet = false;
for _ in 0..max_pages {
let page = OggPage::read(reader).and_then(|page| page.ok_or_else(|| "EOF".into()));
let OggPage {
header,
segments,
data,
} = match page {
Ok(page) => page,
Err(error) if packets.is_empty() => return Err(error),
Err(_) => break,
};
if header[5] & 1 == 0 && !current.is_empty() {
current.clear();
skip_packet = false;
}
let mut offset = 0;
for length in segments {
let length = length as usize;
if !skip_packet && current.len() + length > 10 * 1024 * 1024 {
current.clear();
skip_packet = true;
}
if !skip_packet {
current.extend_from_slice(&data[offset..offset + length]);
}
offset += length;
if length < 255 {
if !current.is_empty() {
packets.push(std::mem::take(&mut current));
}
skip_packet = false;
if packets.len() >= max_packets {
break;
}
}
}
if packets.len() >= max_packets {
break;
}
}
Ok(packets)
}
pub(crate) fn ogg_stream_is_opus(packets: &[Vec<u8>]) -> Option<bool> {
packets.iter().find_map(|packet| {
if packet.starts_with(b"OpusHead") {
Some(true)
} else if packet.len() > 7 && packet.starts_with(b"\x01vorbis") {
Some(false)
} else {
None
}
})
}
pub(super) fn ogg(reader: &mut impl Read) -> Result<AudioMetadata, String> {
let packets = ogg_packets(reader, 30, 80)?;
let stream = ogg_stream_is_opus(&packets);
let packet = packets.iter().find_map(|packet| {
if stream != Some(false) && packet.len() > 8 && packet.starts_with(b"OpusTags") {
Some(&packet[8..])
} else if stream != Some(true) && packet.len() > 7 && packet.starts_with(b"\x03vorbis") {
Some(&packet[7..])
} else {
None
}
});
let metadata = packet
.map(|data| comments::ogg(&comments::parse(data, 512 * 1024)))
.unwrap_or_default();
if metadata.title.is_empty()
&& metadata.artist.is_empty()
&& metadata.replay_gain_track_gain.is_empty()
&& metadata.replay_gain_album_gain.is_empty()
{
Err("no Vorbis comments found".into())
} else {
Ok(metadata)
}
}
pub(super) fn ogg_cover(reader: &mut impl Read) -> Result<CoverArt, String> {
let packets = ogg_packets(reader, 30, 80)?;
let stream = ogg_stream_is_opus(&packets);
for packet in &packets {
let comments = if stream != Some(false) && packet.starts_with(b"OpusTags") {
&packet[8..]
} else if stream != Some(true) && packet.starts_with(b"\x03vorbis") {
&packet[7..]
} else {
continue;
};
for (key, value) in comments::parse(comments, 10_000_000) {
if !key.eq_ignore_ascii_case("METADATA_BLOCK_PICTURE") {
continue;
}
let value: Vec<_> = value
.bytes()
.filter(|byte| !matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
.collect();
let decoded = base64::engine::general_purpose::STANDARD
.decode(&value)
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&value));
if let Ok(decoded) = decoded
&& let Some((_, cover)) = picture_block(&decoded, true)
{
return Ok(cover);
}
}
}
Err("no cover art found".into())
}
pub(super) fn ape(reader: &mut (impl Read + Seek)) -> Result<AudioMetadata, String> {
let end = seek(reader, SeekFrom::End(0))?;
if end < 32 {
return Err("file too small for APE tag".into());
}
for offset in [Some(end - 32), end.checked_sub(161).map(|_| end - 160)]
.into_iter()
.flatten()
{
if let Ok(metadata) = ape_at(reader, offset) {
return Ok(metadata);
}
}
Err("no APEv2 tag found".into())
}
pub(crate) struct ApeFooter {
pub(crate) version: u32,
pub(crate) size: u32,
pub(crate) count: u32,
pub(crate) flags: u32,
}
impl ApeFooter {
pub(crate) fn read(
reader: &mut (impl Read + Seek),
offset: u64,
) -> Result<Option<Self>, String> {
seek(reader, SeekFrom::Start(offset))?;
let data = bytes(reader, 32)?;
let integer = |index| u32::from_le_bytes(data[index..index + 4].try_into().unwrap());
if &data[..8] != b"APETAGEX" || integer(20) & (1 << 29) != 0 {
return Ok(None);
}
Ok(Some(Self {
version: integer(8),
size: integer(12),
count: integer(16),
flags: integer(20),
}))
}
pub(crate) fn items_start(&self, offset: u64) -> Result<u64, String> {
if !matches!(self.version, 1000 | 2000) || self.size < 32 || self.count > 1000 {
return Err("invalid APE footer".into());
}
offset
.checked_sub(u64::from(self.size - 32))
.ok_or_else(|| "invalid APE items offset".into())
}
}
fn ape_at(reader: &mut (impl Read + Seek), footer: u64) -> Result<AudioMetadata, String> {
let header = ApeFooter::read(reader, footer)?.ok_or("invalid APE footer")?;
let start = header.items_start(footer)?;
seek(reader, SeekFrom::Start(start))?;
let mut remaining = footer - start;
let mut metadata = AudioMetadata::default();
for _ in 0..header.count {
if remaining < 8 {
break;
}
let header = bytes(reader, 8)?;
remaining -= 8;
let length = u64::from(u32::from_le_bytes(header[..4].try_into().unwrap()));
let mut key = Vec::new();
let mut terminated = false;
while remaining > 0 {
let mut value = [0];
exact(reader, &mut value)?;
remaining -= 1;
if value[0] == 0 {
terminated = true;
break;
}
// Unknown overlong keys cannot match any supported metadata field.
if key.len() <= 1024 {
key.push(value[0]);
}
}
if !terminated || length > remaining {
break;
}
if length <= 32 * 1024 * 1024 && key.len() <= 1024 && comments::ape_key(&utf8(&key)) {
let value = bytes(reader, length as usize)?;
comments::apply(&mut metadata, &utf8(&key), &utf8(&value), true);
} else {
seek(reader, SeekFrom::Current(length as i64))?;
}
remaining -= length;
}
Ok(metadata)
}
pub(super) fn riff(reader: &mut (impl Read + Seek), aiff: bool) -> Result<AudioMetadata, String> {
let (tag, info) = riff_chunks(reader, aiff)?;
riff_metadata(&tag, &info, aiff)
}
pub(super) fn riff_cover(reader: &mut (impl Read + Seek), aiff: bool) -> Result<CoverArt, String> {
let (tag, _) = riff_chunks(reader, aiff).map_err(|_| "no embedded cover")?;
let (data, mime) = super::write::embedded_cover(&tag).ok_or("no embedded cover")?;
Ok(CoverArt { data, mime })
}
fn riff_chunks(
reader: &mut (impl Read + Seek),
aiff: bool,
) -> Result<(Vec<u8>, BTreeMap<String, String>), String> {
let header = bytes(reader, 12)?;
if if aiff {
&header[..4] != b"FORM" || !matches!(&header[8..], b"AIFF" | b"AIFC")
} else {
&header[..4] != b"RIFF" || &header[8..] != b"WAVE"
} {
return Err(if aiff {
"not an AIFF file"
} else {
"not a WAVE file"
}
.into());
}
let mut tag = Vec::new();
let mut info = BTreeMap::<String, String>::new();
loop {
let mut header = [0; 8];
if exact(reader, &mut header).is_err() {
break;
}
let length = if aiff {
u32::from_be_bytes(header[4..].try_into().unwrap())
} else {
u32::from_le_bytes(header[4..].try_into().unwrap())
};
let wanted = matches!(&header[..4], b"ID3 " | b"id3 ")
|| if aiff {
matches!(&header[..4], b"NAME" | b"AUTH" | b"ANNO" | b"(c) ")
} else {
&header[..4] == b"LIST"
};
if wanted && length > 0 && length <= 16 * 1024 * 1024 {
if let Ok(data) = bytes(reader, length as usize) {
if matches!(&header[..4], b"ID3 " | b"id3 ") {
tag = data;
} else if aiff {
info.insert(
utf8(&header[..4]),
utf8(&data).trim().trim_end_matches('\0').into(),
);
} else {
riff_info(&data, &mut info);
}
}
seek(reader, SeekFrom::Current(i64::from(length & 1)))?;
} else {
seek(
reader,
SeekFrom::Current(i64::from(length) + i64::from(length & 1)),
)?;
}
}
Ok((tag, info))
}
fn riff_metadata(
tag: &[u8],
info: &BTreeMap<String, String>,
aiff: bool,
) -> Result<AudioMetadata, String> {
if let Ok(metadata) = id3::embedded(tag)
&& has_tags(&metadata)
{
return Ok(metadata);
}
let get = |key| info.get(key).cloned().unwrap_or_default();
let metadata = if aiff {
AudioMetadata {
title: get("NAME"),
artist: get("AUTH"),
comment: get("ANNO"),
copyright: get("(c) "),
..AudioMetadata::default()
}
} else {
let date = get("ICRD");
AudioMetadata {
title: get("INAM"),
artist: get("IART"),
album: get("IPRD"),
genre: id3::genre(&get("IGNR")),
year: if date.len() >= 4 {
utf8(&date.as_bytes()[..4])
} else {
String::new()
},
date,
comment: get("ICMT"),
copyright: get("ICOP"),
composer: get("IMUS"),
track_number: get("ITRK").trim().parse::<isize>().unwrap_or(0) as i64,
..AudioMetadata::default()
}
};
if has_tags(&metadata) {
Ok(metadata)
} else {
Err(if aiff {
"no AIFF tags found"
} else {
"no WAV tags found"
}
.into())
}
}
fn has_tags(metadata: &AudioMetadata) -> bool {
!metadata.title.is_empty() || !metadata.artist.is_empty() || !metadata.album.is_empty()
}
fn riff_info(data: &[u8], info: &mut BTreeMap<String, String>) {
if !data.starts_with(b"INFO") {
return;
}
let mut offset: usize = 4;
while let Some(header) = data.get(offset..offset.saturating_add(8)) {
let length = u32::from_le_bytes(header[4..].try_into().unwrap()) as usize;
offset += 8;
if length == 0 {
break;
}
let Some(value) = data.get(offset..offset.saturating_add(length)) else {
break;
};
info.insert(
utf8(&header[..4]),
utf8(value).trim_end_matches('\0').trim().into(),
);
offset += length;
offset = offset.saturating_add(length & 1);
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Application metadata JSON, preserving the distinct legacy tag-reader JSON.
use super::{AudioMetadata, read_audio_tags};
use crate::media::{mp3_quality, ogg_quality, probe_mp4_quality, probe_quality, riff_quality};
use serde_json::{Map, Value};
use std::io::{self, Read, Seek, SeekFrom};
/// Read the public ReadFileMetadataWithHint payload from an already-owned file.
/// The path remains its identity; a display hint supplies only a missing suffix.
pub fn read_file_metadata(
file: &mut (impl Read + Seek),
path: &str,
hint: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Value, String> {
check()?;
let mut file = ObservedReader {
file,
check,
failure: None,
};
let result = read_metadata(&mut file, path, hint);
// Parsers tolerate absent/corrupt tags, but must not turn an OS error or
// cancellation into success, even if the parser swallowed a read error.
if let Some(error) = file.failure {
return Err(error);
}
check()?;
result
}
fn read_metadata(file: &mut (impl Read + Seek), path: &str, hint: &str) -> Result<Value, String> {
let extension = file_metadata_extension(path, hint)?;
let (mut format, mut codec) = match extension.as_str() {
".flac" => ("flac", "flac"),
".m4a" | ".mp4" | ".aac" => ("m4a", ""),
".mp3" => ("mp3", "mp3"),
".ogg" | ".opus" => ("opus", "opus"),
".ape" => ("ape", "ape"),
".wv" => ("wv", "wv"),
".mpc" => ("mpc", "mpc"),
".wav" => ("wav", "pcm"),
".aiff" | ".aif" | ".aifc" => ("aiff", "pcm"),
_ => unreachable!("validated metadata extension"),
};
let size = file.seek(SeekFrom::End(0)).map_err(|e| e.to_string())? as i64;
let mut metadata = read_audio_tags(file, &extension[1..], &|| Ok(()));
if format == "flac" && metadata.is_err() {
let fallback = read_audio_tags(file, "ogg", &|| Ok(()));
if fallback.is_ok() {
metadata = fallback;
format = "opus";
codec = "opus";
} else {
return Err(format!(
"failed to read metadata: {}",
metadata.unwrap_err()
));
}
}
let mut result = match metadata {
Ok(metadata) => tag_fields(metadata, format != "flac")?,
Err(_) => {
let mut fields = tag_fields(AudioMetadata::default(), false)?;
fields.retain(|key, _| !key.starts_with("replaygain_"));
fields
}
};
result.insert("format".into(), format.into());
result.insert("audio_codec".into(), codec.into());
result.insert("duration".into(), 0.into());
file.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?;
let quality = match format {
"flac" => probe_quality(file, &|| Ok(())),
"m4a" => probe_mp4_quality(file, &|| Ok(())),
"mp3" => mp3_quality(file, size),
"opus" => ogg_quality(file, size, path),
"wav" | "aiff" => riff_quality(file, size, format == "aiff"),
_ => return Ok(result.into()),
};
if let Ok(quality) = quality {
result.insert("sample_rate".into(), quality.sample_rate.into());
result.insert("duration".into(), quality.duration.into());
if format != "opus" {
result.insert("bit_depth".into(), quality.bit_depth.into());
}
let bitrate = match format {
"flac" => {
if !quality.codec.is_empty() {
result.insert("audio_codec".into(), quality.codec.into());
}
if size > 0 && quality.sample_rate > 0 && quality.total_samples > 0 {
Some(
(size as f64 * 8.0
/ (quality.total_samples as f64 / quality.sample_rate as f64)
/ 1000.0) as i64,
)
} else {
None
}
}
"m4a" => {
let mapped = match quality.codec.trim().to_lowercase().as_str() {
"flac" => "flac",
"alac" => "alac",
"eac3" | "ec-3" => "eac3",
"ac3" | "ac-3" => "ac3",
"ac4" | "ac-4" => "ac4",
"opus" => "opus",
_ => "m4a",
};
result.insert("format".into(), mapped.into());
result.insert("audio_codec".into(), quality.codec.into());
if quality.bitrate > 0 && !matches!(mapped, "flac" | "alac") {
Some(quality.bitrate)
} else if quality.duration > 0 && size > 0 {
Some((size as f64 * 8.0 / quality.duration as f64 / 1000.0) as i64)
} else {
None
}
}
"mp3" | "opus" if quality.bitrate > 0 => Some(quality.bitrate / 1000),
_ => None,
};
if let Some(bitrate) = bitrate {
result.insert("bitrate".into(), bitrate.into());
}
}
Ok(result.into())
}
/// Validate before opening the native path: Go rejects unsupported suffixes
/// even when that path does not exist or cannot be opened.
pub fn file_metadata_extension(path: &str, hint: &str) -> Result<String, String> {
let path_ext = extension(path);
let suffix = if path_ext.is_empty() {
extension(hint)
} else {
path_ext
}
.to_lowercase();
match suffix.as_str() {
".flac" | ".m4a" | ".mp4" | ".aac" | ".mp3" | ".ogg" | ".opus" | ".ape" | ".wv"
| ".mpc" | ".wav" | ".aiff" | ".aif" | ".aifc" => Ok(suffix),
_ => Err(format!("unsupported file format: {path}")),
}
}
fn tag_fields(
mut metadata: AudioMetadata,
year_fallback: bool,
) -> Result<Map<String, Value>, String> {
if year_fallback && metadata.date.is_empty() {
metadata.date = metadata.year.clone();
}
let Value::Object(mut fields) = serde_json::to_value(metadata).map_err(|e| e.to_string())?
else {
unreachable!("AudioMetadata serializes as an object");
};
fields.remove("year");
for suffix in ["track_gain", "track_peak", "album_gain", "album_peak"] {
if let Some(value) = fields.remove(&format!("replay_gain_{suffix}")) {
fields.insert(format!("replaygain_{suffix}"), value);
}
}
Ok(fields)
}
// filepath.Ext includes a leading dotfile and a trailing dot, unlike Path::extension.
fn extension(path: &str) -> &str {
let name = path.rsplit('/').next().unwrap_or_default();
name.rfind('.').map_or("", |index| &name[index..])
}
pub(super) struct ObservedReader<'a, R> {
pub(super) file: &'a mut R,
pub(super) check: &'a dyn Fn() -> Result<(), String>,
pub(super) failure: Option<String>,
}
impl<R> ObservedReader<'_, R> {
fn check(&mut self) -> io::Result<()> {
if let Some(error) = &self.failure {
return Err(io::Error::other(error.clone()));
}
(self.check)().map_err(|error| {
self.failure = Some(error.clone());
io::Error::other(error)
})
}
fn record<T>(&mut self, result: io::Result<T>) -> io::Result<T> {
if let Err(error) = &result {
self.failure = Some(error.to_string());
}
result
}
}
impl<R: Read> Read for ObservedReader<'_, R> {
fn read(&mut self, data: &mut [u8]) -> io::Result<usize> {
self.check()?;
let result = self.file.read(data);
self.record(result)
}
}
impl<R: Seek> Seek for ObservedReader<'_, R> {
fn seek(&mut self, offset: SeekFrom) -> io::Result<u64> {
self.check()?;
let result = self.file.seek(offset);
self.record(result)
}
}
+675
View File
@@ -0,0 +1,675 @@
use super::{AudioMetadata, CoverArt, bytes, exact, pair, seek, truthy, utf8};
use crate::matching::{lowercase, uppercase};
use std::io::{self, Cursor, Read, Seek, SeekFrom};
// ID3v2.2/2.3 global unsynchronization also covers frame headers. Decode
// incrementally so skipping artwork never requires a tag-sized allocation.
struct Input<'a, R> {
source: &'a mut R,
remaining: u64,
unsync: bool,
after_ff: bool,
}
impl<R: Read> Read for Input<'_, R> {
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
if !self.unsync {
let length = output.len().min(self.remaining as usize);
let count = self.source.read(&mut output[..length])?;
self.remaining -= count as u64;
return Ok(count);
}
let mut count = 0;
while count < output.len() && self.remaining > 0 {
let mut value = [0];
if self.source.read(&mut value)? == 0 {
break;
}
self.remaining -= 1;
if self.after_ff && value[0] == 0 {
self.after_ff = false;
continue;
}
self.after_ff = value[0] == 0xff;
output[count] = value[0];
count += 1;
}
Ok(count)
}
}
impl<R: Read + Seek> Input<'_, R> {
fn skip(&mut self, length: u64) -> Result<(), String> {
if !self.unsync {
seek(self.source, SeekFrom::Current(length as i64))?;
self.remaining = self.remaining.saturating_sub(length);
return Ok(());
}
let mut left = length;
let mut buffer = [0; 8192];
while left > 0 {
let count = left.min(buffer.len() as u64) as usize;
exact(self, &mut buffer[..count])?;
left -= count as u64;
}
Ok(())
}
}
pub(super) fn read(reader: &mut (impl Read + Seek)) -> Result<AudioMetadata, String> {
let mut metadata = v2(reader).unwrap_or_default();
if (metadata.title.is_empty() || metadata.artist.is_empty())
&& let Ok(old) = v1(reader)
{
for (target, value) in [
(&mut metadata.title, old.title),
(&mut metadata.artist, old.artist),
(&mut metadata.album, old.album),
(&mut metadata.year, old.year),
(&mut metadata.genre, old.genre),
] {
if target.is_empty() {
*target = value;
}
}
}
if metadata.title.is_empty() && metadata.artist.is_empty() {
Err("no ID3 tags found".into())
} else {
Ok(metadata)
}
}
fn v2(reader: &mut (impl Read + Seek)) -> Result<AudioMetadata, String> {
v2_with_cover(reader, false).map(|(metadata, _)| metadata)
}
pub(super) fn cover(reader: &mut (impl Read + Seek)) -> Result<CoverArt, String> {
v2_with_cover(reader, true)?
.1
.ok_or_else(|| "no cover art found".into())
}
fn v2_with_cover(
reader: &mut (impl Read + Seek),
include_cover: bool,
) -> Result<(AudioMetadata, Option<CoverArt>), String> {
let header = bytes(reader, 10)?;
if &header[..3] != b"ID3" {
return Err("no ID3v2 header".into());
}
let version = header[3];
let flags = header[5];
if !(2..=4).contains(&version) || header[6..].iter().any(|value| *value >= 128) {
return Err("invalid ID3 version or tag size".into());
}
let mut size = syncsafe(&header[6..]);
let end = seek(reader, SeekFrom::End(0))?;
if size > end.saturating_sub(10) {
return Err("unexpected EOF".into());
}
seek(reader, SeekFrom::Start(10))?;
let mut input = Input {
source: reader,
remaining: size,
unsync: version < 4 && flags & 0x80 != 0,
after_ff: false,
};
if flags & 0x40 != 0 {
if version == 2 {
return Err("compressed ID3v2.2 tag unsupported".into());
}
let extended = bytes(&mut input, 4)?;
let length = if version == 4 {
syncsafe(&extended).checked_sub(4)
} else {
Some(u64::from(u32::from_be_bytes(extended.try_into().unwrap())))
}
.ok_or("invalid ID3 extended header")?;
if size < 4 || length > size - 4 {
return Err("invalid ID3 extended header".into());
}
input.skip(length)?;
size -= length + 4;
}
let mut metadata = AudioMetadata::default();
let mut cover = None;
// Go retains fields decoded before a malformed later frame.
let result = frames(
&mut input,
size,
version,
version == 4 && flags & 0x80 != 0,
&mut metadata,
include_cover.then_some(&mut cover),
);
if include_cover && cover.is_none() {
result?;
}
Ok((metadata, cover))
}
pub(super) fn embedded(data: &[u8]) -> Result<AudioMetadata, String> {
if data.len() < 10 || &data[..3] != b"ID3" {
return Err("no ID3v2 header".into());
}
let version = data[3];
let flags = data[5];
let size = syncsafe(&data[6..10]) as usize;
let size = if size == 0 || size > data.len() - 10 {
data.len() - 10
} else {
size
};
let mut body = &data[10..10 + size];
if flags & 0x10 != 0 && body.len() >= 10 && body[body.len() - 10..].starts_with(b"3DI") {
body = &body[..body.len() - 10];
}
if flags & 0x40 != 0 && body.len() >= 4 {
let size = match version {
3 => u32::from_be_bytes(body[..4].try_into().unwrap()) as u64,
4 => syncsafe(&body[..4]),
_ => 0,
};
let skip = if size + 4 <= body.len() as u64 {
size + 4
} else {
size
};
if size > 0 && skip < body.len() as u64 {
body = &body[skip as usize..];
}
}
let size = body.len() as u64;
let mut cursor = Cursor::new(body);
let mut input = Input {
source: &mut cursor,
remaining: size,
unsync: false,
after_ff: false,
};
let mut metadata = AudioMetadata::default();
let _ = frames(
&mut input,
size,
version,
flags & 0x80 != 0,
&mut metadata,
None,
);
Ok(metadata)
}
fn frames<R: Read + Seek>(
input: &mut Input<'_, R>,
mut remaining: u64,
version: u8,
unsync: bool,
metadata: &mut AudioMetadata,
mut cover: Option<&mut Option<CoverArt>>,
) -> Result<(), String> {
let (header_size, id_size) = if version == 2 { (6, 3) } else { (10, 4) };
while remaining >= header_size as u64 {
let header = bytes(input, header_size)?;
remaining -= header_size as u64;
if header[0] == 0 || header.starts_with(b"3DI") {
break;
}
let size = match version {
2 => u64::from(u32::from_be_bytes([0, header[3], header[4], header[5]])),
4 => {
if header[4..8].iter().any(|value| *value >= 128) {
return Err("invalid ID3 frame size".into());
}
syncsafe(&header[4..8])
}
_ => u64::from(u32::from_be_bytes(header[4..8].try_into().unwrap())),
};
if size == 0 || size > remaining {
return Err("invalid ID3 frame bounds".into());
}
remaining -= size;
let id = &header[..id_size];
let flags = if version == 2 { 0 } else { header[9] };
let picture = matches!(id, b"APIC" | b"PIC");
let wanted = if picture {
cover.as_ref().is_some_and(|cover| cover.is_none())
} else {
id[0] == b'T' || matches!(id, b"COMM" | b"USLT" | b"ULT")
};
let unsupported =
(version == 3 && flags & 0xc0 != 0) || (version == 4 && flags & 0x0c != 0);
if !wanted || unsupported || size > 32 * 1024 * 1024 {
input.skip(size)?;
continue;
}
let mut data = bytes(input, size as usize)?;
let mut offset =
usize::from((version == 3 && flags & 0x20 != 0) || (version == 4 && flags & 0x40 != 0));
if version == 4 && flags & 1 != 0 {
offset += 4;
}
if offset > data.len() {
continue;
}
if unsync || version == 4 && flags & 2 != 0 {
let mut after_ff = false;
let mut write = offset;
for read in offset..data.len() {
let value = data[read];
if after_ff && value == 0 {
after_ff = false;
continue;
}
after_ff = value == 0xff;
data[write] = value;
write += 1;
}
data.truncate(write);
}
if picture {
if let Some(cover) = cover.as_deref_mut() {
*cover = picture_frame(&data[offset..], version);
}
} else {
apply(metadata, version, id, &data[offset..]);
}
}
Ok(())
}
fn picture_frame(data: &[u8], version: u8) -> Option<CoverArt> {
let encoding = *data.first()?;
let (mime, start) = if version == 2 {
let format = data.get(1..4)?;
(
if format == b"PNG" {
"image/png"
} else {
"image/jpeg"
}
.into(),
5,
)
} else {
let end = data.get(1..)?.iter().position(|byte| *byte == 0)? + 1;
(utf8(&data[1..end]), end + 2)
};
let description = data.get(start..)?;
let skip = if matches!(encoding, 0 | 3) {
description.iter().position(|byte| *byte == 0)? + 1
} else {
description.windows(2).position(|bytes| bytes == [0, 0])? + 2
};
let image = description.get(skip..)?;
(!image.is_empty()).then(|| CoverArt {
data: image.to_vec(),
mime,
})
}
fn apply(metadata: &mut AudioMetadata, version: u8, id: &[u8], data: &[u8]) {
let text = data
.split_first()
.map(|(encoding, body)| decode(*encoding, body))
.unwrap_or_default();
let value = text.split('\0').next().unwrap_or_default();
let id = if version == 2 {
match id {
b"TT2" => b"TIT2".as_slice(),
b"TP1" => b"TPE1",
b"TP2" => b"TPE2",
b"TAL" => b"TALB",
b"TYE" => {
metadata.year = value.into();
return;
}
b"TCO" => b"TCON",
b"TRK" => b"TRCK",
b"TPA" => b"TPOS",
b"TCM" => b"TCOM",
b"TPB" => b"TPUB",
b"TCR" => b"TCOP",
b"ULT" => b"USLT",
b"TXX" => b"TXXX",
_ => return,
}
} else {
id
};
match id {
b"TIT2" => metadata.title = value.into(),
b"TPE1" => metadata.artist = value.into(),
b"TPE2" => metadata.album_artist = value.into(),
b"TALB" => metadata.album = value.into(),
b"TYER" | b"TDRC" => {
metadata.year = value.into();
if value.len() >= 4 {
metadata.date = value.into();
}
}
b"TCON" => metadata.genre = genre(value),
b"TRCK" => (metadata.track_number, metadata.total_tracks) = pair(value),
b"TPOS" => (metadata.disc_number, metadata.total_discs) = pair(value),
b"TSRC" => metadata.isrc = value.into(),
b"TCOM" => metadata.composer = value.into(),
b"TPUB" => metadata.label = value.into(),
b"TCOP" => metadata.copyright = value.into(),
b"TCMP" if truthy(value) && metadata.album_type.is_empty() => {
metadata.album_type = "compilation".into()
}
b"COMM" | b"USLT" => {
let value = language(data);
if !value.is_empty() {
if id == b"COMM" {
metadata.comment = value;
} else if metadata.lyrics.is_empty() {
metadata.lyrics = value;
}
}
}
b"TXXX" => {
let (description, value) = user_text(data);
if matches!(
lowercase(description.trim()).as_str(),
"lyrics"
| "lyric"
| "unsyncedlyrics"
| "unsynced lyrics"
| "syncedlyrics"
| "synced lyrics"
| "uslt"
| "sylt"
| "lrc"
) && metadata.lyrics.is_empty()
&& !value.is_empty()
{
metadata.lyrics = value.clone();
}
match uppercase(&description).as_str() {
"ITUNESADVISORY" => metadata.explicit = truthy(&value),
"RELEASETYPE" => metadata.album_type = value,
"BARCODE" | "UPC" => metadata.upc = value,
"REPLAYGAIN_TRACK_GAIN" if version != 2 => metadata.replay_gain_track_gain = value,
"REPLAYGAIN_TRACK_PEAK" if version != 2 => metadata.replay_gain_track_peak = value,
"REPLAYGAIN_ALBUM_GAIN" if version != 2 => metadata.replay_gain_album_gain = value,
"REPLAYGAIN_ALBUM_PEAK" if version != 2 => metadata.replay_gain_album_peak = value,
_ => {}
}
}
_ => {}
}
}
pub(crate) fn decode(encoding: u8, mut data: &[u8]) -> String {
if !matches!(encoding, 1 | 2) {
return utf8(data).trim_end_matches('\0').into();
}
let mut little = false;
if encoding == 1 {
if data.starts_with(&[0xff, 0xfe]) {
little = true;
data = &data[2..];
} else if data.starts_with(&[0xfe, 0xff]) {
data = &data[2..];
}
}
// Preserve the legacy decoder's individual UTF-16 code-unit conversion.
data.as_chunks::<2>()
.0
.iter()
.map(|pair| {
if little {
u16::from_le_bytes(*pair)
} else {
u16::from_be_bytes(*pair)
}
})
.take_while(|value| *value != 0)
.map(|value| char::from_u32(u32::from(value)).unwrap_or(char::REPLACEMENT_CHARACTER))
.collect()
}
fn separator(data: &[u8], encoding: u8) -> Option<(usize, usize)> {
if matches!(encoding, 1 | 2) {
data.as_chunks::<2>()
.0
.iter()
.position(|pair| *pair == [0, 0])
.map(|index| (index * 2, 2))
} else {
data.iter()
.position(|value| *value == 0)
.map(|index| (index, 1))
}
}
fn language(data: &[u8]) -> String {
if data.len() < 5 {
return String::new();
}
let encoding = data[0];
let rest = &data[4..];
let text = match separator(rest, encoding) {
Some((index, size)) if matches!(encoding, 1 | 2) || index + size < rest.len() => {
&rest[index + size..]
}
_ if matches!(encoding, 1 | 2) => &[],
_ => rest,
};
decode(encoding, text)
}
pub(crate) fn user_text(data: &[u8]) -> (String, String) {
let Some((&encoding, body)) = data.split_first() else {
return Default::default();
};
let Some((index, size)) = separator(body, encoding) else {
return Default::default();
};
if index + size >= body.len() {
return Default::default();
}
(
decode(encoding, &body[..index]).trim().into(),
decode(encoding, &body[index + size..]).trim().into(),
)
}
fn v1(reader: &mut (impl Read + Seek)) -> Result<AudioMetadata, String> {
let end = seek(reader, SeekFrom::End(0))?;
if end < 128 {
return Err("no ID3v1 tag found".into());
}
seek(reader, SeekFrom::Start(end - 128))?;
let data = bytes(reader, 128)?;
if !data.starts_with(b"TAG") {
return Err("no ID3v1 tag".into());
}
let text = |start, end| {
utf8(&data[start..end])
.trim_end_matches([' ', '\0'])
.to_owned()
};
Ok(AudioMetadata {
title: text(3, 33),
artist: text(33, 63),
album: text(63, 93),
year: text(93, 97),
genre: GENRES.get(data[127] as usize).unwrap_or(&"").to_string(),
..AudioMetadata::default()
})
}
fn syncsafe(data: &[u8]) -> u64 {
u64::from(data[0]) << 21
| u64::from(data[1]) << 14
| u64::from(data[2]) << 7
| u64::from(data[3])
}
pub(super) fn genre(value: &str) -> String {
if value.starts_with('(')
&& let Some(end) = value.find(')')
&& let Ok(index) = value[1..end].parse::<usize>()
&& let Some(genre) = GENRES.get(index)
{
return if end + 1 < value.len() {
value[end + 1..].into()
} else {
(*genre).into()
};
}
value.into()
}
const GENRES: &[&str] = &[
"Blues",
"Classic Rock",
"Country",
"Dance",
"Disco",
"Funk",
"Grunge",
"Hip-Hop",
"Jazz",
"Metal",
"New Age",
"Oldies",
"Other",
"Pop",
"R&B",
"Rap",
"Reggae",
"Rock",
"Techno",
"Industrial",
"Alternative",
"Ska",
"Death Metal",
"Pranks",
"Soundtrack",
"Euro-Techno",
"Ambient",
"Trip-Hop",
"Vocal",
"Jazz+Funk",
"Fusion",
"Trance",
"Classical",
"Instrumental",
"Acid",
"House",
"Game",
"Sound Clip",
"Gospel",
"Noise",
"AlternRock",
"Bass",
"Soul",
"Punk",
"Space",
"Meditative",
"Instrumental Pop",
"Instrumental Rock",
"Ethnic",
"Gothic",
"Darkwave",
"Techno-Industrial",
"Electronic",
"Pop-Folk",
"Eurodance",
"Dream",
"Southern Rock",
"Comedy",
"Cult",
"Gangsta",
"Top 40",
"Christian Rap",
"Pop/Funk",
"Jungle",
"Native American",
"Cabaret",
"New Wave",
"Psychedelic",
"Rave",
"Showtunes",
"Trailer",
"Lo-Fi",
"Tribal",
"Acid Punk",
"Acid Jazz",
"Polka",
"Retro",
"Musical",
"Rock & Roll",
"Hard Rock",
"Folk",
"Folk-Rock",
"National Folk",
"Swing",
"Fast Fusion",
"Bebop",
"Latin",
"Revival",
"Celtic",
"Bluegrass",
"Avantgarde",
"Gothic Rock",
"Progressive Rock",
"Psychedelic Rock",
"Symphonic Rock",
"Slow Rock",
"Big Band",
"Chorus",
"Easy Listening",
"Acoustic",
"Humour",
"Speech",
"Chanson",
"Opera",
"Chamber Music",
"Sonata",
"Symphony",
"Booty Bass",
"Primus",
"Porn Groove",
"Satire",
"Slow Jam",
"Club",
"Tango",
"Samba",
"Folklore",
"Ballad",
"Power Ballad",
"Rhythmic Soul",
"Freestyle",
"Duet",
"Punk Rock",
"Drum Solo",
"A capella",
"Euro-House",
"Dance Hall",
"Goa",
"Drum & Bass",
"Club-House",
"Hardcore",
"Terror",
"Indie",
"BritPop",
"Negerpunk",
"Polsk Punk",
"Beat",
"Christian Gangsta Rap",
"Heavy Metal",
"Black Metal",
"Crossover",
"Contemporary Christian",
"Christian Rock",
"Merengue",
"Salsa",
"Thrash Metal",
"Anime",
"J-Pop",
"Synthpop",
];
@@ -0,0 +1,274 @@
//! Library scan DTOs composed from the existing tag and quality readers.
use super::{AudioMetadata, CoverArt, file::ObservedReader, read_tags};
use crate::lyrics::lrc::has_usable_content;
use crate::media::{mp3_quality, ogg_quality, probe_mp4_quality, probe_quality, riff_quality};
use serde_json::{Value, json};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
pub fn library_extension(path: &str, hint: &str) -> String {
let suffix = |path: &str| {
let name = path.rsplit('/').next().unwrap_or_default();
name.rfind('.')
.map(|offset| name[offset + 1..].to_lowercase())
};
suffix(path).or_else(|| suffix(hint)).unwrap_or_default()
}
pub fn library_id(path: &str) -> String {
let hash = path.chars().fold(5381_u32, |hash, value| {
hash.wrapping_mul(33).wrapping_add(value as u32)
});
format!("lib_{hash:x}")
}
fn filename(path: &str) -> String {
let name = Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(path);
name[..name.rfind('.').unwrap_or(name.len())].into()
}
/// Missing/unsupported audio still produces a filename-derived track in Go.
/// WAV/AIFF use defaults even when their tag/quality readers fail.
pub fn library_metadata(path: &str, hint: &str, scan_time: &str, mod_time: i64) -> Value {
let mut format = library_extension(path, hint);
let riff = matches!(format.as_str(), "wav" | "aiff" | "aif" | "aifc");
if matches!(format.as_str(), "aiff" | "aif" | "aifc") {
format = "aiff".into();
}
let name = filename(if hint.is_empty() { path } else { hint });
let mut result = json!({
"id": library_id(path), "filePath":path, "scannedAt":scan_time,
"trackName":name, "artistName":"Unknown Artist", "albumName":"Unknown Album",
"hasLyrics":false,
});
if !format.is_empty() {
result["format"] = format.into();
}
if mod_time != 0 {
result["fileModTime"] = mod_time.into();
}
if !riff {
result["metadataFromFilename"] = true.into();
let numeric = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit());
if let Some((first, title)) = name.split_once(" - ") {
result["trackName"] = title.into();
if first.len() > 3 || !numeric(first) {
result["artistName"] = first.into();
}
} else if name.len() > 3 && name.as_bytes()[..2].iter().all(u8::is_ascii_digit) {
result["trackName"] = name[2..].trim_start_matches([' ', '.', '-']).into();
}
let parent = Path::new(path).parent().unwrap_or(Path::new("."));
let album = parent
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_else(|| if parent == Path::new("/") { "/" } else { "." });
if !matches!(album, "" | "." | "fd" | "self") {
result["albumName"] = album.into();
}
}
result
}
pub fn read_library_metadata(
file: &mut (impl Read + Seek),
path: &str,
hint: &str,
scan_time: &str,
mod_time: i64,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Value, String> {
read_observed(file, path, hint, scan_time, mod_time, check, None)
}
/// FLAC scans collect artwork during the existing tag pass. Other formats keep
/// their existing readers; callers request this only on a FLAC cover-cache miss.
pub fn read_library_metadata_with_cover(
file: &mut (impl Read + Seek),
path: &str,
hint: &str,
scan_time: &str,
mod_time: i64,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(Value, Option<CoverArt>), String> {
let mut cover = None;
let metadata = read_observed(
file,
path,
hint,
scan_time,
mod_time,
check,
Some(&mut cover),
)?;
Ok((metadata, cover))
}
fn read_observed(
file: &mut (impl Read + Seek),
path: &str,
hint: &str,
scan_time: &str,
mod_time: i64,
check: &dyn Fn() -> Result<(), String>,
cover: Option<&mut Option<CoverArt>>,
) -> Result<Value, String> {
check()?;
let mut reader = ObservedReader {
file,
check,
failure: None,
};
let result = read(&mut reader, path, hint, scan_time, mod_time, cover);
if let Some(error) = reader.failure {
return Err(error);
}
check()?;
result
}
fn read(
file: &mut (impl Read + Seek),
path: &str,
hint: &str,
scan_time: &str,
mod_time: i64,
mut cover: Option<&mut Option<CoverArt>>,
) -> Result<Value, String> {
let mut result = library_metadata(path, hint, scan_time, mod_time);
let format = library_extension(path, hint);
let metadata = read_tags(file, &format, &|| Ok(()), cover.as_deref_mut());
let tagged = metadata.is_ok();
if !tagged && let Some(cover) = cover {
*cover = None;
}
if let Ok(metadata) = metadata {
apply_tags(&mut result, metadata, &format, path, hint)?;
}
// MP4 and RIFF probe quality independently of whether tags are present.
if !tagged
&& !matches!(
format.as_str(),
"m4a" | "mp4" | "aac" | "wav" | "aiff" | "aif" | "aifc"
)
{
return Ok(result);
}
let size = file
.seek(SeekFrom::End(0))
.map_err(|error| error.to_string())?;
file.seek(SeekFrom::Start(0))
.map_err(|error| error.to_string())?;
let quality = match format.as_str() {
"flac" => probe_quality(file, &|| Ok(())),
"m4a" | "mp4" | "aac" => probe_mp4_quality(file, &|| Ok(())),
"mp3" => mp3_quality(file, size as i64),
"ogg" | "opus" => ogg_quality(file, size as i64, path),
"wav" | "aiff" | "aif" | "aifc" => riff_quality(file, size as i64, format != "wav"),
_ => return Ok(result),
};
if let Ok(quality) = quality {
for (key, value) in [
("bitDepth", quality.bit_depth),
("sampleRate", quality.sample_rate),
("duration", quality.duration),
] {
if value != 0 {
result[key] = value.into();
}
}
let bitrate = match format.as_str() {
"flac" if quality.total_samples > 0 && quality.sample_rate > 0 => {
(size as f64 * 8.0
/ (quality.total_samples as f64 / quality.sample_rate as f64)
/ 1000.0) as i64
}
"mp3" | "ogg" | "opus" => quality.bitrate / 1000,
"m4a" | "mp4" | "aac" => {
let mapped = match quality.codec.trim().to_lowercase().as_str() {
"flac" => "flac",
"alac" => "alac",
"eac3" | "ec-3" => "eac3",
"ac3" | "ac-3" => "ac3",
"ac4" | "ac-4" => "ac4",
"opus" => "opus",
"aac" | "mp4a" => "m4a",
_ => "",
};
if !mapped.is_empty() {
result["format"] = mapped.into();
}
quality.bitrate
}
_ => 0,
};
if bitrate > 0 {
result["bitrate"] = bitrate.into();
}
}
Ok(result)
}
fn apply_tags(
result: &mut Value,
mut tags: AudioMetadata,
format: &str,
path: &str,
hint: &str,
) -> Result<(), String> {
if format != "flac" && tags.date.is_empty() {
tags.date = tags.year.clone();
}
result
.as_object_mut()
.unwrap()
.remove("metadataFromFilename");
result["trackName"] = if tags.title.is_empty() {
filename(if hint.is_empty() { path } else { hint }).into()
} else {
tags.title.clone().into()
};
result["artistName"] = if tags.artist.is_empty() {
"Unknown Artist".into()
} else {
tags.artist.clone().into()
};
result["albumName"] = if tags.album.is_empty() {
"Unknown Album".into()
} else {
tags.album.clone().into()
};
result["hasLyrics"] = has_usable_content(&tags.lyrics).into();
let tags = serde_json::to_value(tags).map_err(|error| error.to_string())?;
for (source, target) in [
("album_artist", "albumArtist"),
("isrc", "isrc"),
("track_number", "trackNumber"),
("total_tracks", "totalTracks"),
("disc_number", "discNumber"),
("total_discs", "totalDiscs"),
("date", "releaseDate"),
("genre", "genre"),
("composer", "composer"),
("label", "label"),
("copyright", "copyright"),
("comment", "comment"),
("album_type", "albumType"),
("explicit", "explicit"),
("upc", "upc"),
] {
let value = &tags[source];
if !matches!(value, Value::Null)
&& value != ""
&& value != &json!(0)
&& value != &json!(false)
{
result[target] = value.clone();
}
}
Ok(())
}
+252
View File
@@ -0,0 +1,252 @@
use super::{AudioMetadata, CoverArt, bytes, seek, truthy, utf8};
use crate::matching::uppercase;
use std::io::{Read, Seek, SeekFrom};
#[derive(Clone, Copy)]
struct Atom {
payload: u64,
end: u64,
kind: [u8; 4],
}
struct Reader<'a, R> {
file: &'a mut R,
size: u64,
}
pub(super) fn read(file: &mut (impl Read + Seek)) -> Result<AudioMetadata, String> {
let size = seek(file, SeekFrom::End(0))?;
let mut reader = Reader { file, size };
let ilst = reader.ilst()?;
let mut metadata = AudioMetadata::default();
let mut position = ilst.payload;
while position.saturating_add(8) <= ilst.end {
let atom = reader.atom(position, ilst.end)?;
match &atom.kind {
b"\xa9nam" => metadata.title = reader.text(atom),
b"\xa9ART" => metadata.artist = reader.text(atom),
b"\xa9alb" => metadata.album = reader.text(atom),
b"aART" => metadata.album_artist = reader.text(atom),
b"\xa9day" => {
metadata.date = reader.text(atom);
metadata.year = metadata.date.clone();
}
b"\xa9gen" => metadata.genre = reader.text(atom),
b"\xa9wrt" => metadata.composer = reader.text(atom),
b"\xa9cmt" => metadata.comment = reader.text(atom),
b"cprt" => metadata.copyright = reader.text(atom),
b"\xa9lyr" => metadata.lyrics = reader.text(atom),
b"trkn" => (metadata.track_number, metadata.total_tracks) = reader.pair(atom),
b"disk" => (metadata.disc_number, metadata.total_discs) = reader.pair(atom),
b"rtng" => {
if let Ok(data) = reader.data(atom)
&& let Some(value) = data.last()
{
metadata.explicit = *value == 1;
}
}
b"cpil" => {
if let Ok(data) = reader.data(atom)
&& data.last().is_some_and(|value| *value != 0)
&& metadata.album_type.is_empty()
{
metadata.album_type = "compilation".into();
}
}
b"----" => {
if let Ok((name, value)) = reader.freeform(atom) {
apply(&mut metadata, &name, value);
}
}
_ => {}
}
position = atom.end;
}
if metadata.title.is_empty()
&& metadata.artist.is_empty()
&& metadata.album.is_empty()
&& metadata.album_artist.is_empty()
&& metadata.lyrics.is_empty()
&& metadata.track_number == 0
&& metadata.disc_number == 0
{
Err("no M4A tags found".into())
} else {
Ok(metadata)
}
}
pub(super) fn cover(file: &mut (impl Read + Seek)) -> Result<CoverArt, String> {
let size = seek(file, SeekFrom::End(0))?;
let mut reader = Reader { file, size };
let ilst = reader.ilst()?;
let cover = reader
.find(ilst.payload, ilst.end, b"covr")
.ok()
.flatten()
.ok_or("cover atom not found")?;
let data = reader
.find(cover.payload, cover.end, b"data")
.ok()
.flatten()
.ok_or("data atom not found in cover")?;
if data.end <= data.payload + 8 {
return Err("empty cover data".into());
}
let data = reader.payload(data, 8)?;
let mime = if data.len() >= 8 && data.starts_with(b"\x89PNG") {
"image/png"
} else {
"image/jpeg"
};
Ok(CoverArt {
data,
mime: mime.into(),
})
}
impl<R: Read + Seek> Reader<'_, R> {
fn ilst(&mut self) -> Result<Atom, String> {
let moov = self
.find(0, self.size, b"moov")
.ok()
.flatten()
.ok_or("moov not found")?;
let mut ilst = None;
if let Ok(Some(udta)) = self.find(moov.payload, moov.end, b"udta") {
ilst = self.metadata(udta);
}
ilst.or_else(|| self.metadata(moov))
.ok_or_else(|| "ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)".into())
}
fn atom(&mut self, offset: u64, end: u64) -> Result<Atom, String> {
if offset.saturating_add(8) > self.size {
return Err("unexpected EOF".into());
}
seek(self.file, SeekFrom::Start(offset))?;
let header = bytes(self.file, 8)?;
let kind: [u8; 4] = header[4..].try_into().unwrap();
let mut length = u64::from(u32::from_be_bytes(header[..4].try_into().unwrap()));
let mut header_size = 8;
if length == 1 {
length = u64::from_be_bytes(bytes(self.file, 8)?.try_into().unwrap());
header_size = 16;
} else if length == 0 {
length = end.saturating_sub(offset);
}
if length < header_size || length > i64::MAX as u64 || offset.checked_add(length).is_none()
{
return Err(format!("invalid atom size for {}", utf8(&kind)));
}
Ok(Atom {
payload: offset + header_size,
end: offset + length,
kind,
})
}
fn find(&mut self, mut offset: u64, end: u64, kind: &[u8; 4]) -> Result<Option<Atom>, String> {
while offset.saturating_add(8) <= end {
let atom = self.atom(offset, end)?;
if &atom.kind == kind {
return Ok(Some(atom));
}
offset = atom.end;
}
Ok(None)
}
fn metadata(&mut self, parent: Atom) -> Option<Atom> {
let meta = self.find(parent.payload, parent.end, b"meta").ok()??;
self.find(meta.payload + 4, meta.end, b"ilst")
.ok()
.flatten()
.or_else(|| self.find(meta.payload, meta.end, b"ilst").ok().flatten())
}
fn payload(&mut self, atom: Atom, prefix: u64) -> Result<Vec<u8>, String> {
let start = atom.payload + prefix;
let length = atom.end.checked_sub(start).ok_or("invalid atom payload")?;
// Bound selected metadata and artwork, including malformed 64-bit
// declarations on 32-bit Android. Unknown atoms are skipped.
if length == 0 || length > 32 * 1024 * 1024 || atom.end > self.size {
return Err("invalid atom payload".into());
}
seek(self.file, SeekFrom::Start(start))?;
bytes(self.file, length as usize)
}
fn data(&mut self, parent: Atom) -> Result<Vec<u8>, String> {
let atom = self
.find(parent.payload, parent.end, b"data")?
.ok_or("data atom not found")?;
self.payload(atom, 8)
}
fn text(&mut self, atom: Atom) -> String {
self.data(atom).map(|data| text(&data)).unwrap_or_default()
}
fn pair(&mut self, atom: Atom) -> (i64, i64) {
if let Ok(data) = self.data(atom)
&& data.len() >= 6
{
(
i64::from(u16::from_be_bytes(data[2..4].try_into().unwrap())),
i64::from(u16::from_be_bytes(data[4..6].try_into().unwrap())),
)
} else {
(0, 0)
}
}
fn freeform(&mut self, parent: Atom) -> Result<(String, String), String> {
let mut offset = parent.payload;
let mut name = String::new();
let mut value = String::new();
while offset.saturating_add(8) <= parent.end {
let atom = self.atom(offset, parent.end)?;
if atom.kind == *b"name"
&& let Ok(data) = self.payload(atom, 4)
{
name = text(&data);
}
if atom.kind == *b"data"
&& let Ok(data) = self.payload(atom, 8)
{
value = text(&data);
}
offset = atom.end;
}
if name.is_empty() || value.is_empty() {
Err("freeform M4A tag incomplete".into())
} else {
Ok((name, value))
}
}
}
fn text(data: &[u8]) -> String {
utf8(data).trim_end_matches('\0').trim().into()
}
fn apply(metadata: &mut AudioMetadata, name: &str, value: String) {
match uppercase(name.trim()).as_str() {
"ISRC" => metadata.isrc = value,
"LABEL" | "ORGANIZATION" => metadata.label = value,
"COMMENT" if metadata.comment.is_empty() => metadata.comment = value,
"COMPOSER" if metadata.composer.is_empty() => metadata.composer = value,
"COPYRIGHT" if metadata.copyright.is_empty() => metadata.copyright = value,
"LYRICS" | "UNSYNCEDLYRICS" | "SYNCEDLYRICS" if metadata.lyrics.is_empty() => {
metadata.lyrics = value
}
"REPLAYGAIN_TRACK_GAIN" => metadata.replay_gain_track_gain = value,
"REPLAYGAIN_TRACK_PEAK" => metadata.replay_gain_track_peak = value,
"REPLAYGAIN_ALBUM_GAIN" => metadata.replay_gain_album_gain = value,
"REPLAYGAIN_ALBUM_PEAK" => metadata.replay_gain_album_peak = value,
"ITUNESADVISORY" => metadata.explicit = truthy(&value),
"RELEASETYPE" => metadata.album_type = value,
"BARCODE" | "UPC" => metadata.upc = value,
_ => {}
}
}
+715
View File
@@ -0,0 +1,715 @@
//! Streaming metadata rewrites. The native owner supplies a separate staged
//! output and publishes it only after this operation succeeds and is synced.
mod ape;
mod id3;
mod mp4;
mod ogg;
mod riff;
pub(super) use id3::cover as embedded_cover;
use super::{CheckedReader, bytes, exact, pair, seek};
use crate::matching::{lowercase, uppercase};
use regex::Regex;
use std::collections::{BTreeMap, BTreeSet};
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom, Write};
use std::sync::LazyLock;
type Fields = BTreeMap<String, String>;
const MAX_TAG_BYTES: usize = 64 * 1024 * 1024;
struct Section {
start: u64,
end: u64,
data: Vec<u8>,
}
pub fn rewrite_audio_tags(
source: &mut (impl Read + Seek),
output: &mut impl Write,
format: &str,
fields: &Fields,
cover: Option<&[u8]>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
check()?;
validate_fields(fields)?;
let mut source = BufReader::new(CheckedReader {
reader: source,
check,
});
seek(&mut source, SeekFrom::Start(0))?;
if matches!(format, "wav" | "aiff" | "aif" | "aifc") {
let sections = riff::edit(&mut source, format != "wav", fields, cover, check)?;
return write_sections(&mut source, output, sections, check);
}
let cover = cover.filter(|data| !data.is_empty());
if matches!(format, "ogg" | "opus") {
return ogg::rewrite(&mut source, output, fields, cover, check);
}
if matches!(format, "ape" | "wv" | "mpc") {
let section = ape::edit(&mut source, fields, cover, check)?;
return write_sections(&mut source, output, vec![section], check);
}
if matches!(format, "m4a" | "mp4" | "m4b") {
let sections = mp4::edit(&mut source, fields, cover, check)?;
return write_sections(&mut source, output, sections, check);
}
let (head, audio_start) = match format {
"flac" => flac_header(&mut source, fields, cover, None, check)?,
"mp3" => id3::header(&mut source, fields, cover, check)?,
_ => return Err(format!("unsupported tag writer: {format}")),
};
write_sections(
&mut source,
output,
vec![Section {
start: 0,
end: audio_start,
data: head,
}],
check,
)
}
/// Embed canonical Vorbis keys without clearing empty values or removing
/// legacy aliases. This is the enrichment contract, distinct from the editor.
pub fn embed_flac_metadata(
source: &mut (impl Read + Seek),
output: &mut impl Write,
fields: &Fields,
artist_tag_mode: &str,
cover: Option<&[u8]>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
check()?;
validate_fields(fields)?;
let mut source = BufReader::new(CheckedReader {
reader: source,
check,
});
seek(&mut source, SeekFrom::Start(0))?;
let (data, end) = flac_header(
&mut source,
fields,
cover.filter(|data| !data.is_empty()),
Some(artist_tag_mode),
check,
)?;
write_sections(
&mut source,
output,
vec![Section {
start: 0,
end,
data,
}],
check,
)
}
fn validate_fields(fields: &Fields) -> Result<(), String> {
if fields
.iter()
.try_fold(0usize, |total, (key, value)| {
total.checked_add(key.len())?.checked_add(value.len())
})
.is_none_or(|length| length > MAX_TAG_BYTES)
{
return Err("tag edit fields exceed 64 MiB".into());
}
Ok(())
}
/// Rewrite only existing M4A freeform tags. False means a no-op and no output
/// was written, so the native owner can discard its staging file immediately.
pub fn rewrite_m4a_freeform(
source: &mut (impl Read + Seek),
output: &mut impl Write,
fields: &Fields,
replay_gain_only: bool,
check: &dyn Fn() -> Result<(), String>,
) -> Result<bool, String> {
check()?;
validate_fields(fields)?;
let mut source = BufReader::new(CheckedReader {
reader: source,
check,
});
let sections = mp4::edit_freeform(&mut source, fields, replay_gain_only, check)?;
write_optional_sections(&mut source, output, sections, check)
}
/// Normalize an AC-4 container and lazily open its original configuration source.
pub fn rewrite_ac4_config<R: Read + Seek>(
source: &mut (impl Read + Seek),
output: &mut impl Write,
reference: impl FnOnce() -> Result<R, String>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<bool, String> {
check()?;
let mut source = BufReader::new(CheckedReader {
reader: source,
check,
});
let sections = mp4::ac4::config(&mut source, reference, check)?;
write_optional_sections(&mut source, output, sections, check)
}
/// Replace AC-4 metadata; false means no output, and the cover is read only for AC-4.
pub fn rewrite_ac4_metadata(
source: &mut (impl Read + Seek),
output: &mut impl Write,
metadata_json: &str,
cover: impl FnOnce() -> Result<Option<Vec<u8>>, String>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<bool, String> {
check()?;
let mut source = BufReader::new(CheckedReader {
reader: source,
check,
});
let sections = mp4::ac4::metadata(&mut source, metadata_json, cover, check)?;
write_optional_sections(&mut source, output, sections, check)
}
fn write_optional_sections(
source: &mut (impl Read + Seek),
output: &mut impl Write,
sections: Vec<Section>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<bool, String> {
if sections.is_empty() {
check()?;
return Ok(false);
}
write_sections(source, output, sections, check)?;
Ok(true)
}
fn write_sections(
source: &mut (impl Read + Seek),
output: &mut impl Write,
mut sections: Vec<Section>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
sections.sort_by_key(|section| section.start);
let mut position = 0;
for section in &sections {
if section.start < position || section.end < section.start {
return Err("overlapping or invalid file sections".into());
}
position = section.end;
}
seek(source, SeekFrom::Start(0))?;
position = 0;
for section in sections {
copy(source, output, Some(section.start - position), check)?;
for chunk in section.data.chunks(65536) {
check()?;
output.write_all(chunk).map_err(|error| error.to_string())?;
}
seek(source, SeekFrom::Start(section.end))?;
position = section.end;
}
copy(source, output, None, check)
}
fn copy(
source: &mut impl Read,
output: &mut impl Write,
mut remaining: Option<u64>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
let mut buffer = [0; 65536];
loop {
check()?;
let limit = remaining.map_or(buffer.len(), |n| n.min(buffer.len() as u64) as usize);
if limit == 0 {
break;
}
let count = source
.read(&mut buffer[..limit])
.map_err(|error| error.to_string())?;
if count == 0 {
if remaining.is_some() {
return Err("unexpected EOF while copying audio".into());
}
break;
}
output
.write_all(&buffer[..count])
.map_err(|error| error.to_string())?;
remaining = remaining.map(|n| n - count as u64);
}
check()
}
fn flac_header(
source: &mut (impl Read + Seek),
fields: &Fields,
cover: Option<&[u8]>,
embed_mode: Option<&str>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(Vec<u8>, u64), String> {
if bytes(source, 4)? != b"fLaC" {
return Err("fLaC head incorrect".into());
}
let mut blocks = Vec::new();
let mut total = 0;
loop {
check()?;
if blocks.len() >= 65536 {
return Err("FLAC metadata block count exceeds 65536".into());
}
let h = bytes(source, 4)?;
let length = u32::from_be_bytes([0, h[1], h[2], h[3]]) as usize;
total += length + 4;
if total > MAX_TAG_BYTES {
return Err("FLAC metadata exceeds 64 MiB".into());
}
blocks.push((h[0] & 0x7f, bytes(source, length)?));
if h[0] & 0x80 != 0 {
break;
}
}
let audio_start = seek(source, SeekFrom::Current(0))?;
let sync = bytes(source, 2)?;
if sync[0] != 0xff || sync[1] >> 2 != 0x3e {
return Err("frames do not begin with sync code".into());
}
let index = blocks.iter().position(|(kind, _)| *kind == 4);
let (vendor, mut comments) = if let Some(index) = index {
parse_comments(&blocks[index].1)?
} else {
(b"SpotiFLAC".to_vec(), Vec::new())
};
if let Some(mode) = embed_mode {
for (key, value) in fields.iter().filter(|(_, value)| !value.is_empty()) {
if matches!(key.as_str(), "ARTIST" | "ALBUMARTIST") {
let values = if mode.trim().eq_ignore_ascii_case("split_vorbis") {
split_artists(value)
} else {
vec![value.clone()]
};
if !values.is_empty() {
set_comment(&mut comments, key, "");
for value in values.iter().filter(|value| !value.trim().is_empty()) {
comments.push(format!("{key}={value}").into_bytes());
}
}
} else {
set_comment(&mut comments, key, value);
}
}
} else {
edit_comments(&mut comments, fields);
}
let mut data = Vec::new();
put_string(&mut data, &vendor);
data.extend((comments.len() as u32).to_le_bytes());
for comment in comments {
put_string(&mut data, &comment);
}
if let Some(index) = index {
blocks[index].1 = data;
} else {
blocks.push((4, data));
}
if let Some(cover) = cover {
blocks.retain(|(kind, _)| *kind != 6);
if let Some(picture) = picture(cover, check)? {
blocks.push((6, picture));
}
}
let mut head = b"fLaC".to_vec();
let count = blocks.len();
for (index, (kind, data)) in blocks.into_iter().enumerate() {
if head.len() + data.len() + 4 > MAX_TAG_BYTES {
return Err("FLAC metadata exceeds 64 MiB".into());
}
if data.len() > 0xff_ffff {
return Err("FLAC metadata block exceeds 24-bit size".into());
}
head.push(kind | if index + 1 == count { 0x80 } else { 0 });
head.extend(&(data.len() as u32).to_be_bytes()[1..]);
head.extend(data);
}
Ok((head, audio_start))
}
fn parse_comments(data: &[u8]) -> Result<(Vec<u8>, Vec<Vec<u8>>), String> {
fn string(reader: &mut Cursor<&[u8]>) -> Result<Vec<u8>, String> {
let mut length = [0; 4];
exact(reader, &mut length)?;
let length = u32::from_le_bytes(length) as usize;
if length
> reader
.get_ref()
.len()
.saturating_sub(reader.position() as usize)
{
return Err("truncated Vorbis comment".into());
}
bytes(reader, length)
}
let mut reader = Cursor::new(data);
let vendor = string(&mut reader)?;
let mut count = [0; 4];
exact(&mut reader, &mut count)?;
let mut comments = Vec::new();
for _ in 0..u32::from_le_bytes(count) {
comments.push(string(&mut reader)?);
}
Ok((vendor, comments))
}
fn put_string(output: &mut Vec<u8>, data: &[u8]) {
output.extend((data.len() as u32).to_le_bytes());
output.extend(data);
}
fn comment_key(value: &[u8]) -> String {
value
.iter()
.position(|value| *value == b'=')
.filter(|index| *index > 0)
.map(|index| uppercase(&String::from_utf8_lossy(&value[..index])))
.unwrap_or_default()
}
fn set_comment(comments: &mut Vec<Vec<u8>>, key: &str, value: &str) {
comments.retain(|comment| comment_key(comment) != key);
if !value.is_empty() {
comments.push(format!("{key}={value}").into_bytes());
}
}
fn get_comment(comments: &[Vec<u8>], key: &str) -> String {
comments
.iter()
.find(|comment| comment_key(comment) == key)
.map(|comment| String::from_utf8_lossy(&comment[key.len() + 1..]).into_owned())
.unwrap_or_default()
}
fn edit_comments(comments: &mut Vec<Vec<u8>>, fields: &Fields) {
for (field, key) in [
("title", "TITLE"),
("album", "ALBUM"),
("date", "DATE"),
("isrc", "ISRC"),
("genre", "GENRE"),
("label", "ORGANIZATION"),
("copyright", "COPYRIGHT"),
("composer", "COMPOSER"),
("comment", "COMMENT"),
("explicit", "ITUNESADVISORY"),
("album_type", "RELEASETYPE"),
("upc", "BARCODE"),
("barcode", "BARCODE"),
("compilation", "COMPILATION"),
("replaygain_track_gain", "REPLAYGAIN_TRACK_GAIN"),
("replaygain_track_peak", "REPLAYGAIN_TRACK_PEAK"),
("replaygain_album_gain", "REPLAYGAIN_ALBUM_GAIN"),
("replaygain_album_peak", "REPLAYGAIN_ALBUM_PEAK"),
] {
if let Some(value) = fields.get(field) {
set_comment(comments, key, value);
}
}
for (field, aliases) in [
("label", &["LABEL", "PUBLISHER"][..]),
("date", &["YEAR"][..]),
] {
if fields.contains_key(field) {
for key in aliases {
set_comment(comments, key, "");
}
}
}
for (field, key) in [("artist", "ARTIST"), ("album_artist", "ALBUMARTIST")] {
if let Some(value) = fields.get(field) {
set_comment(comments, key, "");
let values = if fields
.get("artist_tag_mode")
.is_some_and(|mode| mode.trim().eq_ignore_ascii_case("split_vorbis"))
{
split_artists(value)
} else {
vec![value.clone()]
};
for value in values {
if !value.trim().is_empty() {
comments.push(format!("{key}={value}").into_bytes());
}
}
if field == "album_artist" {
for alias in ["ALBUM ARTIST", "ALBUM_ARTIST"] {
set_comment(comments, alias, "");
}
}
}
}
for (field, total, key, alias) in [
("track_number", "track_total", "TRACKNUMBER", "TRACK"),
("disc_number", "disc_total", "DISCNUMBER", "DISC"),
] {
if fields.contains_key(field) || fields.contains_key(total) {
let mut current = pair(&get_comment(comments, key));
if current == (0, 0) {
current = pair(&get_comment(comments, alias));
}
set_comment(comments, key, &edit_index(current, fields, field, total));
set_comment(comments, alias, "");
}
}
if let Some(value) = fields.get("lyrics") {
set_comment(comments, "SYNCEDLYRICS", "");
set_comment(comments, "LYRICS", value);
set_comment(comments, "UNSYNCEDLYRICS", value);
}
}
fn edit_index(
(mut number, mut total): (i64, i64),
fields: &Fields,
key: &str,
total_key: &str,
) -> String {
if let Some(value) = fields.get(key) {
number = value.trim().parse::<i64>().unwrap_or(0);
}
if let Some(value) = fields.get(total_key) {
total = value.trim().parse::<i64>().unwrap_or(0);
}
if number <= 0 {
String::new()
} else if total > 0 {
format!("{number}/{total}")
} else {
number.to_string()
}
}
fn metadata_fields(metadata: &super::AudioMetadata, fields: &Fields) -> Fields {
let mut result = Fields::new();
for (field, current) in [
("title", &metadata.title),
("artist", &metadata.artist),
("album", &metadata.album),
("album_artist", &metadata.album_artist),
("date", &metadata.date),
("genre", &metadata.genre),
("composer", &metadata.composer),
("label", &metadata.label),
("copyright", &metadata.copyright),
("isrc", &metadata.isrc),
("lyrics", &metadata.lyrics),
("comment", &metadata.comment),
("album_type", &metadata.album_type),
("upc", &metadata.upc),
("replaygain_track_gain", &metadata.replay_gain_track_gain),
("replaygain_track_peak", &metadata.replay_gain_track_peak),
("replaygain_album_gain", &metadata.replay_gain_album_gain),
("replaygain_album_peak", &metadata.replay_gain_album_peak),
] {
result.insert(field.into(), fields.get(field).unwrap_or(current).clone());
}
static INTEGER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[+-]?[0-9]+").unwrap());
for (field, current) in [
("track_number", metadata.track_number),
("track_total", metadata.total_tracks),
("disc_number", metadata.disc_number),
("disc_total", metadata.total_discs),
] {
let value = fields.get(field).map_or(current, |value| {
INTEGER
.find(value.trim())
.and_then(|n| n.as_str().parse::<isize>().ok())
.unwrap_or(0) as i64
});
result.insert(field.into(), value.to_string());
}
let explicit = fields
.get("explicit")
.map_or(metadata.explicit, |value| super::truthy(value));
result.insert("explicit".into(), if explicit { "1" } else { "" }.into());
let compilation = result["album_type"]
.trim()
.eq_ignore_ascii_case("compilation");
result.insert(
"compilation".into(),
if compilation { "1" } else { "" }.into(),
);
result
}
fn split_artists(value: &str) -> Vec<String> {
static SPLIT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?-u:\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?\s*)").unwrap()
});
let value = value.trim();
if value.is_empty() {
return Vec::new();
}
let mut seen = BTreeSet::new();
let mut result: Vec<String> = SPLIT
.split(value)
.map(str::trim)
.filter(|part| !part.is_empty())
.filter(|part| seen.insert(lowercase(part)))
.map(str::to_owned)
.collect();
if result.is_empty() {
result.push(value.into());
}
result
}
fn cover_mime(data: &[u8]) -> &'static str {
if data.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if data.starts_with(b"\xff\xd8\xff") {
"image/jpeg"
} else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
"image/gif"
} else if data.len() >= 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
"image/webp"
} else {
"image/jpeg"
}
}
fn picture(
original: &[u8],
check: &dyn Fn() -> Result<(), String>,
) -> Result<Option<Vec<u8>>, String> {
let mut resized = None;
if original.len() > 16_000_000 {
let Ok(image) = image::ImageReader::new(Cursor::new(original))
.with_guessed_format()
.map_err(|e| e.to_string())?
.decode()
else {
return Ok(None);
};
for quality in [90, 80, 70, 60] {
check()?;
let mut encoded = Vec::new();
if image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, quality)
.encode_image(&image.to_rgb8())
.is_ok()
&& encoded.len() <= 16_000_000
{
resized = Some(encoded);
break;
}
}
if resized.is_none() {
for dimension in [1500, 1200, 1000, 800] {
check()?;
let scaled = image
.resize(dimension, dimension, image::imageops::FilterType::Nearest)
.to_rgb8();
let mut encoded = Vec::new();
if image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 85)
.encode_image(&scaled)
.is_ok()
&& encoded.len() <= 16_000_000
{
resized = Some(encoded);
break;
}
}
}
if resized.is_none() {
return Ok(None);
}
}
check()?;
let data = resized.as_deref().unwrap_or(original);
let mime = cover_mime(data);
let dimensions = image::ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|e| e.to_string())?
.into_dimensions()
.ok();
let (width, height, depth) = dimensions.map_or((0, 0, 0), |(w, h)| {
(
w,
h,
match mime {
"image/png" => 32,
"image/jpeg" => 24,
_ => 0,
},
)
});
let mut result = 3_u32.to_be_bytes().to_vec();
for value in [mime.as_bytes(), b"Front Cover"] {
result.extend((value.len() as u32).to_be_bytes());
result.extend(value);
}
for value in [width, height, depth, 0, data.len() as u32] {
result.extend(value.to_be_bytes());
}
result.extend(data);
Ok(Some(result))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oversized_flac_cover_reencodes_or_omits_invalid_image() {
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
3,
2,
image::Rgba([30, 60, 90, 255]),
));
let mut encoded = Cursor::new(Vec::new());
image
.write_to(&mut encoded, image::ImageFormat::Png)
.unwrap();
let mut cover = encoded.into_inner();
// Valid PNG with trailing data forces the same size fallback as a
// large artwork file without an expensive image-generation fixture.
cover.resize(16_000_001, 0);
let block = picture(&cover, &|| Ok(())).unwrap().unwrap();
let mut reader = Cursor::new(block);
let mut number = || {
let mut value = [0; 4];
reader.read_exact(&mut value).unwrap();
u32::from_be_bytes(value)
};
assert_eq!(number(), 3);
assert_eq!(number(), 10);
assert_eq!(bytes(&mut reader, 10).unwrap(), b"image/jpeg");
assert_eq!(bytes(&mut reader, 4).unwrap(), 11_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 11).unwrap(), b"Front Cover");
assert_eq!(bytes(&mut reader, 4).unwrap(), 3_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 4).unwrap(), 2_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 4).unwrap(), 24_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 4).unwrap(), [0; 4]);
let length = u32::from_be_bytes(bytes(&mut reader, 4).unwrap().try_into().unwrap());
assert!(length < 16_000_000);
let jpeg = bytes(&mut reader, length as usize).unwrap();
assert_eq!(
image::ImageReader::new(Cursor::new(jpeg))
.with_guessed_format()
.unwrap()
.into_dimensions()
.unwrap(),
(3, 2)
);
cover.fill(0);
assert!(picture(&cover, &|| Ok(())).unwrap().is_none());
assert_eq!(
picture(b"small cover", &|| Err("cancelled".into())).unwrap_err(),
"cancelled"
);
}
}
@@ -0,0 +1,198 @@
use super::{Fields, MAX_TAG_BYTES, Section, bytes, edit_index, metadata_fields, seek};
use crate::matching::uppercase;
use crate::tags::{AudioMetadata, containers::ApeFooter};
use std::collections::BTreeSet;
use std::io::{Read, Seek, SeekFrom};
struct Item {
key: Vec<u8>,
value: Vec<u8>,
flags: u32,
}
pub(super) fn edit(
source: &mut (impl Read + Seek),
fields: &Fields,
cover: Option<&[u8]>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Section, String> {
let end = seek(source, SeekFrom::End(0))?;
let mut start = end;
let mut existing = None;
for offset in [end.checked_sub(32), end.checked_sub(161).map(|_| end - 160)]
.into_iter()
.flatten()
{
check()?;
let Some(footer) = ApeFooter::read(source, offset)? else {
continue;
};
if start == end {
let size = u64::from(footer.size) + if footer.flags & (1 << 31) != 0 { 32 } else { 0 };
start = (offset + 32)
.checked_sub(size)
.ok_or("invalid APE tag size")?;
}
if existing.is_none()
&& let Ok(begin) = footer.items_start(offset)
{
let size = offset - begin;
if size > MAX_TAG_BYTES as u64 {
return Err("APE metadata exceeds 64 MiB".into());
}
seek(source, SeekFrom::Start(begin))?;
let data = bytes(source, size as usize)?;
existing = Some(parse_items(&data, footer.count));
}
}
let metadata = metadata_fields(&AudioMetadata::default(), fields);
let mut added = Vec::new();
let mut remove = BTreeSet::new();
for (field, key) in [
("title", "Title"),
("artist", "Artist"),
("album", "Album"),
("album_artist", "Album Artist"),
("genre", "Genre"),
("date", "Year"),
("isrc", "ISRC"),
("lyrics", "Lyrics"),
("label", "Label"),
("copyright", "Copyright"),
("composer", "Composer"),
("comment", "Comment"),
("explicit", "ITUNESADVISORY"),
("album_type", "RELEASETYPE"),
("upc", "BARCODE"),
("compilation", "COMPILATION"),
("replaygain_track_gain", "REPLAYGAIN_TRACK_GAIN"),
("replaygain_track_peak", "REPLAYGAIN_TRACK_PEAK"),
("replaygain_album_gain", "REPLAYGAIN_ALBUM_GAIN"),
("replaygain_album_peak", "REPLAYGAIN_ALBUM_PEAK"),
] {
if fields.contains_key(field) {
// Go's clear-date override names DATE; a newly emitted Year also
// replaces YEAR, but clearing DATE alone leaves the old YEAR item.
remove.insert(if field == "date" {
"DATE".into()
} else {
uppercase(key)
});
}
let value = &metadata[field];
if !value.is_empty() {
added.push(Item {
key: key.as_bytes().to_vec(),
value: value.as_bytes().to_vec(),
flags: 0,
});
}
}
for (field, total, key) in [
("track_number", "track_total", "Track"),
("disc_number", "disc_total", "Disc"),
] {
if fields.contains_key(field) {
remove.insert(uppercase(key));
}
if fields.contains_key(field) || fields.contains_key(total) {
remove.insert(format!("{}NUMBER", uppercase(key)));
}
let value = edit_index((0, 0), &metadata, field, total);
if !value.is_empty() {
added.push(Item {
key: key.as_bytes().to_vec(),
value: value.into_bytes(),
flags: 0,
});
}
}
for (field, aliases) in [
("album_artist", &["ALBUMARTIST"][..]),
("label", &["PUBLISHER"][..]),
("lyrics", &["UNSYNCEDLYRICS", "SYNCEDLYRICS"][..]),
] {
if fields.contains_key(field) {
remove.extend(aliases.iter().map(|s| (*s).to_owned()));
}
}
if fields
.get("cover_path")
.is_some_and(|path| !path.trim().is_empty())
{
remove.insert("COVER ART (FRONT)".into());
if let Some(cover) = cover {
let mut value = b"cover.jpg\0".to_vec();
value.extend(cover);
added.push(Item {
key: b"Cover Art (Front)".to_vec(),
value,
flags: 2,
});
}
}
remove.extend(
added
.iter()
.map(|item| uppercase(&String::from_utf8_lossy(&item.key))),
);
let mut items = existing.unwrap_or_default();
items.retain(|item| !remove.contains(&uppercase(&String::from_utf8_lossy(&item.key))));
items.extend(added);
if items.is_empty() {
return Err("empty APE tag".into());
}
let mut body = Vec::new();
let count = items.len() as u32;
for item in items {
check()?;
if body.len() + 8 + item.key.len() + 1 + item.value.len() + 64 > MAX_TAG_BYTES {
return Err("APE metadata exceeds 64 MiB".into());
}
body.extend((item.value.len() as u32).to_le_bytes());
body.extend(item.flags.to_le_bytes());
body.extend(item.key);
body.push(0);
body.extend(item.value);
}
let size = body.len() as u32 + 32;
let header = |flags: u32| {
let mut result = b"APETAGEX".to_vec();
for value in [2000_u32, size, count, flags, 0, 0] {
result.extend(value.to_le_bytes());
}
result
};
let mut data = header(0xa000_0000);
data.extend(body);
data.extend(header(0x8000_0000));
Ok(Section { start, end, data })
}
fn parse_items(data: &[u8], count: u32) -> Vec<Item> {
let mut items = Vec::new();
let mut position = 0;
for _ in 0..count {
let Some(header) = data.get(position..position + 8) else {
break;
};
let size = u32::from_le_bytes(header[..4].try_into().unwrap()) as usize;
let flags = u32::from_le_bytes(header[4..].try_into().unwrap());
position += 8;
let Some(key_size) = data[position..].iter().position(|byte| *byte == 0) else {
break;
};
let key = data[position..position + key_size].to_vec();
position += key_size + 1;
let Some(value) = data.get(position..position.saturating_add(size)) else {
break;
};
position += size;
items.push(Item {
key,
value: value.to_vec(),
flags,
});
}
items
}
@@ -0,0 +1,381 @@
use super::{Fields, MAX_TAG_BYTES, bytes, cover_mime, edit_index, pair, seek};
use crate::matching::uppercase;
use crate::tags::id3::{decode, user_text};
use std::collections::BTreeSet;
use std::io::{Cursor, Read, Seek, SeekFrom};
struct Frame {
id: Vec<u8>,
data: Vec<u8>,
}
pub(super) fn header(
source: &mut (impl Read + Seek),
fields: &Fields,
cover: Option<&[u8]>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(Vec<u8>, u64), String> {
edit_header(source, fields, cover, None, 512, check)
}
pub(super) fn fresh(
fields: &Fields,
cover: Option<(&[u8], &str)>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Vec<u8>, String> {
edit_header(
&mut Cursor::new(Vec::<u8>::new()),
fields,
cover.map(|p| p.0),
cover.map(|p| p.1),
0,
check,
)
.map(|result| result.0)
}
fn edit_header(
source: &mut (impl Read + Seek),
fields: &Fields,
cover: Option<&[u8]>,
mime_override: Option<&str>,
padding: usize,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(Vec<u8>, u64), String> {
let (frames, audio_start) = read_frames(source, check)?;
let mut drop = BTreeSet::<Vec<u8>>::new();
let mut added = Vec::new();
for (field, id, aliases) in [
("title", "TIT2", &[][..]),
("artist", "TPE1", &[][..]),
("album", "TALB", &[][..]),
("album_artist", "TPE2", &[][..]),
("date", "TDRC", &["TYER", "TDAT", "TIME"][..]),
("genre", "TCON", &[][..]),
("label", "TPUB", &[][..]),
("copyright", "TCOP", &[][..]),
("composer", "TCOM", &[][..]),
("isrc", "TSRC", &[][..]),
("compilation", "TCMP", &[][..]),
] {
if let Some(value) = fields.get(field) {
drop.insert(id.as_bytes().to_vec());
for alias in aliases {
drop.insert(alias.as_bytes().to_vec());
}
if !value.trim().is_empty() {
added.push(frame(id, text(value)));
}
}
}
for (field, id) in [("comment", "COMM"), ("lyrics", "USLT")] {
if let Some(value) = fields.get(field) {
drop.insert(id.as_bytes().to_vec());
if !value.trim().is_empty() {
let mut data = b"\x03eng\0".to_vec();
data.extend(value.as_bytes());
added.push(frame(id, data));
}
}
}
for (field, total, id) in [
("track_number", "track_total", "TRCK"),
("disc_number", "disc_total", "TPOS"),
] {
if fields.contains_key(field) || fields.contains_key(total) {
let current = frames
.iter()
.find(|frame| frame.id == id.as_bytes())
.map(|frame| {
let value = frame
.data
.split_first()
.map(|(encoding, data)| decode(*encoding, data))
.unwrap_or_default();
pair(value.split('\0').next().unwrap_or_default())
})
.unwrap_or_default();
drop.insert(id.as_bytes().to_vec());
let value = edit_index(current, fields, field, total);
if !value.is_empty() {
added.push(frame(id, text(&value)));
}
}
}
let mut drop_descriptions = BTreeSet::new();
for (field, description) in [
("replaygain_track_gain", "REPLAYGAIN_TRACK_GAIN"),
("replaygain_track_peak", "REPLAYGAIN_TRACK_PEAK"),
("replaygain_album_gain", "REPLAYGAIN_ALBUM_GAIN"),
("replaygain_album_peak", "REPLAYGAIN_ALBUM_PEAK"),
("explicit", "ITUNESADVISORY"),
("album_type", "RELEASETYPE"),
("upc", "BARCODE"),
] {
if let Some(value) = fields.get(field) {
drop_descriptions.insert(description.to_owned());
if !value.trim().is_empty() {
let mut data = text(description);
data.push(0);
data.extend(value.as_bytes());
added.push(frame("TXXX", data));
}
}
}
if let Some(cover) = cover {
drop.insert(b"APIC".to_vec());
let mime = if let Some(mime) = mime_override {
mime
} else if cover_mime(cover) != "image/jpeg" || cover.starts_with(b"\xff\xd8\xff") {
cover_mime(cover)
} else if cover.starts_with(b"BM") {
"image/bmp"
} else if cover.starts_with(b"%PDF-") {
"application/pdf"
} else if cover
.iter()
.take(512)
.all(|b| !matches!(b, 0..=8 | 11 | 14..=26 | 28..=31))
{
"text/plain; charset=utf-8"
} else {
"application/octet-stream"
};
let mut data = text(mime);
data.extend([0, 3, 0]);
data.extend(cover);
added.push(frame("APIC", data));
}
let kept = frames.into_iter().filter(|frame| {
!drop.contains(&frame.id)
&& !(frame.id == b"TXXX"
&& drop_descriptions.contains(&uppercase(user_text(&frame.data).0.trim())))
});
let mut body = Vec::new();
for frame in kept.chain(added) {
check()?;
if body.len() + frame.data.len() + 10 + padding > MAX_TAG_BYTES {
return Err("ID3 metadata exceeds 64 MiB".into());
}
body.extend(frame.id);
body.extend(syncsafe(frame.data.len()));
body.extend([0, 0]);
body.extend(frame.data);
}
body.resize(body.len() + padding, 0);
let mut result = b"ID3\x04\0\0".to_vec();
result.extend(syncsafe(body.len()));
result.extend(body);
Ok((result, audio_start))
}
pub(in crate::tags) fn cover(tag: &[u8]) -> Option<(Vec<u8>, String)> {
let (frames, _) = read_frames(&mut Cursor::new(tag), &|| Ok(())).ok()?;
let data = &frames.iter().find(|frame| frame.id == b"APIC")?.data;
let encoding = *data.first()?;
let mime_end = data[1..].iter().position(|byte| *byte == 0)? + 1;
let mime = String::from_utf8_lossy(&data[1..mime_end]).into_owned();
let mut start = mime_end + 2;
if matches!(encoding, 0 | 3) {
start += data.get(start..)?.iter().position(|byte| *byte == 0)? + 1;
} else {
start += data
.get(start..)?
.windows(2)
.position(|pair| pair == [0, 0])?
+ 2;
}
(start < data.len()).then(|| (data[start..].to_vec(), mime))
}
fn frame(id: &str, data: Vec<u8>) -> Frame {
Frame {
id: id.as_bytes().to_vec(),
data,
}
}
fn text(value: &str) -> Vec<u8> {
let mut data = vec![3];
data.extend(value.as_bytes());
data
}
fn syncsafe(size: usize) -> [u8; 4] {
[
(size >> 21 & 127) as u8,
(size >> 14 & 127) as u8,
(size >> 7 & 127) as u8,
(size & 127) as u8,
]
}
fn size(data: &[u8], syncsafe: bool) -> usize {
if syncsafe {
((data[0] as usize) << 21)
| ((data[1] as usize) << 14)
| ((data[2] as usize) << 7)
| data[3] as usize
} else {
u32::from_be_bytes(data.try_into().unwrap()) as usize
}
}
fn read_frames(
source: &mut (impl Read + Seek),
check: &dyn Fn() -> Result<(), String>,
) -> Result<(Vec<Frame>, u64), String> {
let mut header = [0; 10];
let mut received = 0;
while received < header.len() {
let n = source
.read(&mut header[received..])
.map_err(|e| e.to_string())?;
if n == 0 {
break;
}
received += n;
}
if received < 10 || &header[..3] != b"ID3" {
return Ok((Vec::new(), 0));
}
let version = header[3];
let flags = header[5];
let length = size(&header[6..], true);
if length == 0 {
return Err("invalid ID3v2 tag size".into());
}
if length > MAX_TAG_BYTES {
return Err("ID3 metadata exceeds 64 MiB".into());
}
let end = seek(source, SeekFrom::End(0))?;
if 10 + length as u64 > end {
return Err("truncated ID3v2 tag".into());
}
seek(source, SeekFrom::Start(10))?;
let mut data = bytes(source, length)?;
let audio_start = 10 + length as u64 + if flags & 0x10 != 0 { 10 } else { 0 };
if flags & 0x10 != 0 && data.len() >= 10 && &data[data.len() - 10..data.len() - 7] == b"3DI" {
data.truncate(data.len() - 10);
}
let mut offset = 0;
if flags & 0x40 != 0 && data.len() >= 4 && matches!(version, 3 | 4) {
let length = size(&data[..4], version == 4);
if length > 0 {
let skip = if length + 4 <= data.len() {
length + 4
} else if length <= data.len() {
length
} else {
0
};
if skip < data.len() {
offset = skip;
}
}
}
let mut frames = Vec::new();
let header_length = if version == 2 { 6 } else { 10 };
while offset + header_length < data.len() {
check()?;
if frames.len() >= 65536 {
return Err("ID3 frame count exceeds 65536".into());
}
let id_length = if version == 2 { 3 } else { 4 };
let id = &data[offset..offset + id_length];
if id[0] == 0 {
break;
}
let length = if version == 2 {
((data[offset + 3] as usize) << 16)
| ((data[offset + 4] as usize) << 8)
| data[offset + 5] as usize
} else {
size(&data[offset + 4..offset + 8], version == 4)
};
if length == 0 || length > data.len() - offset - header_length {
break;
}
let frame_flags = if version == 2 { 0 } else { data[offset + 9] };
let mut payload = &data[offset + header_length..offset + header_length + length];
offset += header_length + length;
if version == 3 {
if frame_flags & 0xc0 != 0 {
continue;
}
if frame_flags & 0x20 != 0 {
payload = &payload[1..];
}
} else if version != 2 {
if frame_flags & 0x0c != 0 {
continue;
}
if frame_flags & 0x40 != 0 {
payload = &payload[1..];
}
if frame_flags & 1 != 0 {
if payload.len() < 4 {
continue;
}
payload = &payload[4..];
}
}
let mut payload =
if flags & 0x80 != 0 || (version != 2 && version != 3 && frame_flags & 2 != 0) {
let mut result = Vec::new();
let mut offset = 0;
while offset < payload.len() {
result.push(payload[offset]);
offset += if payload[offset] == 0xff && payload.get(offset + 1) == Some(&0) {
2
} else {
1
};
}
result
} else {
payload.to_vec()
};
let id = if version == 2 {
match id {
b"TT2" => b"TIT2".as_slice(),
b"TP1" => b"TPE1",
b"TP2" => b"TPE2",
b"TAL" => b"TALB",
b"TYE" => b"TDRC",
b"TCO" => b"TCON",
b"TRK" => b"TRCK",
b"TPA" => b"TPOS",
b"TCM" => b"TCOM",
b"TPB" => b"TPUB",
b"TCR" => b"TCOP",
b"TXX" => b"TXXX",
b"ULT" => b"USLT",
b"COM" => b"COMM",
b"PIC" => {
if payload.len() < 5 {
continue;
}
let mime = if payload[1..4].eq_ignore_ascii_case(b"PNG") {
"image/png"
} else {
"image/jpeg"
};
let mut converted = vec![payload[0]];
converted.extend(mime.as_bytes());
converted.push(0);
converted.extend(&payload[4..]);
payload = converted;
b"APIC"
}
_ => continue,
}
} else {
id
};
frames.push(Frame {
id: id.to_vec(),
data: payload,
});
}
Ok((frames, audio_start))
}
@@ -0,0 +1,608 @@
pub(super) mod ac4;
use super::{Fields, MAX_TAG_BYTES, Section, bytes, seek};
use crate::matching::uppercase;
use crate::media::mp4::{Atom, Reader};
use regex::Regex;
use std::cell::Cell;
use std::collections::BTreeSet;
use std::io::{Cursor, Read, Seek, SeekFrom};
use std::sync::LazyLock;
type Check<'a> = &'a dyn Fn() -> Result<(), String>;
struct Location {
ilst: Atom,
ancestors: Vec<Atom>,
}
pub(super) fn edit(
source: &mut (impl Read + Seek),
fields: &Fields,
cover: Option<&[u8]>,
check: Check<'_>,
) -> Result<Vec<Section>, String> {
let mut section = load(source, b"moov", check)?.ok_or("moov not found")?;
let data = &mut section.data;
let location = ensure_ilst(data, section.start, check)?;
let mut drop = BTreeSet::new();
let mut appended = Vec::new();
for (field, kind) in [
("title", b"\xa9nam"),
("artist", b"\xa9ART"),
("album", b"\xa9alb"),
("album_artist", b"aART"),
("date", b"\xa9day"),
("genre", b"\xa9gen"),
("composer", b"\xa9wrt"),
("comment", b"\xa9cmt"),
("copyright", b"cprt"),
("lyrics", b"\xa9lyr"),
] {
if let Some(value) = fields.get(field) {
drop.insert(*kind);
if field == "genre" {
drop.insert(*b"gnre");
}
if !value.trim().is_empty() {
appended.extend(value_atom(kind, 1, value.as_bytes()));
}
}
}
let mut remove_names = BTreeSet::new();
for (field, name) in [
("isrc", "ISRC"),
("label", "LABEL"),
("album_type", "RELEASETYPE"),
("upc", "BARCODE"),
] {
if let Some(value) = fields.get(field) {
remove_names.insert(name.to_owned());
if !value.trim().is_empty() {
appended.extend(freeform(name, value.trim()));
}
}
}
if fields.contains_key("label") {
remove_names.insert("ORGANIZATION".into());
}
if fields.contains_key("lyrics") {
remove_names.extend(["LYRICS", "UNSYNCEDLYRICS", "SYNCEDLYRICS"].map(str::to_owned));
}
for (field, kind, data_type) in [("explicit", b"rtng", 21), ("compilation", b"cpil", 22)] {
if let Some(value) = fields.get(field) {
drop.insert(*kind);
if matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "explicit"
) {
appended.extend(value_atom(kind, data_type, &[1]));
}
}
}
let replay_gain = replay_gain(fields);
if !replay_gain.is_empty() {
// Go replaces the entire ReplayGain group when any supplied value is
// nonempty. A request containing only empty values leaves it alone.
remove_names.extend(
[
"REPLAYGAIN_TRACK_GAIN",
"REPLAYGAIN_TRACK_PEAK",
"REPLAYGAIN_ALBUM_GAIN",
"REPLAYGAIN_ALBUM_PEAK",
"ITUNNORM",
]
.map(str::to_owned),
);
for (name, value) in replay_gain {
appended.extend(freeform(&name, &value));
}
}
let edit_track = fields.contains_key("track_number") || fields.contains_key("track_total");
let edit_disc = fields.contains_key("disc_number") || fields.contains_key("disc_total");
if cover.is_some() {
drop.insert(*b"covr");
}
let mut body = Vec::new();
let mut track = (0, 0);
let mut disc = (0, 0);
for child in children(data, location.ilst.payload, location.ilst.end, check)? {
let keep = if drop.contains(&child.kind) {
false
} else if child.kind == *b"trkn" {
track = index_pair(data, child, check)?;
!edit_track
} else if child.kind == *b"disk" {
disc = index_pair(data, child, check)?;
!edit_disc
} else if child.kind == *b"----" {
!remove_names.contains(&uppercase(&freeform_name(data, child, check)?))
} else {
true
};
if keep {
body.extend_from_slice(&data[child.start as usize..child.end as usize]);
}
}
for (edited, current, field, total, kind) in [
(edit_track, track, "track_number", "track_total", b"trkn"),
(edit_disc, disc, "disc_number", "disc_total", b"disk"),
] {
if edited {
let number = fields.get(field).map_or(current.0, |v| positive(v));
let total = fields.get(total).map_or(current.1, |v| positive(v));
if number > 0 {
let mut pair = vec![0; if kind == b"disk" { 6 } else { 8 }];
pair[2..4].copy_from_slice(&(number as u16).to_be_bytes());
pair[4..6].copy_from_slice(&(total as u16).to_be_bytes());
appended.extend(value_atom(kind, 0, &pair));
}
}
}
if let Some(cover) = cover {
appended.extend(value_atom(
b"covr",
if cover.starts_with(b"\x89PNG") {
14
} else {
13
},
cover,
));
}
if body.len() + appended.len() > MAX_TAG_BYTES {
return Err("MP4 metadata exceeds 64 MiB".into());
}
body.extend(appended);
replace(
data,
location.ilst.start,
location.ilst.end,
&build(b"ilst", &body),
&location.ancestors,
section.start,
check,
)?;
Ok(vec![section])
}
fn load(
source: &mut (impl Read + Seek),
kind: &[u8; 4],
check: Check<'_>,
) -> Result<Option<Section>, String> {
let size = seek(source, SeekFrom::End(0))?;
let mut reader = Reader {
file: source,
size,
check,
};
let mut start = 0;
for _ in 0..65536 {
if start + 8 > size {
return Ok(None);
}
let atom = reader.atom(start, size)?;
if atom.end > size {
return Err("MP4 atom extends past end of file".into());
}
if &atom.kind == kind {
let length = atom.end - atom.start;
if length > MAX_TAG_BYTES as u64 {
return Err("MP4 metadata exceeds 64 MiB".into());
}
seek(reader.file, SeekFrom::Start(start))?;
return Ok(Some(Section {
start,
end: atom.end,
data: bytes(reader.file, length as usize)?,
}));
}
start = atom.end;
}
Err("MP4 atom count exceeds 65536".into())
}
fn atom(data: &[u8], start: u64, end: u64, check: Check<'_>) -> Result<Atom, String> {
let mut source = Cursor::new(data);
let atom = Reader {
file: &mut source,
size: data.len() as u64,
check,
}
.atom(start, end)?;
if atom.end > end || atom.end > data.len() as u64 {
return Err("MP4 atom extends past parent".into());
}
Ok(atom)
}
fn children(data: &[u8], mut start: u64, end: u64, check: Check<'_>) -> Result<Vec<Atom>, String> {
let mut result = Vec::new();
while start + 8 <= end {
if result.len() >= 65536 {
return Err("MP4 atom count exceeds 65536".into());
}
let child = atom(data, start, end, check)?;
start = child.end;
result.push(child);
}
Ok(result)
}
fn find(
data: &[u8],
mut start: u64,
end: u64,
kind: &[u8; 4],
check: Check<'_>,
) -> Result<Option<Atom>, String> {
for _ in 0..65536 {
if start + 8 > end {
return Ok(None);
}
let child = atom(data, start, end, check)?;
if &child.kind == kind {
return Ok(Some(child));
}
start = child.end;
}
Err("MP4 atom count exceeds 65536".into())
}
fn locate_meta(
data: &[u8],
meta: Atom,
quicktime: bool,
check: Check<'_>,
) -> Result<Option<Atom>, String> {
let cancelled = Cell::new(false);
let guard = || {
let result = check();
if result.is_err() {
cancelled.set(true);
}
result
};
let iso = find(data, meta.payload + 4, meta.end, b"ilst", &guard);
if !quicktime || cancelled.get() || iso.as_ref().is_ok_and(Option::is_some) {
return iso;
}
find(data, meta.payload, meta.end, b"ilst", check)
}
fn locate(data: &[u8], quicktime: bool, check: Check<'_>) -> Result<Option<Location>, String> {
let moov = atom(data, 0, data.len() as u64, check)?;
if moov.kind != *b"moov" {
return Err("moov not found".into());
}
if let Some(udta) = find(data, moov.payload, moov.end, b"udta", check)?
&& let Some(meta) = find(data, udta.payload, udta.end, b"meta", check)?
&& let Some(ilst) = locate_meta(data, meta, quicktime, check)?
{
return Ok(Some(Location {
ilst,
ancestors: vec![moov, udta, meta],
}));
}
if let Some(meta) = find(data, moov.payload, moov.end, b"meta", check)?
&& let Some(ilst) = locate_meta(data, meta, quicktime, check)?
{
return Ok(Some(Location {
ilst,
ancestors: vec![moov, meta],
}));
}
Ok(None)
}
fn meta(body: &[u8]) -> Vec<u8> {
let mut handler = vec![0; 25];
handler[8..12].copy_from_slice(b"mdir");
handler[12..16].copy_from_slice(b"appl");
let mut payload = vec![0; 4];
payload.extend(build(b"hdlr", &handler));
payload.extend(build(b"ilst", body));
build(b"meta", &payload)
}
fn ensure_ilst(data: &mut Vec<u8>, base: u64, check: Check<'_>) -> Result<Location, String> {
if let Some(location) = locate(data, false, check)? {
return Ok(location);
}
let moov = atom(data, 0, data.len() as u64, check)?;
let mut ancestors = vec![moov];
let (position, inserted) =
if let Some(udta) = find(data, moov.payload, moov.end, b"udta", check)? {
ancestors.push(udta);
if let Some(meta) = find(data, udta.payload, udta.end, b"meta", check)? {
ancestors.push(meta);
(meta.end, build(b"ilst", &[]))
} else {
(udta.end, meta(&[]))
}
} else {
(moov.end, build(b"udta", &meta(&[])))
};
replace(data, position, position, &inserted, &ancestors, base, check)?;
locate(data, false, check)?.ok_or_else(|| "failed to create ilst".into())
}
fn replace(
data: &mut Vec<u8>,
start: u64,
end: u64,
replacement: &[u8],
ancestors: &[Atom],
base: u64,
check: Check<'_>,
) -> Result<(), String> {
check()?;
let delta = replacement.len() as i64 - (end - start) as i64;
if (data.len() as u64)
.checked_add_signed(delta)
.is_none_or(|n| n > MAX_TAG_BYTES as u64)
{
return Err("MP4 metadata exceeds 64 MiB".into());
}
data.splice(start as usize..end as usize, replacement.iter().copied());
for ancestor in ancestors {
grow(data, *ancestor, delta)?;
}
shift_offsets(data, base + start, delta, check)
}
fn grow(data: &mut [u8], atom: Atom, delta: i64) -> Result<(), String> {
let size = (atom.end - atom.start)
.checked_add_signed(delta)
.ok_or("invalid resized MP4 atom")?;
let start = atom.start as usize;
if atom.payload - atom.start == 16 {
data[start..start + 4].copy_from_slice(&1_u32.to_be_bytes());
data[start + 8..start + 16].copy_from_slice(&size.to_be_bytes());
} else {
let size = u32::try_from(size).map_err(|_| "MP4 atom too large for 32-bit header")?;
data[start..start + 4].copy_from_slice(&size.to_be_bytes());
}
Ok(())
}
fn shift_offsets(
data: &mut [u8],
position: u64,
delta: i64,
check: Check<'_>,
) -> Result<(), String> {
if delta == 0 {
return Ok(());
}
let moov = atom(data, 0, data.len() as u64, check)?;
for track in children(data, moov.payload, moov.end, check)?
.into_iter()
.filter(|atom| atom.kind == *b"trak")
{
let mut parent = Some(track);
for kind in [b"mdia", b"minf", b"stbl"] {
parent = match parent {
Some(p) => find(data, p.payload, p.end, kind, check)?,
None => None,
};
}
let Some(parent) = parent else {
continue;
};
for (kind, width) in [(b"stco", 4), (b"co64", 8)] {
let Some(table) = find(data, parent.payload, parent.end, kind, check)? else {
continue;
};
if table.payload + 8 > table.end {
continue;
}
let begin = table.payload as usize;
let count = u32::from_be_bytes(data[begin + 4..begin + 8].try_into().unwrap()) as usize;
for offset in (begin + 8..table.end as usize).step_by(width).take(count) {
check()?;
if offset + width > table.end as usize {
break;
}
let value = if width == 4 {
u32::from_be_bytes(data[offset..offset + 4].try_into().unwrap()) as u64
} else {
u64::from_be_bytes(data[offset..offset + 8].try_into().unwrap())
};
if value >= position {
let shifted = value
.checked_add_signed(delta)
.ok_or("MP4 chunk offset overflow")?;
if width == 4 {
let shifted =
u32::try_from(shifted).map_err(|_| "MP4 stco offset overflow")?;
data[offset..offset + 4].copy_from_slice(&shifted.to_be_bytes());
} else {
data[offset..offset + 8].copy_from_slice(&shifted.to_be_bytes());
}
}
}
}
}
Ok(())
}
fn build(kind: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let mut result = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
result.extend(kind);
result.extend(payload);
result
}
fn value_atom(kind: &[u8; 4], data_type: u32, value: &[u8]) -> Vec<u8> {
let mut payload = data_type.to_be_bytes().to_vec();
payload.extend([0; 4]);
payload.extend(value);
build(kind, &build(b"data", &payload))
}
fn freeform(name: &str, value: &str) -> Vec<u8> {
let mut body = build(b"mean", b"\0\0\0\0com.apple.iTunes");
let mut name_payload = vec![0; 4];
name_payload.extend(name.as_bytes());
body.extend(build(b"name", &name_payload));
let mut value_payload = vec![0, 0, 0, 1, 0, 0, 0, 0];
value_payload.extend(value.as_bytes());
body.extend(build(b"data", &value_payload));
build(b"----", &body)
}
fn freeform_name(data: &[u8], parent: Atom, check: Check<'_>) -> Result<String, String> {
let Some(name) = find(data, parent.payload, parent.end, b"name", check)? else {
return Ok(String::new());
};
if name.payload + 4 >= name.end {
return Ok(String::new());
}
Ok(
String::from_utf8_lossy(&data[name.payload as usize + 4..name.end as usize])
.trim_end_matches('\0')
.trim()
.into(),
)
}
fn index_pair(data: &[u8], parent: Atom, check: Check<'_>) -> Result<(i64, i64), String> {
let Some(value) = find(data, parent.payload, parent.end, b"data", check)? else {
return Ok((0, 0));
};
if value.payload + 14 > value.end {
return Ok((0, 0));
}
let start = value.payload as usize + 8;
Ok((
u16::from_be_bytes(data[start + 2..start + 4].try_into().unwrap()) as i64,
u16::from_be_bytes(data[start + 4..start + 6].try_into().unwrap()) as i64,
))
}
fn positive(value: &str) -> i64 {
value.trim().parse::<isize>().unwrap_or(0).max(0) as i64
}
fn replay_gain(fields: &Fields) -> Fields {
let mut result: Fields = [
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
]
.into_iter()
.filter_map(|name| {
fields
.get(name)
.map(|v| (name.to_owned(), v.trim().to_owned()))
})
.filter(|(_, value)| !value.is_empty())
.collect();
static NUMBER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[+-]?[0-9]+(?:\.[0-9]+)?").unwrap());
let gain = result
.get("replaygain_track_gain")
.and_then(|v| NUMBER.find(v))
.and_then(|m| m.as_str().parse::<f64>().ok());
let peak = result
.get("replaygain_track_peak")
.and_then(|v| v.parse::<f64>().ok())
.filter(|v| v.is_nan() || *v > 0.0);
if let (Some(gain), Some(peak)) = (gain, peak) {
let clamp = |value: f64| -> i64 {
let value = value.round();
if !value.is_finite()
|| !(-9223372036854775808.0..9223372036854775808.0).contains(&value)
{
0
} else {
(value as i64).clamp(0, 65534)
}
};
let g1 = clamp(10_f64.powf(gain / -10.0) * 1000.0);
let g2 = clamp(10_f64.powf(gain / -10.0) * 2500.0);
let peak = clamp(peak * 32768.0);
result.insert(
"iTunNORM".into(),
[g1, g1, g2, g2, 0, 0, peak, peak, 0, 0]
.map(|n| format!("{n:08X}"))
.join(" "),
);
}
result
}
pub(super) fn edit_freeform(
source: &mut (impl Read + Seek),
fields: &Fields,
replay_gain_only: bool,
check: Check<'_>,
) -> Result<Vec<Section>, String> {
let mut remove = BTreeSet::new();
let values = if replay_gain_only {
let values = replay_gain(fields);
if values.is_empty() {
return Ok(Vec::new());
}
remove.extend(
[
"REPLAYGAIN_TRACK_GAIN",
"REPLAYGAIN_TRACK_PEAK",
"REPLAYGAIN_ALBUM_GAIN",
"REPLAYGAIN_ALBUM_PEAK",
"ITUNNORM",
]
.map(str::to_owned),
);
values
} else {
let mut values = Fields::new();
for (field, name) in [("isrc", "ISRC"), ("label", "LABEL")] {
if let Some(value) = fields.get(field) {
remove.insert(name.to_owned());
values.insert(name.to_owned(), value.trim().to_owned());
if field == "label" {
remove.insert("ORGANIZATION".into());
}
}
}
if values.is_empty() {
return Ok(Vec::new());
}
values
};
let mut section = load(source, b"moov", check)?.ok_or("moov not found")?;
let data = &mut section.data;
let Some(location) = locate(data, true, check)? else {
return Ok(Vec::new());
};
let mut body = Vec::new();
for child in children(data, location.ilst.payload, location.ilst.end, check)? {
if child.kind != *b"----"
|| !remove.contains(&uppercase(&freeform_name(data, child, check)?))
{
body.extend_from_slice(&data[child.start as usize..child.end as usize]);
}
}
for (name, value) in values {
if !value.is_empty() {
body.extend(freeform(&name, &value));
}
if body.len() > MAX_TAG_BYTES {
return Err("MP4 metadata exceeds 64 MiB".into());
}
}
replace(
data,
location.ilst.start,
location.ilst.end,
&build(b"ilst", &body),
&location.ancestors,
section.start,
check,
)?;
Ok(vec![section])
}
@@ -0,0 +1,267 @@
use super::{Atom, Check, Fields, MAX_TAG_BYTES, Section, atom, build, children, find, freeform};
use super::{load, meta, replace, seek, value_atom};
use crate::tags::CheckedReader;
use serde::de::{MapAccess, Visitor};
use serde::{Deserialize, Deserializer};
use std::fmt;
use std::io::{BufReader, ErrorKind, Read, Seek, SeekFrom};
struct Location {
ancestors: Vec<Atom>,
entry: Atom,
}
fn locate(data: &[u8], check: Check<'_>) -> Result<Option<Location>, String> {
let moov = atom(data, 0, data.len() as u64, check)?;
for track in children(data, moov.payload, moov.end, check)?
.into_iter()
.filter(|a| a.kind == *b"trak")
{
let mut ancestors = vec![moov, track];
for kind in [b"mdia", b"minf", b"stbl", b"stsd"] {
let parent = *ancestors.last().unwrap();
let Some(child) = find(data, parent.payload, parent.end, kind, check)? else {
break;
};
ancestors.push(child);
}
if ancestors.len() == 6 {
let stsd = ancestors[5];
if let Some(entry) = find(data, stsd.payload + 8, stsd.end, b"ac-4", check)? {
return Ok(Some(Location { ancestors, entry }));
}
}
}
Ok(None)
}
fn audio_header(data: &[u8], entry: Atom) -> Result<(u16, u64), String> {
let base = entry.payload as usize;
if entry.payload + 10 > entry.end {
return Err("malformed ac-4 sample entry".into());
}
let version = u16::from_be_bytes(data[base + 8..base + 10].try_into().unwrap());
let length = match version {
1 => 44,
2 => 64,
_ => 28,
};
if entry.payload + length > entry.end {
return Err("malformed ac-4 sample entry".into());
}
Ok((version, length))
}
pub(in super::super) fn config<R: Read + Seek>(
source: &mut (impl Read + Seek),
reference: impl FnOnce() -> Result<R, String>,
check: Check<'_>,
) -> Result<Vec<Section>, String> {
seek(source, SeekFrom::Start(0))?;
let mut head = [0; 8];
match source.read_exact(&mut head) {
Err(error) if error.kind() == ErrorKind::UnexpectedEof => return Ok(Vec::new()),
Err(error) => return Err(error.to_string()),
Ok(()) => {}
}
if !head[4..].iter().all(|byte| (0x20..=0x7e).contains(byte)) {
return Ok(Vec::new());
}
let Some(mut section) = load(source, b"moov", check)? else {
return Ok(Vec::new());
};
let ftyp = load(source, b"ftyp", check)?;
let Some(mut location) = locate(&section.data, check)? else {
return Ok(Vec::new());
};
let mut sections = Vec::new();
if let Some(mut ftyp) = ftyp {
let header = atom(&ftyp.data, 0, ftyp.data.len() as u64, check)?;
let base = header.payload as usize;
let mut changed = false;
if base + 4 <= ftyp.data.len() && &ftyp.data[base..base + 4] != b"mp42" {
ftyp.data[base..base + 4].copy_from_slice(b"mp42");
changed = true;
}
let brands_start = ftyp.data.len().min(base + 8);
for brand in ftyp.data[brands_start..].as_chunks_mut::<4>().0 {
check()?;
if brand == b"qt " {
brand.copy_from_slice(b"isom");
changed = true;
}
}
if changed {
sections.push(ftyp);
}
}
let data = &mut section.data;
let (version, _) = audio_header(data, location.entry)?;
if version == 1 {
let base = location.entry.payload;
data[base as usize + 8..base as usize + 10].fill(0);
location.ancestors.push(location.entry);
replace(
data,
base + 28,
base + 44,
&[],
&location.ancestors,
section.start,
check,
)?;
location = locate(data, check)?.ok_or("ac-4 entry lost during normalization")?;
}
let (_, header) = audio_header(data, location.entry)?;
let position = location.entry.payload + header;
if find(data, position, location.entry.end, b"dac4", check)?.is_none() {
check()?;
let mut reference = reference()?;
let mut reference = BufReader::new(CheckedReader {
reader: &mut reference,
check,
});
let reference = load(&mut reference, b"moov", check)?.ok_or("source has no moov")?;
let moov = atom(&reference.data, 0, reference.data.len() as u64, check)?;
let mut config = None;
// Encrypted sample entries can contain dac4 below opaque enca headers.
for start in moov.payload..moov.end.saturating_sub(7) {
if start % 4096 == 0 {
check()?;
}
let index = start as usize;
if &reference.data[index + 4..index + 8] == b"dac4" {
// A signature match is only a candidate. Malformed candidates
// are skipped, but cancellation always propagates.
if let Ok(found) = atom(&reference.data, start, moov.end, &|| Ok(())) {
config = Some(found);
break;
}
}
}
check()?;
let config = config.ok_or("dac4 not found in source")?;
location.ancestors.push(location.entry);
replace(
data,
position,
position,
&reference.data[config.start as usize..config.end as usize],
&location.ancestors,
section.start,
check,
)?;
}
sections.push(section);
Ok(sections)
}
#[derive(Default)]
struct Metadata(Fields);
impl<'de> Deserialize<'de> for Metadata {
fn deserialize<D: Deserializer<'de>>(decoder: D) -> Result<Self, D::Error> {
struct Strings;
impl<'de> Visitor<'de> for Strings {
type Value = Metadata;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("AC-4 metadata object")
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
let mut result = Metadata::default();
while let Some((key, value)) = map.next_entry::<String, serde_json::Value>()? {
// Go retains prior string values on null/type errors and
// continues decoding later fields; duplicate keys are ordered.
if let serde_json::Value::String(value) = value {
result.0.insert(key.to_ascii_lowercase(), value);
}
}
Ok(result)
}
}
decoder.deserialize_map(Strings)
}
}
pub(in super::super) fn metadata(
source: &mut (impl Read + Seek),
metadata_json: &str,
cover: impl FnOnce() -> Result<Option<Vec<u8>>, String>,
check: Check<'_>,
) -> Result<Vec<Section>, String> {
let Some(mut section) = load(source, b"moov", check)? else {
return Ok(Vec::new());
};
if locate(&section.data, check)?.is_none() {
return Ok(Vec::new());
}
if metadata_json.len() > MAX_TAG_BYTES {
return Err("tag edit fields exceed 64 MiB".into());
}
let metadata: Metadata =
serde_json::from_str(&crate::text::json_surrogates(metadata_json)).unwrap_or_default();
let value = |name: &str| metadata.0.get(name).map(String::as_str).unwrap_or("");
let mut body = Vec::new();
for (field, kind) in [
("title", b"\xa9nam"),
("artist", b"\xa9ART"),
("album", b"\xa9alb"),
("albumartist", b"aART"),
("date", b"\xa9day"),
("genre", b"\xa9gen"),
("composer", b"\xa9wrt"),
("copyright", b"cprt"),
("lyrics", b"\xa9lyr"),
] {
if !value(field).trim().is_empty() {
body.extend(value_atom(kind, 1, value(field).as_bytes()));
}
}
for (number, total, kind) in [
("tracknumber", "totaltracks", b"trkn"),
("discnumber", "totaldiscs", b"disk"),
] {
let number = value(number).trim().parse::<isize>().unwrap_or(0);
if number > 0 {
let total = value(total).trim().parse::<isize>().unwrap_or(0);
let mut pair = [0; 8];
pair[2..4].copy_from_slice(&(number as u16).to_be_bytes());
pair[4..6].copy_from_slice(&(total as u16).to_be_bytes());
body.extend(value_atom(kind, 0, &pair));
}
}
for (field, name) in [("isrc", "ISRC"), ("label", "LABEL")] {
if !value(field).trim().is_empty() {
body.extend(freeform(name, value(field).trim()));
}
}
check()?;
if let Some(cover) = cover()?.filter(|cover| !cover.is_empty()) {
if body.len().saturating_add(cover.len()) > MAX_TAG_BYTES {
return Err("MP4 metadata exceeds 64 MiB".into());
}
let kind = if cover.len() >= 8 && cover.starts_with(b"\x89PNG") {
14
} else {
13
};
body.extend(value_atom(b"covr", kind, &cover));
}
let data = &mut section.data;
let moov = atom(data, 0, data.len() as u64, check)?;
let (start, end) = find(data, moov.payload, moov.end, b"udta", check)?
.map_or((moov.end, moov.end), |udta| (udta.start, udta.end));
// The AC-4 helper replaces the whole udta, matching Go's finalization path.
replace(
data,
start,
end,
&build(b"udta", &meta(&body)),
&[moov],
section.start,
check,
)?;
Ok(vec![section])
}
@@ -0,0 +1,228 @@
use super::{
Fields, MAX_TAG_BYTES, copy, edit_comments, parse_comments, picture, put_string, seek,
set_comment,
};
use crate::tags::containers::OggPage;
use base64::{Engine, engine::general_purpose::STANDARD};
use std::io::{Read, Seek, SeekFrom, Write};
use std::sync::LazyLock;
type Check<'a> = &'a dyn Fn() -> Result<(), String>;
fn read_page(source: &mut impl Read) -> Result<Option<OggPage>, String> {
let page = OggPage::read(source)?;
if page.as_ref().is_some_and(|page| page.header[4] != 0) {
return Err("invalid ogg page".into());
}
Ok(page)
}
fn write_page(page: &OggPage, output: &mut impl Write, check: Check<'_>) -> Result<(), String> {
check()?;
let mut bytes = Vec::with_capacity(page.len());
bytes.extend(page.header);
bytes[22..26].fill(0);
bytes[26] = page.segments.len() as u8;
bytes.extend(&page.segments);
bytes.extend(&page.data);
static CRC: LazyLock<[u32; 256]> = LazyLock::new(|| {
std::array::from_fn(|index| {
let mut value = (index as u32) << 24;
for _ in 0..8 {
value = (value << 1)
^ if value & 0x8000_0000 != 0 {
0x04c1_1db7
} else {
0
};
}
value
})
});
let crc = bytes.iter().fold(0_u32, |crc, byte| {
(crc << 8) ^ CRC[((crc >> 24) as u8 ^ byte) as usize]
});
bytes[22..26].copy_from_slice(&crc.to_le_bytes());
output.write_all(&bytes).map_err(|error| error.to_string())
}
pub(super) fn rewrite(
source: &mut (impl Read + Seek),
output: &mut impl Write,
fields: &Fields,
cover: Option<&[u8]>,
check: Check<'_>,
) -> Result<(), String> {
let first = read_page(source)?.ok_or("ogg stream too short")?;
let (opus, packet_count) = if first.data.starts_with(b"OpusHead") {
(true, 2)
} else if first.data.starts_with(b"\x01vorbis") {
(false, 3)
} else {
return Err("unsupported ogg codec".into());
};
let serial = &first.header[14..18];
let mut packets = Vec::new();
let mut current = Vec::new();
let mut page = first.clone();
let mut header_bytes = 0;
let mut header_pages = 0;
loop {
check()?;
header_pages += 1;
header_bytes += page.len();
if header_bytes > MAX_TAG_BYTES {
return Err("Ogg headers exceed 64 MiB".into());
}
if &page.header[14..18] != serial {
return Err("multiplexed ogg streams are not supported".into());
}
let mut offset = 0;
for (index, length) in page.segments.iter().copied().enumerate() {
let end = offset + usize::from(length);
current.extend_from_slice(&page.data[offset..end]);
offset = end;
if length < 255 {
packets.push(std::mem::take(&mut current));
if packets.len() == packet_count {
if index + 1 != page.segments.len() {
return Err("header packet shares a page with audio".into());
}
break;
}
}
}
if packets.len() == packet_count {
break;
}
if header_pages >= 1024 {
return Err("ogg header spans too many pages".into());
}
page = read_page(source)?.ok_or("incomplete ogg header packets")?;
}
let prefix: &[u8] = if opus { b"OpusTags" } else { b"\x03vorbis" };
let body = packets[1]
.strip_prefix(prefix)
.ok_or("comment header not found")?;
let (vendor, mut comments) = parse_comments(body)?;
edit_comments(&mut comments, fields);
if opus {
opus_gain(&mut comments, fields)?;
}
if let Some(cover) = cover
&& let Some(picture) = picture(cover, check)?
{
set_comment(
&mut comments,
"METADATA_BLOCK_PICTURE",
&STANDARD.encode(picture),
);
}
let length = prefix.len()
+ 8
+ vendor.len()
+ comments
.iter()
.map(|comment| 4 + comment.len())
.sum::<usize>()
+ usize::from(!opus);
if length > MAX_TAG_BYTES {
return Err("Ogg comments exceed 64 MiB".into());
}
let mut comment = prefix.to_vec();
put_string(&mut comment, &vendor);
comment.extend((comments.len() as u32).to_le_bytes());
for value in comments {
put_string(&mut comment, &value);
}
if !opus {
comment.push(1);
}
packets[1] = comment;
// Retain the BOS page (including OpusHead output gain) and the Vorbis
// setup packet; only the comment/setup pages are repaginated.
write_page(&first, output, check)?;
let next_sequence = write_headers(&packets[1..], serial, output, check)?;
if next_sequence as usize == header_pages {
seek(source, SeekFrom::Start(header_bytes as u64))?;
return copy(source, output, None, check);
}
let mut sequence = next_sequence;
while let Some(mut page) = read_page(source)? {
check()?;
if &page.header[14..18] != serial {
return Err("multiplexed ogg streams are not supported".into());
}
page.header[18..22].copy_from_slice(&sequence.to_le_bytes());
sequence = sequence.wrapping_add(1);
write_page(&page, output, check)?;
}
check()
}
fn write_headers(
packets: &[Vec<u8>],
serial: &[u8],
output: &mut impl Write,
check: Check<'_>,
) -> Result<u32, String> {
let mut page = OggPage {
header: [0; 27],
segments: Vec::new(),
data: Vec::new(),
};
page.header[..4].copy_from_slice(b"OggS");
page.header[14..18].copy_from_slice(serial);
let mut sequence = 1_u32;
for packet in packets {
let mut rest = packet.as_slice();
loop {
let count = rest.len().min(255);
page.segments.push(count as u8);
page.data.extend_from_slice(&rest[..count]);
rest = &rest[count..];
if page.segments.len() == 255 {
page.header[18..22].copy_from_slice(&sequence.to_le_bytes());
write_page(&page, output, check)?;
sequence = sequence.wrapping_add(1);
page.segments.clear();
page.data.clear();
page.header[5] = u8::from(count == 255);
}
if count < 255 {
break;
}
}
}
if !page.segments.is_empty() {
page.header[18..22].copy_from_slice(&sequence.to_le_bytes());
write_page(&page, output, check)?;
sequence = sequence.wrapping_add(1);
}
Ok(sequence)
}
fn opus_gain(comments: &mut Vec<Vec<u8>>, fields: &Fields) -> Result<(), String> {
for (scope, upper) in [("track", "TRACK"), ("album", "ALBUM")] {
let Some(raw) = fields.get(&format!("replaygain_{scope}_gain")) else {
continue;
};
let raw = raw.trim();
let value = if raw.is_empty() {
String::new()
} else {
let db = raw.strip_suffix("dB").unwrap_or(raw).trim().parse::<f64>();
let q = db.map(|db| ((db - 5.0) * 256.0).round());
match q {
Ok(q) if q.is_finite() && (-32768.0..=32767.0).contains(&q) => {
(q as i32).to_string()
}
_ => return Err(format!("invalid Opus {scope} ReplayGain: {raw:?}")),
}
};
set_comment(comments, &format!("R128_{upper}_GAIN"), &value);
set_comment(comments, &format!("REPLAYGAIN_{upper}_GAIN"), "");
set_comment(comments, &format!("REPLAYGAIN_{upper}_PEAK"), "");
}
Ok(())
}
@@ -0,0 +1,140 @@
use super::{Fields, Section, bytes, id3, metadata_fields, seek};
use crate::tags::{file::ObservedReader, read_audio_tags};
use std::io::{Read, Seek, SeekFrom};
pub(super) fn edit(
source: &mut (impl Read + Seek),
aiff: bool,
fields: &Fields,
cover: Option<&[u8]>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Vec<Section>, String> {
let mut observed = ObservedReader {
file: source,
check,
failure: None,
};
let metadata = read_audio_tags(&mut observed, if aiff { "aiff" } else { "wav" }, &|| Ok(()))
.unwrap_or_default();
if let Some(error) = observed.failure {
return Err(error);
}
let source = observed.file;
let length = seek(source, SeekFrom::End(0))?;
seek(source, SeekFrom::Start(0))?;
let header = bytes(source, 12)?;
if &header[..4] != if aiff { b"FORM" } else { b"RIFF" } {
return Err("unexpected container magic".into());
}
let mut sections = Vec::new();
let mut start = 12_u64;
let mut body_size = 4_u64;
let mut embedded_cover = None;
for _ in 0..65536 {
check()?;
if start + 8 > length {
break;
}
seek(source, SeekFrom::Start(start))?;
let header = bytes(source, 8)?;
let size = if aiff {
u32::from_be_bytes(header[4..].try_into().unwrap())
} else {
u32::from_le_bytes(header[4..].try_into().unwrap())
};
let end = start + 8 + u64::from(size) + u64::from(size & 1);
if header[..4].eq_ignore_ascii_case(b"id3 ") {
if cover.is_none()
&& size > 0
&& size <= 16 * 1024 * 1024
&& end <= length
&& matches!(&header[..4], b"ID3 " | b"id3 ")
{
embedded_cover = id3::cover(&bytes(source, size as usize)?);
}
sections.push(Section {
start,
end: end.min(length),
data: Vec::new(),
});
} else {
if end > length {
return Err("unexpected EOF while copying audio".into());
}
body_size += end - start;
}
start = end;
}
if start + 8 <= length {
return Err("RIFF chunk count exceeds 65536".into());
}
let cover_path = fields.get("cover_path").map(|p| p.trim()).unwrap_or("");
if !cover_path.is_empty() && cover.is_none() {
return Err("read cover art: file not found".into());
}
if cover.is_some_and(|cover| cover.is_empty()) {
return Err("cover art is empty".into());
}
let cover = if let Some(cover) = cover {
let mime = if cover.len() >= 8 && cover.starts_with(b"\x89PNG") {
"image/png"
} else if cover.len() >= 12 && &cover[..4] == b"RIFF" && &cover[8..12] == b"WEBP" {
"image/webp"
} else if cover.starts_with(b"GIF87a") || cover.starts_with(b"GIF89a") {
"image/gif"
} else {
match std::path::Path::new(cover_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"png" => "image/png",
"webp" => "image/webp",
"gif" => "image/gif",
_ => "image/jpeg",
}
};
Some((cover, mime))
} else {
embedded_cover
.as_ref()
.map(|(data, mime)| (data.as_slice(), mime.as_str()))
};
let tag = id3::fresh(&metadata_fields(&metadata, fields), cover, check)?;
let tag_size = tag.len() as u32;
let mut chunk = if aiff {
b"ID3 ".to_vec()
} else {
b"id3 ".to_vec()
};
chunk.extend(if aiff {
tag_size.to_be_bytes()
} else {
tag_size.to_le_bytes()
});
chunk.extend(tag);
if tag_size & 1 == 1 {
chunk.push(0)
}
body_size += chunk.len() as u64;
let size = u32::try_from(body_size).map_err(|_| "RIFF container exceeds 32-bit size")?;
sections.push(Section {
start: 4,
end: 8,
data: if aiff {
size.to_be_bytes()
} else {
size.to_le_bytes()
}
.to_vec(),
});
// Go drops an incomplete trailing chunk header, then appends the new ID3.
sections.push(Section {
start: start.min(length),
end: length,
data: chunk,
});
Ok(sections)
}
+69
View File
@@ -0,0 +1,69 @@
/// Go replaces every malformed UTF-8 byte separately when serializing JSON.
/// Rust's lossy decoder can combine a truncated sequence into one replacement.
pub(crate) fn utf8(mut bytes: &[u8]) -> String {
let mut text = String::new();
while !bytes.is_empty() {
match std::str::from_utf8(bytes) {
Ok(valid) => {
text.push_str(valid);
break;
}
Err(error) => {
let valid = error.valid_up_to();
text.push_str(std::str::from_utf8(&bytes[..valid]).expect("valid UTF-8 prefix"));
text.push(char::REPLACEMENT_CHARACTER);
bytes = &bytes[valid + 1..];
}
}
}
text
}
/// encoding/json replaces unpaired UTF-16 escapes, including in ignored fields.
pub fn json_surrogates(json: &str) -> std::borrow::Cow<'_, str> {
fn unit(bytes: &[u8]) -> Option<u16> {
let digits = bytes.get(..4)?;
if !digits.iter().all(u8::is_ascii_hexdigit) {
return None;
}
u16::from_str_radix(std::str::from_utf8(digits).ok()?, 16).ok()
}
let bytes = json.as_bytes();
let (mut position, mut copied, mut quoted) = (0, 0, false);
let mut output = String::new();
while position < bytes.len() {
if quoted && bytes[position] == b'\\' {
if bytes.get(position + 1) == Some(&b'u')
&& let Some(code) = unit(&bytes[position + 2..])
{
if (0xd800..=0xdbff).contains(&code)
&& bytes.get(position + 6..position + 8) == Some(b"\\u")
&& unit(&bytes[position + 8..])
.is_some_and(|code| (0xdc00..=0xdfff).contains(&code))
{
position += 12;
continue;
}
if (0xd800..=0xdfff).contains(&code) {
output.push_str(&json[copied..position]);
output.push_str("\\ufffd");
copied = position + 6;
}
position += 6;
continue;
}
position += 2;
continue;
}
if bytes[position] == b'"' {
quoted = !quoted;
}
position += 1;
}
if copied == 0 {
std::borrow::Cow::Borrowed(json)
} else {
output.push_str(&json[copied..]);
std::borrow::Cow::Owned(output)
}
}