use super::{ AlbumAvailability, TrackAvailability, album, deezer_id_from_metadata, from_deezer, from_links, resolved_links, }; use crate::deezer::MetadataLookup; use crate::lyrics::{LyricsError, builtin::TrackResolver}; use crate::resolver::{Check, PlatformResolverService, Resolver, ResolverError}; use std::collections::BTreeMap; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant}; #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum Kind { Availability, Links, } #[derive(Clone)] enum Value { Availability(Box), Links(BTreeMap), } type Outcome = Result; #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] struct Key { kind: Kind, region: String, id: String, isrc: bool, } struct Entry { result: Outcome, expires: Instant, } #[derive(Default)] struct Flight { waiters: AtomicUsize, result: Mutex>, ready: Condvar, } struct Waiter(Arc); impl Drop for Waiter { fn drop(&mut self) { self.0.waiters.fetch_sub(1, Ordering::AcqRel); } } struct State { generation: u64, region: String, cache: BTreeMap, flights: BTreeMap<(u64, Key), Arc>, active: usize, } struct Inner { resolver: PlatformResolverService, metadata: Arc, state: Mutex, closed: AtomicBool, idle: Condvar, } /// One native owner for raw resolver work and both application caches. pub struct AvailabilityService { inner: Arc, } impl AvailabilityService { pub fn new(resolver: Arc, metadata: Arc) -> Self { Self { inner: Arc::new(Inner { resolver: PlatformResolverService::new(resolver), metadata, state: Mutex::new(State { generation: 0, region: "US".into(), cache: BTreeMap::new(), flights: BTreeMap::new(), active: 0, }), closed: AtomicBool::new(false), idle: Condvar::new(), }), } } pub fn set_region(&self, region: &str) -> Result<(), ResolverError> { let mut state = self.inner.state.lock().unwrap(); self.inner.check(&|| Ok(()))?; let region = spotiflac_core::matching::uppercase(region.trim()); state.region = if region.len() == 2 && region.bytes().all(|byte| byte.is_ascii_uppercase()) { region } else { "US".into() }; Ok(()) } pub fn region(&self) -> Result { self.inner.check(&|| Ok(()))?; Ok(self.inner.state.lock().unwrap().region.clone()) } pub fn clear_cache(&self) -> Result { let mut state = self.inner.state.lock().unwrap(); self.inner.check(&|| Ok(()))?; state.generation = state.generation.wrapping_add(1); let count = state.cache.len(); state.cache.clear(); Ok(count) } pub fn check_track( &self, spotify_id: &str, isrc: &str, check: &Check<'_>, ) -> Result { match self.lookup(Kind::Availability, spotify_id, isrc, check)? { Value::Availability(value) => Ok(*value), _ => unreachable!("availability cache type"), } } pub fn track_platform_links( &self, spotify_id: &str, isrc: &str, check: &Check<'_>, ) -> Result, ResolverError> { match self.lookup(Kind::Links, spotify_id, isrc, check)? { Value::Links(value) => Ok(value), _ => unreachable!("links cache type"), } } pub fn check_album( &self, id: &str, check: &Check<'_>, ) -> Result { self.with_raw(check, |resolver, check| album(resolver, id, check)) } pub fn check_from_deezer( &self, id: &str, check: &Check<'_>, ) -> Result { self.with_raw(check, |resolver, check| from_deezer(resolver, id, check)) } pub fn deezer_album_id(&self, id: &str, check: &Check<'_>) -> Result { let album = self.check_album(id, check)?; if album.deezer && !album.deezer_id.is_empty() { Ok(album.deezer_id) } else { Err(ResolverError::Failed("album not found on Deezer".into())) } } fn with_raw( &self, check: &Check<'_>, work: impl FnOnce(&PlatformResolverService, &Check<'_>) -> Result, ) -> Result { self.inner.check(check)?; let result = work(&self.inner.resolver, &|| { self.inner.check(check).map_err(|error| error.to_string()) }); self.inner.check(check)?; result } fn lookup(&self, kind: Kind, spotify_id: &str, isrc: &str, check: &Check<'_>) -> Outcome { self.inner.check(check)?; let spotify_id = spotify_id.trim(); let isrc = spotiflac_core::matching::uppercase(isrc.trim()); let (id, isrc) = if !spotify_id.is_empty() { (spotify_id.to_owned(), false) } else if !isrc.is_empty() { (isrc, true) } else { return Err(ResolverError::Failed( "spotify track ID and ISRC are empty".into(), )); }; if id.len() > 64 << 10 { return Err(ResolverError::Failed( "availability input exceeds limit".into(), )); } let (key, generation, flight, start) = { let mut state = self.inner.state.lock().unwrap(); self.inner.check(check)?; let key = Key { kind, region: state.region.clone(), id, isrc, }; if let Some(entry) = state.cache.get(&key) && Instant::now() <= entry.expires { return entry.result.clone().map_err(|_| { ResolverError::Failed( if kind == Kind::Availability { "track availability unavailable (cached)" } else { "track platform links unavailable (cached)" } .into(), ) }); } state.cache.remove(&key); let generation = state.generation; let flight_key = (generation, key.clone()); let existing = state .flights .get(&flight_key) .filter(|flight| flight.waiters.load(Ordering::Acquire) > 0) .cloned(); let (flight, start) = if let Some(flight) = existing { (flight, false) } else { if state.active >= 64 { return Err(ResolverError::Busy); } let flight = Arc::new(Flight::default()); state.flights.insert(flight_key, flight.clone()); state.active += 1; (flight, true) }; flight.waiters.fetch_add(1, Ordering::AcqRel); (key, generation, flight, start) }; let _waiter = Waiter(flight.clone()); if start { let inner = self.inner.clone(); let worker_key = key.clone(); let worker_flight = flight.clone(); let spawned = std::thread::Builder::new() .name("platform-availability".into()) .spawn(move || { let check = || { if inner.closed.load(Ordering::Acquire) { Err("availability service closed".into()) } else if worker_flight.waiters.load(Ordering::Acquire) == 0 { Err("availability request cancelled".into()) } else { Ok(()) } }; let result = catch_unwind(AssertUnwindSafe(|| inner.fetch(&worker_key, &check))) .unwrap_or_else(|_| { Err(ResolverError::Failed("availability worker panicked".into())) }); let result = check().map_err(ResolverError::Cancelled).and(result); inner.finish(generation, &worker_key, &worker_flight, result); }); if let Err(error) = spawned { self.inner.finish( generation, &key, &flight, Err(ResolverError::Failed(error.to_string())), ); } } loop { self.inner.check(check)?; let result = flight.result.lock().unwrap(); if let Some(value) = result.as_ref() { let value = value.clone(); drop(result); self.inner.check(check)?; return value; } drop( flight .ready .wait_timeout(result, Duration::from_millis(25)) .unwrap(), ); } } pub fn shutdown(&self) { self.inner.closed.store(true, Ordering::Release); let mut state = self.inner.state.lock().unwrap(); while state.active != 0 { state = self.inner.idle.wait(state).unwrap(); } state.cache.clear(); drop(state); self.inner.resolver.shutdown(); } } impl Drop for AvailabilityService { fn drop(&mut self) { self.shutdown(); } } impl TrackResolver for AvailabilityService { fn deezer_id_from_spotify(&self, id: &str, check: &Check<'_>) -> Result { let track = self .check_track(id, "", check) .map_err(|error| match error { ResolverError::Cancelled(message) => LyricsError::Cancelled(message), ResolverError::Closed => LyricsError::Cancelled(error.to_string()), ResolverError::Transport(message) => LyricsError::Unavailable(message), _ => LyricsError::Other(error.to_string()), })?; if track.deezer && !track.deezer_id.is_empty() { Ok(track.deezer_id) } else { Err(LyricsError::Other("track not found on Deezer".into())) } } } impl Inner { fn check(&self, check: &Check<'_>) -> Result<(), ResolverError> { if self.closed.load(Ordering::Acquire) { Err(ResolverError::Closed) } else { check().map_err(ResolverError::Cancelled) } } fn fetch(&self, key: &Key, check: &Check<'_>) -> Outcome { let id = if key.isrc { let started = Instant::now(); let track = self.metadata.search_by_isrc(&key.id, &|| { check()?; if started.elapsed() >= Duration::from_secs(30) { Err("ISRC lookup deadline exceeded".into()) } else { Ok(()) } })?; let id = deezer_id_from_metadata(&track); if id.is_empty() { return Err(ResolverError::Failed(format!( "failed to resolve Deezer track ID from ISRC {}", key.id ))); } id } else { key.id.clone() }; if key.kind == Kind::Availability && key.isrc { return from_deezer(&self.resolver, &id, check) .map(|value| Value::Availability(Box::new(value))); } let url = if key.isrc { format!("https://www.deezer.com/track/{id}") } else { format!("https://open.spotify.com/track/{id}") }; let links = resolved_links(&self.resolver, &url, check)?; if key.kind == Kind::Availability { return Ok(Value::Availability(Box::new(from_links(&id, &links)))); } let links: BTreeMap<_, _> = links .into_iter() .filter_map(|(key, value)| { let value = value.trim(); (value.starts_with("https://") || value.starts_with("http://")) .then(|| (key, value.into())) }) .collect(); if links.is_empty() { Err(ResolverError::Failed("no platform links found".into())) } else { Ok(Value::Links(links)) } } fn finish(&self, generation: u64, key: &Key, flight: &Arc, result: Outcome) { let mut state = self.state.lock().unwrap(); if generation == state.generation && !self.closed.load(Ordering::Acquire) && !matches!( result, Err(ResolverError::Cancelled(_) | ResolverError::Closed | ResolverError::Busy) ) { let limit = if key.kind == Kind::Availability { 500 } else { 200 }; if !state.cache.contains_key(key) && state .cache .keys() .filter(|entry| entry.kind == key.kind) .count() >= limit && let Some(oldest) = state .cache .iter() .filter(|(entry, _)| entry.kind == key.kind) .min_by_key(|(_, value)| value.expires) .map(|(key, _)| key.clone()) { state.cache.remove(&oldest); } let ttl = Duration::from_secs(if result.is_ok() { 30 * 60 } else { 5 * 60 }); state.cache.insert( key.clone(), Entry { result: result.clone(), expires: Instant::now() + ttl, }, ); } *flight.result.lock().unwrap() = Some(result); flight.ready.notify_all(); let flight_key = (generation, key.clone()); if state .flights .get(&flight_key) .is_some_and(|value| Arc::ptr_eq(value, flight)) { state.flights.remove(&flight_key); } state.active -= 1; self.idle.notify_all(); } } #[cfg(test)] mod tests { use super::*; use crate::resolver::{Metadata, Resolution}; use spotiflac_core::metadata::TrackMetadata; #[derive(Default)] struct Probe { calls: AtomicUsize, metadata_calls: AtomicUsize, release: AtomicBool, metadata_blocked: AtomicBool, failed: AtomicBool, } impl Resolver for Probe { fn resolve( &self, _: &str, _: &Metadata, check: &Check<'_>, ) -> Result { self.calls.fetch_add(1, Ordering::AcqRel); while !self.release.load(Ordering::Acquire) { check().map_err(ResolverError::Cancelled)?; std::thread::sleep(Duration::from_millis(2)); } if self.failed.load(Ordering::Acquire) { return Err(ResolverError::Failed("fixture failure".into())); } Ok(Resolution { links: BTreeMap::from([( "deezer".into(), "https://www.deezer.com/track/101".into(), )]), ..Resolution::default() }) } } impl MetadataLookup for Probe { fn search_by_isrc( &self, _: &str, check: &Check<'_>, ) -> Result { self.metadata_calls.fetch_add(1, Ordering::AcqRel); while self.metadata_blocked.load(Ordering::Acquire) { check().map_err(ResolverError::Cancelled)?; std::thread::sleep(Duration::from_millis(2)); } Ok(TrackMetadata { spotify_id: "deezer:42".into(), ..TrackMetadata::default() }) } } fn until(predicate: impl Fn() -> bool) { let started = Instant::now(); while !predicate() { assert!( started.elapsed() < Duration::from_secs(3), "availability test timed out" ); std::thread::sleep(Duration::from_millis(2)); } } #[test] fn shared_requests_cancel_independently_and_return_owned_cached_responses() { let probe = Arc::new(Probe::default()); let service = Arc::new(AvailabilityService::new(probe.clone(), probe.clone())); let cancelled = Arc::new(AtomicBool::new(false)); let first = { let service = service.clone(); let cancelled = cancelled.clone(); std::thread::spawn(move || { service.check_track("example", "", &|| { if cancelled.load(Ordering::Acquire) { Err("first cancelled".into()) } else { Ok(()) } }) }) }; until(|| probe.calls.load(Ordering::Acquire) == 1); let second = { let service = service.clone(); std::thread::spawn(move || service.check_track("example", "", &|| Ok(()))) }; until(|| { service .inner .state .lock() .unwrap() .flights .values() .next() .unwrap() .waiters .load(Ordering::Acquire) == 2 }); cancelled.store(true, Ordering::Release); assert_eq!( first.join().unwrap(), Err(ResolverError::Cancelled("first cancelled".into())) ); probe.release.store(true, Ordering::Release); let mut track = second.join().unwrap().unwrap(); track.deezer_id = "caller changed".into(); assert_eq!( service .check_track("example", "", &|| Ok(())) .unwrap() .deezer_id, "101" ); assert_eq!(probe.calls.load(Ordering::Acquire), 1); let mut links = service .track_platform_links("example", "", &|| Ok(())) .unwrap(); links.insert("deezer".into(), "changed".into()); assert_ne!( service .track_platform_links("example", "", &|| Ok(())) .unwrap(), links ); assert_eq!(probe.calls.load(Ordering::Acquire), 2); assert_eq!( service .check_track("", "usaa00000001", &|| Ok(())) .unwrap() .deezer_id, "42" ); service .track_platform_links("", "USAA00000001", &|| Ok(())) .unwrap(); service .check_track("", " USAA00000001 ", &|| Ok(())) .unwrap(); assert_eq!(probe.metadata_calls.load(Ordering::Acquire), 2); } #[test] fn shutdown_cancels_isrc_and_uncached_calls_and_rejects_retained_apis() { let probe = Arc::new(Probe::default()); probe.metadata_blocked.store(true, Ordering::Release); let service = Arc::new(AvailabilityService::new(probe.clone(), probe.clone())); let pending = { let service = service.clone(); std::thread::spawn(move || service.check_track("", "USAA00000001", &|| Ok(()))) }; until(|| probe.metadata_calls.load(Ordering::Acquire) == 1); let raw = { let service = service.clone(); std::thread::spawn(move || service.check_album("example", &|| Ok(()))) }; until(|| probe.calls.load(Ordering::Acquire) == 1); service.shutdown(); assert_eq!(pending.join().unwrap(), Err(ResolverError::Closed)); assert_eq!(raw.join().unwrap(), Err(ResolverError::Closed)); assert_eq!( service.check_track("", "", &|| Ok(())), Err(ResolverError::Closed) ); assert_eq!( service.track_platform_links("", "", &|| Ok(())), Err(ResolverError::Closed) ); assert_eq!( service.check_from_deezer("", &|| Ok(())), Err(ResolverError::Closed) ); assert_eq!(service.set_region("ID"), Err(ResolverError::Closed)); assert_eq!(service.region(), Err(ResolverError::Closed)); assert_eq!(service.clear_cache(), Err(ResolverError::Closed)); assert_eq!(service.inner.state.lock().unwrap().active, 0); } #[test] fn cache_clear_and_expiry_keep_stale_and_cancelled_results_out() { let probe = Arc::new(Probe::default()); let service = Arc::new(AvailabilityService::new(probe.clone(), probe.clone())); let pending = { let service = service.clone(); std::thread::spawn(move || service.check_track("old", "", &|| Ok(()))) }; until(|| probe.calls.load(Ordering::Acquire) == 1); service.clear_cache().unwrap(); probe.release.store(true, Ordering::Release); pending.join().unwrap().unwrap(); assert!(service.inner.state.lock().unwrap().cache.is_empty()); service.check_track("old", "", &|| Ok(())).unwrap(); probe.failed.store(true, Ordering::Release); assert!( service .track_platform_links("failed", "", &|| Ok(())) .is_err() ); assert!( service .track_platform_links("failed", "", &|| Ok(())) .is_err() ); assert_eq!(probe.calls.load(Ordering::Acquire), 3); { let mut state = service.inner.state.lock().unwrap(); let positive = state .cache .values() .find(|entry| entry.result.is_ok()) .unwrap(); let negative = state .cache .values() .find(|entry| entry.result.is_err()) .unwrap(); assert!( positive.expires.duration_since(negative.expires) > Duration::from_secs(24 * 60) ); for entry in state.cache.values_mut() { entry.expires = Instant::now() - Duration::from_secs(1); } } probe.failed.store(false, Ordering::Release); service .track_platform_links("failed", "", &|| Ok(())) .unwrap(); assert_eq!(probe.calls.load(Ordering::Acquire), 4); probe.release.store(false, Ordering::Release); let started = Instant::now(); assert!(matches!( service.check_track( "cancel", "", &|| if started.elapsed() >= Duration::from_millis(40) { Err("cancel".into()) } else { Ok(()) } ), Err(ResolverError::Cancelled(_)) )); until(|| service.inner.state.lock().unwrap().active == 0); assert!( !service .inner .state .lock() .unwrap() .cache .keys() .any(|key| key.id == "cancel") ); } #[test] fn independent_cache_limits_region_keys_and_lookup_limit_are_enforced() { let probe = Arc::new(Probe::default()); let service = Arc::new(AvailabilityService::new(probe.clone(), probe.clone())); let mut waiters = Vec::new(); for index in 0..64 { let service = service.clone(); waiters.push(std::thread::spawn(move || { service.check_track(&index.to_string(), "", &|| Ok(())) })); } until(|| service.inner.state.lock().unwrap().active == 64); assert_eq!( service.check_track("over-limit", "", &|| Ok(())), Err(ResolverError::Busy) ); probe.release.store(true, Ordering::Release); for waiter in waiters { waiter.join().unwrap().unwrap(); } service.clear_cache().unwrap(); for index in 0..503 { service .check_track(&index.to_string(), "", &|| Ok(())) .unwrap(); } for index in 0..203 { service .track_platform_links(&index.to_string(), "", &|| Ok(())) .unwrap(); } { let state = service.inner.state.lock().unwrap(); assert_eq!( state .cache .keys() .filter(|key| key.kind == Kind::Availability) .count(), 500 ); assert_eq!( state .cache .keys() .filter(|key| key.kind == Kind::Links) .count(), 200 ); assert!(!state.cache.keys().any(|key| key.id == "0")); } service.set_region("id").unwrap(); service.check_track("502", "", &|| Ok(())).unwrap(); service.set_region("bad region").unwrap(); assert_eq!(service.region().unwrap(), "US"); assert!( service .inner .state .lock() .unwrap() .cache .keys() .any(|key| key.region == "ID") ); } }