//! The remembered peer copy of every streamed track — on disk, where a //! process with no WebView can read it. //! //! The phone's resolver remembers which peer file it last played for a //! catalog track (`src/lib/sources.js`, `soulstream.streamSources.v1`), and //! that memory is what lets a track play again from the stream cache with no //! search at all. It lived in the WebView's localStorage, which meant it //! existed only while a WebView did: Android Auto starting the app cold — //! no Activity, no page — could see the cache files sitting in //! `stream-cache/` and had no way to tell which playlist row any of them //! belonged to. Every streamed row was "needs the app", even the ones whose //! bytes were all there. //! //! This is the same memory, mirrored to `/stream-sources.json` by //! the page every time it remembers a source, and read back here by the //! headless browse tree and the cold player. The page stays the writer of //! record for its own store; this file is a second copy that the Rust side //! owns, and the two are keyed identically so a row found by either agrees //! with the other. //! //! Also the home of `keep`: a source fetched because its playlist is kept //! offline is marked, and `playback::prune_cache` leaves that file alone //! however old it gets. Without it the retention window (thirty days //! unplayed, two gigabytes) would quietly undo an offline playlist. use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::{Path, PathBuf}; /// One remembered peer file. `size` is the peer-stated size, which is also /// how a byte-complete cache file is recognised. #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct StreamSource { pub username: String, pub path: String, pub size: u64, #[serde(default)] pub duration: Option, /// Pinned by an offline playlist; the cache pruner skips it. #[serde(default)] pub keep: bool, } /// What the page sends when it remembers (or re-sends) a source. #[derive(Debug, Clone, Deserialize)] pub struct RememberArg { pub track_id: Option, pub artist: Option, pub title: Option, pub username: String, pub path: String, pub size: u64, #[serde(default)] pub duration: Option, /// `None` leaves an existing pin as it was; `Some` sets it. #[serde(default)] pub keep: Option, } /// More than the page keeps (400): this file also holds the offline pins, /// and a pinned source must never be the one an unpinned one pushes out. const CAP: usize = 1200; pub fn file(data_dir: &Path) -> PathBuf { data_dir.join("stream-sources.json") } /// The lookup keys for a track, the page's own rule (`streamSourceKeys`): /// the catalog id when there is one, and the lowercased `artist|title`. pub fn keys(track_id: Option<&str>, artist: Option<&str>, title: Option<&str>) -> Vec { let mut out = Vec::with_capacity(2); if let Some(t) = track_id.map(str::trim).filter(|t| !t.is_empty()) { out.push(format!("t:{t}")); } if let (Some(a), Some(t)) = (artist, title) { if !a.is_empty() && !t.is_empty() { out.push(format!("n:{a}|{t}").to_lowercase()); } } out } /// Insertion-ordered so the cap can drop the oldest memory first — a /// BTreeMap would forget which was old. pub fn load(data_dir: &Path) -> Vec<(String, StreamSource)> { std::fs::read_to_string(file(data_dir)) .ok() .and_then(|s| serde_json::from_str::>(&s).ok()) .unwrap_or_default() } fn save(data_dir: &Path, entries: &[(String, StreamSource)]) { let path = file(data_dir); if let Ok(json) = serde_json::to_string(entries) { // Written beside and renamed over: a cold browse may be reading the // file at this exact moment, and it must see the old whole file or // the new whole file, never half of one. let tmp = path.with_extension("json.tmp"); if std::fs::write(&tmp, json).is_ok() { let _ = std::fs::rename(&tmp, &path); } } } /// Remember these sources. Every key of an entry maps to the same record; /// an existing key is moved to the end (newest), and `keep` is carried /// forward from the old record unless the argument sets it. pub fn remember(data_dir: &Path, args: &[RememberArg]) { if args.is_empty() { return; } let mut entries = load(data_dir); for a in args { if a.username.is_empty() || a.path.is_empty() || a.size == 0 { continue; } for key in keys(a.track_id.as_deref(), a.artist.as_deref(), a.title.as_deref()) { let old_keep = entries .iter() .position(|(k, _)| *k == key) .map(|i| entries.remove(i).1.keep) .unwrap_or(false); entries.push(( key, StreamSource { username: a.username.clone(), path: a.path.clone(), size: a.size, duration: a.duration, keep: a.keep.unwrap_or(old_keep), }, )); } } if entries.len() > CAP { // Drop the oldest UNPINNED memories first; a pin outlives the cap. let over = entries.len() - CAP; let mut dropped = 0; entries.retain(|(_, s)| { if dropped < over && !s.keep { dropped += 1; false } else { true } }); } save(data_dir, &entries); } /// Pin or unpin the source remembered for a track. pub fn set_keep(data_dir: &Path, track_id: Option<&str>, artist: Option<&str>, title: Option<&str>, keep: bool) { let wanted = keys(track_id, artist, title); if wanted.is_empty() { return; } let mut entries = load(data_dir); let mut changed = false; for (k, s) in entries.iter_mut() { if wanted.contains(k) && s.keep != keep { s.keep = keep; changed = true; } } if changed { save(data_dir, &entries); } } /// The remembered source for a track, catalog id first. pub fn lookup(data_dir: &Path, track_id: Option<&str>, artist: Option<&str>, title: Option<&str>) -> Option { let wanted = keys(track_id, artist, title); if wanted.is_empty() { return None; } let entries = load(data_dir); for key in &wanted { if let Some((_, s)) = entries.iter().find(|(k, _)| k == key) { return Some(s.clone()); } } None } /// The cache file this source would land in, if its bytes are all there. /// `None` for a missing file or a mid-transfer prefix — a prefix is not /// the track, and a car that started one would stop mid-song. pub fn cached_file(data_dir: &Path, src: &StreamSource) -> Option { let file = crate::playback::cache_file_in(&crate::playback::cache_dir_in(data_dir), &src.username, &src.path); match std::fs::metadata(&file) { Ok(md) if src.size > 0 && md.len() == src.size => Some(file), _ => None, } } /// Every cache file a pinned source names — the pruner's exemption list. pub fn kept_cache_files(data_dir: &Path) -> HashSet { let dir = crate::playback::cache_dir_in(data_dir); load(data_dir) .into_iter() .filter(|(_, s)| s.keep) .map(|(_, s)| crate::playback::cache_file_in(&dir, &s.username, &s.path)) .collect() } /// Where this instance keeps its data — the same directory the config and /// the stream cache live in, which on Android is also the `dataDir` the /// media service hands to the cold browse. fn data_dir(app: &tauri::AppHandle) -> Result { crate::paths::app_data(app) } /// The page remembering a source (one), or re-sending its whole store at /// boot so memories made before this file existed reach the cold side. #[tauri::command] pub fn stream_source_remember(app: tauri::AppHandle, entries: Vec) -> Result<(), String> { let dir = data_dir(&app)?; tauri::async_runtime::spawn_blocking(move || remember(&dir, &entries)); Ok(()) } /// Pin or unpin a track's remembered source for the cache pruner. #[tauri::command] pub fn stream_source_keep( app: tauri::AppHandle, track_id: Option, artist: Option, title: Option, keep: bool, ) -> Result<(), String> { let dir = data_dir(&app)?; tauri::async_runtime::spawn_blocking(move || { set_keep(&dir, track_id.as_deref(), artist.as_deref(), title.as_deref(), keep) }); Ok(()) } #[cfg(test)] mod tests { use super::*; fn tmp() -> PathBuf { let d = std::env::temp_dir().join(format!("ss-stream-sources-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } #[test] fn keys_follow_the_page() { assert_eq!( keys(Some("42"), Some("Hermeth"), Some("Once Again")), vec!["t:42".to_string(), "n:hermeth|once again".to_string()] ); assert_eq!(keys(None, Some("A"), None), Vec::::new()); } #[test] fn remember_then_lookup_by_either_key_and_keep_survives() { let d = tmp(); remember( &d, &[RememberArg { track_id: Some("7".into()), artist: Some("Hermeth".into()), title: Some("Strictly Acid".into()), username: "peer".into(), path: "a\\b.flac".into(), size: 10, duration: Some(280.0), keep: None, }], ); assert_eq!(lookup(&d, Some("7"), None, None).unwrap().path, "a\\b.flac"); assert_eq!(lookup(&d, None, Some("hermeth"), Some("STRICTLY ACID")).unwrap().size, 10); set_keep(&d, Some("7"), None, None, true); // a re-remember without `keep` carries the pin forward remember( &d, &[RememberArg { track_id: Some("7".into()), artist: None, title: None, username: "peer2".into(), path: "c\\d.flac".into(), size: 11, duration: None, keep: None, }], ); let s = lookup(&d, Some("7"), None, None).unwrap(); assert_eq!(s.username, "peer2"); assert!(s.keep); assert_eq!(kept_cache_files(&d).len(), 1); } #[test] fn cached_file_wants_the_whole_track() { let d = tmp(); let src = StreamSource { username: "u".into(), path: "x\\y.mp3".into(), size: 3, duration: None, keep: false }; assert!(cached_file(&d, &src).is_none()); let f = crate::playback::cache_file_in(&crate::playback::cache_dir_in(&d), "u", "x\\y.mp3"); std::fs::create_dir_all(f.parent().unwrap()).unwrap(); std::fs::write(&f, b"ab").unwrap(); assert!(cached_file(&d, &src).is_none(), "a prefix is not the track"); std::fs::write(&f, b"abc").unwrap(); assert_eq!(cached_file(&d, &src), Some(f)); } }