mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-13 19:21:23 +02:00
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:
1 parent
1da0da18a6
commit
aa99726439
452 files changed
+64417
-65088
No files matched your search
@@ -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(®istry.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(']'))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(×tamp(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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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 §ions {
|
||||
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(§ion.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(§ion.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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user