Four things the car could not do, all from a process with no Activity.
A streamed playlist row plays cold when its bytes are already here. The
page's memory of which peer file a catalog track resolved to lived only in
localStorage; it is mirrored now to `<data>/stream-sources.json`
(stream_sources.rs) — every remembered source, and the whole store once at
boot — and auto_browse.rs looks a row up there, computes its cache file
with the same hash playback::cache_path uses, and calls it playable when
the file is byte-complete. GhettoTechie reads "8 of 40 here" cold and a
tapped row plays from the cache with its catalog cover and colour.
The car resumes, the way every other player does. Where playback was is in
`<data>/resume.json` (resume.rs): the page names the queue whenever it
writes its session, the engine's pump keeps the position. media3's
MediaButtonReceiver is declared, so a PLAY key into a dead app — a
Bluetooth head unit, the Quick Settings card, Auto's autoplay on
connection — starts the service and lands in onPlaybackResumption, which
answers with the `ss:resume` row; the RECENT root serves the same row, and
the Recent tab lists it first. A force-stopped app came back PLAYING where
it left off from one `cmd media_session dispatch play`.
The car is told. A tap that cannot play — a streamed row with no cache copy
while the app is cold, a hand-off the resolver refused — used to be
silence. MediaHub.fail puts the reason in the playback state (STATE_ERROR,
which Auto draws as a card) and sends a SessionError carrying "Open
SoulStream"; the next track withdraws it. The page reports its own verdict
through the new `remote_failed` command.
Every tap while the app is UP is the page's. A library row tapped in the
car used to start on the engine underneath the page's own queue — the bar
naming one track while another played. `AutoNative.describe` spells the
row for the page and `playMediaId` plays it as the page's queue; the plugin
disarms the hand-off in onDestroy, and with the page gone the card's
transport goes to the engine. When the app opens over a car session, the
headless queue is handed to the page once (`playback::init` takes it before
clearing) and restoreSession builds the bar from it.
Every playlist row is playable: Auto draws a non-playable, non-browsable
item as a tile and BROWSES it on tap ("No items"). Rows that need the app
say so and fail on tap with the card; rows whose file is here wear the
host's own downloaded mark. The Recent tab's album subtitle counted played
tracks; it counts the album's now. The page never clears the resume point
and only drops its own session once it has booted — a visibility change
under a launch with the screen off wiped both.
The Soulseek session dials itself back after a drop. A server connection
dies for reasons that are nobody's — the phone changes networks, Android
freezes a backgrounded process past the five-minute keepalive — and until
now every one of those ended as `Event::Disconnected`, the page stamped
offline, and every streamed track after it refused with "sign in on
Community"; in a car nobody opens the login card. `schedule_reconnect`
redials with the stored credentials at 5, 15, 45, 120, then 300 s with
jitter, under the server's rapid-login throttle. Every session carries a
generation: an explicit login or disconnect bumps it, a redial loop stands
down when the user acts, and a drop reported by an OLDER session's pump
(login_inner shuts the old client down and its reader reports after the new
session is up) no longer stamps the fresh session offline — that race was
there all along. The page shows "connecting" on `reconnecting`, the
resolver waits up to twenty seconds for a dial in progress, and debug
builds expose `debug_drop_connection`. Verified by the debug command (drop →
redial 1 in 5 s → online at 7 s) and by a real drop when the head unit
connected: redial 1 failed on DNS, redial 2 at +15 s logged in.
The notification storm was media3 restarting the service into
onStartCommand. The morning's logcat had the card re-posted six times a
second for over a minute ("Package enqueue rate is 6.6 … Shedding"), then
"Too many PendingIntent created for uid" (media3 mints a fresh one for the
custom button on every rebuild), then twenty refused foreground starts in
two hundred milliseconds. media3 starts this service ITSELF on every
foreground notification update — MediaNotificationManager.startForeground
calls startForegroundService(startSelfIntent), a bare component intent with
nothing but a uid extra — and onStartCommand promoted on every intent it
saw, which re-posted the card, which made media3 start the service again.
It ran for as long as the service was foreground: while a track played,
and for the ten-minute tail after a pause (the capture stops at pause +
10:00 exactly). onStartCommand now promotes only for an intent that carries
an action or a data uri. A 250 ms coalescer on card rebuilds
(MediaHub.refreshSoon) logs the rate; it stayed silent through the storm,
which is what pointed at the restart. A full minute of playback after the
fix: zero shedding lines, zero PendingIntent complaints, zero refused
starts.
Verified on the Desktop Head Unit and the Pixel: the tree, the Continue
row, cold cached playback, the error card and recovery from it, the warm
hand-off, the media-key resume, the redial; mock walk 255/255, phone walk
142/142.
310 lines
11 KiB
Rust
310 lines
11 KiB
Rust
//! 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 `<data_dir>/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<f64>,
|
|
/// 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<String>,
|
|
pub artist: Option<String>,
|
|
pub title: Option<String>,
|
|
pub username: String,
|
|
pub path: String,
|
|
pub size: u64,
|
|
#[serde(default)]
|
|
pub duration: Option<f64>,
|
|
/// `None` leaves an existing pin as it was; `Some` sets it.
|
|
#[serde(default)]
|
|
pub keep: Option<bool>,
|
|
}
|
|
|
|
/// 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<String> {
|
|
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::<Vec<(String, StreamSource)>>(&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<StreamSource> {
|
|
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<PathBuf> {
|
|
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<PathBuf> {
|
|
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<PathBuf, String> {
|
|
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<RememberArg>) -> 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<String>,
|
|
artist: Option<String>,
|
|
title: Option<String>,
|
|
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::<String>::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));
|
|
}
|
|
}
|