Files
SoulStream/handoff/rounds/ins-r50-auto-rs.txt
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43
to 48 (each patch adversarially verified, 43's transient-notice expectation
fixed), a partial round 49, the pipeline scripts, the Android Auto design and
the icon comparison mock. Also the two harness changes that were never A/B'd —
adaptive waits, which may only shorten a declared wait and never lengthen it,
and a ship that runs the mock walk and the deploy at the same time. The A/B and
its 255-step baseline are in handoff/tools; main stays at what the phone has
actually walked.
2026-09-14 07:57:41 -05:00

812 lines
32 KiB
Plaintext

//! The library, read through a keyhole, for a browser with no app behind it.
//!
//! Android Auto browses through a `MediaLibraryService`, and the system
//! starts that service in a process with no Activity, no Tauri and no
//! WebView — the whole frontend, every `#[tauri::command]` in `commands.rs`
//! and the entire resolver are simply not there. What IS there is this
//! crate's own `.so`: `JNI_OnLoad` (see `android_glue.rs`) runs the moment
//! `System.loadLibrary("app_lib")` loads us and publishes the JavaVM and the
//! app Context before any component exists, and `crates/library` is plain
//! rusqlite with no Tauri anywhere in it. So the tree the car reads is
//! answered from here, straight off the database, and browse works cold.
//!
//! Four rules hold this module together, and each of them is a bug that has
//! already been paid for somewhere in this project:
//!
//! * **The connection is READ-ONLY.** Never `library::Library::open` — it
//! runs `PRAGMA journal_mode = WAL` and eleven `CREATE TABLE IF NOT
//! EXISTS` before it returns, which are writes, into a database whose real
//! owner may be running in another process at the same moment.
//! `SQLITE_OPEN_READ_ONLY`, a short busy timeout, and this module's own
//! SQL — copied from `crates/library`, never invented.
//!
//! * **Nothing ever fails loudly.** No config, no database, a storage
//! permission revoked since the last launch, a corrupt page: every one of
//! them answers an EMPTY BUT VALID list with the reason in `note`. A head
//! unit that spins forever is worse than one that says the library is
//! empty, and a driver cannot dismiss a dialog. `ss:root` in particular is
//! answered without opening anything at all, so the four tabs are there
//! even on a phone that has never had a library — that is what makes
//! "empty but valid" structural rather than a fallback in Kotlin.
//!
//! * **It agrees with the phone.** Artists are not a Rust concept. They are
//! derived in `src/mobile/lib.svelte.js` by grouping `list_albums()` in
//! its own order, so they are grouped exactly that way here — first-seen
//! casing, insertion-ordered off the same query. A SQL `GROUP BY artist`
//! would pick a different display name and the car would disagree with the
//! phone in Kyle's hand. Same for the recents list, which is the library
//! arm of `recent_albums` at the limit the phone asks for.
//!
//! * **Art crosses as a PATH, and Kotlin turns it into a `content://`.**
//! Minting a URI needs a Context and a FileProvider; deciding WHICH file
//! is art needs the thumbnail cache key, which lives in `commands.rs`. So
//! the decision is here and the minting is there. A `http(s)` cover (a
//! catalog row) is dropped rather than handed over: Auto refuses every
//! scheme but `content://` and `android.resource://`, so a remote URL is
//! not a cover, it is a grey square with extra steps.
//!
//! ## The node grammar (rounds 51 and 52 read this, not this code)
//!
//! ```text
//! ss:root[?limit=N] browsable the four tabs
//! ss:tab:recent|albums|artists|playlists browsable
//! ss:album:<albums.id> browsable
//! ss:artist:<pct-encoded lowercase name> browsable
//! ss:playlist:<playlists.id> browsable
//! ss:track:<tracks.id> playable tracks.path
//! ss:plitem:<playlist_items.id> playable playlist_items.path, WHICH MAY BE NULL
//! ```
//!
//! `ss:plitem:` rows of kind `stream` or `unresolved` have no path at all
//! and are listed and marked playable ON PURPOSE — hiding them would make
//! the car disagree with the phone, and the design owes them `STATE_ERROR`
//! plus "open SoulStream on your phone" rather than silence. A caller that
//! assumes a path is always there will NPE on a real playlist of Kyle's.
//!
//! `ss:artist:` is a percent-encoded `to_lowercase()` of the tag artist: it
//! is stable while the tags are, and not across a retag.
use rusqlite::{Connection, OpenFlags};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// The root node id. Kotlin appends `?limit=N` to it from the browser's own
/// root hints; nothing else in the grammar carries a query.
pub const ROOT: &str = "ss:root";
/// The most rows one node ever answers with.
///
/// Auto does not paginate: `MediaLibraryServiceLegacyStub.onLoadChildren`
/// calls `onGetChildrenOnHandler(..., page = 0, pageSize = Integer.MAX_VALUE,
/// ...)` when the browser sends no pagination option, which a
/// `MediaBrowserCompat.subscribe()` never does. So a whole tab crosses ONE
/// Binder transaction, under a ~1 MB ceiling, and the cap is what keeps a
/// large library from failing that transaction instead of showing 500 rows.
/// Kyle's library is ~114 covers and never reaches it; a five-thousand-album
/// one would, and would be told so through `capped`.
const CAP: usize = 500;
/// The recents list, exactly as the phone asks for it: `recentAlbums(14)` in
/// `src/mobile/lib.svelte.js`, over the command's own 45-second play floor.
/// Not the command's default 20 — the number the PHONE shows is the number
/// the car should show.
const RECENT_LIMIT: i64 = 14;
const RECENT_MIN_SECS: i64 = 45;
/// The thumbnail edge the phone prewarms (`Mobile.svelte` →
/// `library_art_prewarm([360])`), so a browsed cover is a disk read of a
/// 360 px JPEG rather than a 3000 px master decoded inside Auto's process.
const ART_EDGE: u32 = 360;
/// The four tabs, in the order the root serves them. A root-children limit
/// below four truncates from the END, so Recent is the one tab a two-slot
/// head unit is guaranteed to keep.
const TABS: [(&str, &str, &str); 4] = [
("ss:tab:recent", "Recent", "What you played last"),
("ss:tab:albums", "Albums", "Everything you have saved"),
("ss:tab:artists", "Artists", "Grouped the way the phone groups them"),
("ss:tab:playlists", "Playlists", "Your own lists"),
];
/// One row on its way to a `MediaItem`. `art` is an absolute FILE path —
/// Kotlin mints the `content://` — and `duration_ms` is milliseconds because
/// that is what `MediaMetadata.setDurationMs` wants.
struct Item {
id: String,
kind: &'static str,
title: String,
subtitle: String,
browsable: bool,
playable: bool,
art: Option<String>,
duration_ms: Option<i64>,
}
/// The whole answer, as one JSON string. The only entry point; the JNI shim
/// below is a thin wrapper and every other platform reaches it not at all.
///
/// `data_dir` is Android's `Context.getDataDir()` — `/data/user/0/<pkg>` —
/// which is what `paths::app_data` resolves to on the phone (tauri's
/// `PathPlugin.getDataDir` answers `activity.dataDir`). It is NOT
/// `getFilesDir()`: nothing this module reads lives under `files/`.
/// `cache_dir` is `Context.getCacheDir()`, which is where
/// `library_art_prewarm` put the thumbnails.
pub fn browse_json(node: &str, data_dir: &str, cache_dir: &str) -> String {
let started = Instant::now();
let (path, limit) = split_query(node);
let mut note = String::new();
let mut items: Vec<Item> = if path == ROOT {
/* Answered with the database untouched, deliberately: see the module
note. Four tabs on a phone with no config, no library and no
storage permission is the difference between "your library is
empty" and a car that never finishes loading. */
root_tabs(limit)
} else {
match children(path, data_dir, cache_dir) {
Ok(v) => v,
Err(e) => {
note = e;
Vec::new()
}
}
};
let capped = items.len() > CAP;
if capped {
items.truncate(CAP);
}
envelope(node, note.is_empty(), &note, capped, started.elapsed(), &items)
}
/// The empty-but-valid answer, for a caller that has nothing else to say —
/// the JNI shim's panic guard uses it.
pub fn empty_json(node: &str, note: &str) -> String {
envelope(node, false, note, false, Duration::from_millis(0), &[])
}
fn envelope(node: &str, ok: bool, note: &str, capped: bool, took: Duration, items: &[Item]) -> String {
serde_json::json!({
"node": node,
"ok": ok,
"note": note,
"capped": capped,
"ms": took.as_millis() as u64,
"items": items.iter().map(|i| serde_json::json!({
"id": i.id,
"kind": i.kind,
"title": i.title,
"subtitle": i.subtitle,
"browsable": i.browsable,
"playable": i.playable,
"art": i.art,
"duration": i.duration_ms,
})).collect::<Vec<_>>(),
})
.to_string()
}
/// `ss:root?limit=3` → (`ss:root`, Some(3)). Nothing else in the grammar
/// carries a query, and an unparseable one reads as absent.
fn split_query(node: &str) -> (&str, Option<i64>) {
match node.split_once('?') {
None => (node, None),
Some((head, query)) => {
let mut limit = None;
for part in query.split('&') {
if let Some(v) = part.strip_prefix("limit=") {
limit = v.parse::<i64>().ok();
}
}
(head, limit)
}
}
}
fn root_tabs(limit: Option<i64>) -> Vec<Item> {
/* A limit of zero, a negative one or none at all is the browser saying
nothing, which Google's own docs put at four — and four is what we
have. Anything larger is the browser being generous. */
let keep = match limit {
Some(n) if n >= 1 => (n as usize).min(TABS.len()),
_ => TABS.len(),
};
TABS.iter()
.take(keep)
.map(|(id, title, subtitle)| Item {
id: (*id).to_string(),
kind: "tab",
title: (*title).to_string(),
subtitle: (*subtitle).to_string(),
browsable: true,
playable: false,
art: None,
duration_ms: None,
})
.collect()
}
fn children(node: &str, data_dir: &str, cache_dir: &str) -> Result<Vec<Item>, String> {
let rest = node
.strip_prefix("ss:")
.ok_or_else(|| format!("not one of ours: {node}"))?;
let (kind, arg) = rest.split_once(':').unwrap_or((rest, ""));
/* A leaf has no children and needs no database to say so. Round 51 turns
these two into a file path; here they are simply the bottom. */
if kind == "track" || kind == "plitem" {
return Ok(Vec::new());
}
let db = library_db(data_dir);
if !db.is_file() {
/* The commonest honest answer there is: a fresh install, or a
library folder in shared storage with the all-files grant gone.
Said out loud so the empty tree explains itself on the first real
run rather than being mute. */
return Err(format!("no library database at {}", db.display()));
}
let conn = open_read_only(&db).map_err(|e| format!("sqlite open: {e} ({})", db.display()))?;
let thumbs = Path::new(cache_dir).join("art-thumbs-v1");
match (kind, arg) {
("tab", "recent") => recent(&conn, &thumbs),
("tab", "albums") => albums_tab(&conn, &thumbs),
("tab", "artists") => artists_tab(&conn, &thumbs),
("tab", "playlists") => playlists_tab(&conn, &thumbs),
("album", id) => {
let id: i64 = id.parse().map_err(|_| format!("not an album id: {id}"))?;
album_tracks(&conn, &thumbs, id)
}
("artist", key) => artist_albums(&conn, &thumbs, &pct_decode(key)),
("playlist", id) => {
let id: i64 = id.parse().map_err(|_| format!("not a playlist id: {id}"))?;
playlist_rows(&conn, &thumbs, id)
}
_ => Err(format!("unknown node {node}")),
}
}
/* ── where the library is ──────────────────────────────────────────────
*
* Mirrors `config.rs` exactly, and writes nothing on the way: the config is
* READ, never parsed-and-saved, because a service process has no business
* rewriting the file the app owns.
*/
/// `<download_dir>/.soulstream/library.db` — `config::library_data_dir` plus
/// `pipeline::init`'s own filename, with `LIBRARY_DATA_DIRNAME` taken from
/// the one place that spells it.
fn library_db(data_dir: &str) -> PathBuf {
download_dir(data_dir)
.join(crate::config::LIBRARY_DATA_DIRNAME)
.join("library.db")
}
/// `cfg.download_dir` when it is set and non-empty, else `<data_dir>/Library`
/// — the `#[cfg(target_os = "android")]` arm of `config::download_dir`, which
/// returns before the desktop's `audio_dir()` fallback is ever reached.
///
/// The BOM strip is not decoration. serde refuses a leading U+FEFF outright,
/// so a config written by PowerShell 5.1's `Out-File utf8` would parse as
/// nothing at all and the car would quietly browse the wrong (private)
/// library while the phone browsed the right one — the 2026 `config.json`
/// BOM trap, in a process with no log anyone reads.
fn download_dir(data_dir: &str) -> PathBuf {
let cfg = Path::new(data_dir).join("config.json");
if let Ok(text) = std::fs::read_to_string(&cfg) {
let text = text.strip_prefix('\u{feff}').unwrap_or(text.as_str());
if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
if let Some(d) = v.get("download_dir").and_then(|d| d.as_str()) {
if !d.is_empty() {
return PathBuf::from(d);
}
}
}
}
Path::new(data_dir).join("Library")
}
/// Read-only, URI-capable, unshared, and with a SHORT busy timeout.
///
/// The timeout is the interesting one: the app's own process may be
/// mid-checkpoint when the car asks, and the head unit must not be made to
/// wait on it. A second and a half, then the node comes back empty with the
/// reason in `note`, which is exactly the contract the rest of this module
/// keeps.
///
/// On a WAL database a read-only CONNECTION still creates the `-shm` when it
/// can: SQLite's shared-memory open is governed by filesystem permission on
/// the directory, not by the connection's flags, and it is our own UID.
/// Measured on the Win32 VFS (3.49.1) against a database with an
/// uncheckpointed WAL and no `-shm`: the file was created and the WAL's
/// content read. If the unix VFS ever disagrees it fails as
/// SQLITE_CANTOPEN / SQLITE_READONLY_CANTINIT, which lands in `note` rather
/// than anywhere a driver can see.
fn open_read_only(db: &Path) -> rusqlite::Result<Connection> {
let conn = Connection::open_with_flags(
db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
conn.busy_timeout(Duration::from_millis(1500))?;
Ok(conn)
}
fn sql_err(e: rusqlite::Error) -> String {
format!("sqlite: {e}")
}
/* ── art ───────────────────────────────────────────────────────────── */
/// The best FILE on disk for one stored art path: the warmed 360 px
/// thumbnail when it is there, the master otherwise, and nothing when
/// neither is.
///
/// `art_thumb_target` is borrowed from `commands.rs` rather than
/// reimplemented, and the comment over it says why: the key is
/// (path, mtime, len, edge), `album_art` reads through it and
/// `library_art_prewarm` writes through it, and a second implementation
/// drifting would make every warmed thumbnail invisible to this module.
fn art_for(thumbs: &Path, src: Option<&str>) -> Option<String> {
let s = src.map(str::trim).filter(|s| !s.is_empty())?;
/* a catalog row's cover. Auto supports content:// and
android.resource:// and nothing else, so this is not a cover. */
if s.starts_with("http://") || s.starts_with("https://") {
return None;
}
if let Some(t) = crate::commands::art_thumb_target(thumbs, s, ART_EDGE) {
if t.is_file() {
return Some(t.to_string_lossy().into_owned());
}
}
if Path::new(s).is_file() {
return Some(s.to_string());
}
None
}
/* ── the albums, once ──────────────────────────────────────────────── */
struct AlbumRow {
id: i64,
artist: String,
title: String,
year: Option<i64>,
art: Option<String>,
artist_art: Option<String>,
added_at: i64,
tracks: i64,
}
/// `Library::list_albums`'s statement, verbatim — including its
/// `ORDER BY a.added_at DESC, a.artist, a.title`, which IS `newest()` in
/// `lib.svelte.js` and is also the order the artist grouping depends on.
fn all_albums(conn: &Connection) -> rusqlite::Result<Vec<AlbumRow>> {
let mut stmt = conn.prepare(
"SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id)
FROM albums a
ORDER BY a.added_at DESC, a.artist, a.title",
)?;
let rows = stmt.query_map([], |r| {
Ok(AlbumRow {
id: r.get(0)?,
artist: r.get(1)?,
title: r.get(2)?,
year: r.get(3)?,
art: r.get(4)?,
artist_art: r.get(5)?,
added_at: r.get(6)?,
tracks: r.get(7)?,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
fn album_item(thumbs: &Path, a: &AlbumRow) -> Item {
let subtitle = match a.year {
Some(y) if y > 0 => format!("{} \u{b7} {y}", a.artist),
_ => a.artist.clone(),
};
Item {
id: format!("ss:album:{}", a.id),
kind: "album",
title: a.title.clone(),
subtitle,
browsable: true,
playable: false,
art: art_for(thumbs, a.art.as_deref()),
duration_ms: None,
}
}
fn albums_tab(conn: &Connection, thumbs: &Path) -> Result<Vec<Item>, String> {
let albums = all_albums(conn).map_err(sql_err)?;
Ok(albums.iter().map(|a| album_item(thumbs, a)).collect())
}
/// `albumsBy()` in `lib.svelte.js`: the same album list, this artist only,
/// newest release year first and newest added within a year.
fn artist_albums(conn: &Connection, thumbs: &Path, key: &str) -> Result<Vec<Item>, String> {
let mut mine: Vec<AlbumRow> = all_albums(conn)
.map_err(sql_err)?
.into_iter()
.filter(|a| a.artist.to_lowercase() == key)
.collect();
mine.sort_by(|x, y| {
y.year
.unwrap_or(0)
.cmp(&x.year.unwrap_or(0))
.then(y.added_at.cmp(&x.added_at))
});
Ok(mine.iter().map(|a| album_item(thumbs, a)).collect())
}
/// `artists()` in `lib.svelte.js`, grouped in Rust because there is no
/// artists table and never has been.
///
/// Every detail here is a copy rather than an improvement: the key is
/// `to_lowercase()`, the DISPLAY name is the first casing seen in
/// `list_albums` order (not `MIN(artist)`, which a `GROUP BY` would pick and
/// which would differ), the portrait is the first non-null
/// `artist_art_path` with the first non-null `art_path` behind it, and the
/// sort is albums desc → tracks desc → name. The one knowing departure is
/// the tiebreak: JS sorts the last step with `localeCompare` and this sorts
/// by bytes, which only reorders two artists who already have identical
/// album and track counts.
fn artists_tab(conn: &Connection, thumbs: &Path) -> Result<Vec<Item>, String> {
struct Group {
name: String,
key: String,
albums: i64,
tracks: i64,
art: Option<String>,
cover: Option<String>,
}
let albums = all_albums(conn).map_err(sql_err)?;
let mut groups: Vec<Group> = Vec::new();
let mut at: HashMap<String, usize> = HashMap::new();
for a in &albums {
let key = a.artist.to_lowercase();
let i = match at.get(&key) {
Some(i) => *i,
None => {
groups.push(Group {
name: a.artist.clone(),
key: key.clone(),
albums: 0,
tracks: 0,
art: None,
cover: None,
});
at.insert(key, groups.len() - 1);
groups.len() - 1
}
};
let g = &mut groups[i];
g.albums += 1;
g.tracks += a.tracks;
if g.art.is_none() {
g.art = a.artist_art.clone();
}
if g.cover.is_none() {
g.cover = a.art.clone();
}
}
groups.sort_by(|x, y| {
y.albums
.cmp(&x.albums)
.then(y.tracks.cmp(&x.tracks))
.then(x.name.cmp(&y.name))
});
Ok(groups
.iter()
.map(|g| Item {
id: format!("ss:artist:{}", pct_encode(&g.key)),
kind: "artist",
title: g.name.clone(),
subtitle: format!("{} \u{b7} {}", plural(g.albums, "album"), plural(g.tracks, "track")),
browsable: true,
playable: false,
art: art_for(thumbs, g.art.as_deref()).or_else(|| art_for(thumbs, g.cover.as_deref())),
duration_ms: None,
})
.collect())
}
fn plural(n: i64, word: &str) -> String {
if n == 1 {
format!("{n} {word}")
} else {
format!("{n} {word}s")
}
}
/* ── recent ────────────────────────────────────────────────────────── */
/// The LIBRARY arm of `Library::recent_albums`, `link` CTE and all.
///
/// The catalog arm is dropped at the source rather than filtered after,
/// for two reasons that agree: `lib.svelte.js` filters it out of the
/// phone's rail anyway (`r.kind === "library" && r.album_id != null`), and a
/// catalog row's cover is an `http` URL, which is not a cover Auto can show.
/// The `link` CTE stays because it is what keeps ONE record one row when it
/// was played both ways — without it, streaming a saved album pushes a
/// duplicate to the top.
fn recent(conn: &Connection, thumbs: &Path) -> Result<Vec<Item>, String> {
let mut stmt = conn
.prepare(
"WITH link AS (
SELECT DISTINCT p.album_id, p.catalog_album_id
FROM plays p
JOIN albums a ON a.id = p.album_id
WHERE p.catalog_album_id IS NOT NULL
)
SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id),
MAX(p.started_at) AS last_at
FROM plays p
JOIN albums a
ON a.id = COALESCE(
p.album_id,
(SELECT l.album_id FROM link l
WHERE l.catalog_album_id = p.catalog_album_id
LIMIT 1))
WHERE p.seconds >= ?2
GROUP BY a.id
ORDER BY last_at DESC
LIMIT ?1",
)
.map_err(sql_err)?;
let rows = stmt
.query_map(rusqlite::params![RECENT_LIMIT, RECENT_MIN_SECS], |r| {
Ok(AlbumRow {
id: r.get(0)?,
artist: r.get(1)?,
title: r.get(2)?,
year: r.get(3)?,
art: r.get(4)?,
artist_art: r.get(5)?,
added_at: r.get(6)?,
tracks: r.get(7)?,
})
})
.map_err(sql_err)?;
Ok(rows
.filter_map(|r| r.ok())
.map(|a| album_item(thumbs, &a))
.collect())
}
/* ── tracks ────────────────────────────────────────────────────────── */
/// `Library::album_tracks`'s statement and its ORDER BY, verbatim — the one
/// that keeps eleven volumes of Analord from interleaving eleven track-1s.
/// The cover is the ALBUM's, read once, so a whole record's worth of rows
/// costs one extra query and not one per track.
fn album_tracks(conn: &Connection, thumbs: &Path, album_id: i64) -> Result<Vec<Item>, String> {
let cover: Option<String> = conn
.query_row(
"SELECT art_path FROM albums WHERE id = ?1",
[album_id],
|r| r.get(0),
)
.map_err(sql_err)?;
let art = art_for(thumbs, cover.as_deref());
let mut stmt = conn
.prepare(
"SELECT id, title, artist, duration_secs
FROM tracks WHERE album_id = ?1
ORDER BY disc_no IS NOT NULL, disc_no, track_no IS NULL, track_no, title",
)
.map_err(sql_err)?;
let rows = stmt
.query_map([album_id], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, Option<i64>>(3)?,
))
})
.map_err(sql_err)?;
Ok(rows
.filter_map(|r| r.ok())
.map(|(id, title, artist, secs)| Item {
id: format!("ss:track:{id}"),
kind: "track",
title,
subtitle: artist,
browsable: false,
playable: true,
art: art.clone(),
duration_ms: secs.filter(|s| *s > 0).map(|s| s * 1000),
})
.collect())
}
/* ── playlists ─────────────────────────────────────────────────────── */
/// `Library::list_playlists`'s ordering (pinned first, then newest) with its
/// aggregates cut down to the count the car shows, plus the same `art_seeds`
/// pass — one query over all rows, not one per playlist — so a list with no
/// chosen cover still gets the first cover its rows carry.
fn playlists_tab(conn: &Connection, thumbs: &Path) -> Result<Vec<Item>, String> {
let mut seeds: HashMap<i64, String> = HashMap::new();
{
let mut stmt = conn
.prepare(
"SELECT playlist_id, art_path FROM playlist_items
WHERE art_path IS NOT NULL ORDER BY playlist_id, position",
)
.map_err(sql_err)?;
let rows = stmt
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
.map_err(sql_err)?;
for (pid, art) in rows.flatten() {
seeds.entry(pid).or_insert(art);
}
}
let mut stmt = conn
.prepare(
"SELECT p.id, p.name, p.cover_path,
(SELECT COUNT(*) FROM playlist_items i WHERE i.playlist_id = p.id)
FROM playlists p
ORDER BY p.pinned DESC, p.created_at DESC",
)
.map_err(sql_err)?;
let rows = stmt
.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, i64>(3)?,
))
})
.map_err(sql_err)?;
Ok(rows
.filter_map(|r| r.ok())
.map(|(id, name, cover, n)| {
let art = art_for(thumbs, cover.as_deref())
.or_else(|| art_for(thumbs, seeds.get(&id).map(String::as_str)));
Item {
id: format!("ss:playlist:{id}"),
kind: "playlist",
title: name,
subtitle: plural(n, "track"),
browsable: true,
playable: false,
art,
duration_ms: None,
}
})
.collect())
}
/// `Library::playlist_items`'s statement, cut to what a row in a car shows.
///
/// Rows with no `path` — kind `stream` or `unresolved` — are listed and
/// marked PLAYABLE on purpose: the phone shows them, so the car shows them,
/// and the round that plays them owes them an error state with a resolution
/// rather than a gap in the list.
fn playlist_rows(conn: &Connection, thumbs: &Path, playlist_id: i64) -> Result<Vec<Item>, String> {
let mut stmt = conn
.prepare(
"SELECT id, title, artist, duration_secs, art_path
FROM playlist_items WHERE playlist_id = ?1 ORDER BY position",
)
.map_err(sql_err)?;
let rows = stmt
.query_map([playlist_id], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, Option<i64>>(3)?,
r.get::<_, Option<String>>(4)?,
))
})
.map_err(sql_err)?;
Ok(rows
.filter_map(|r| r.ok())
.map(|(id, title, artist, secs, art)| Item {
id: format!("ss:plitem:{id}"),
kind: "plitem",
title,
subtitle: artist,
browsable: false,
playable: true,
art: art_for(thumbs, art.as_deref()),
duration_ms: secs.filter(|s| *s > 0).map(|s| s * 1000),
})
.collect())
}
/* ── percent coding, by hand ───────────────────────────────────────── */
/// RFC 3986 unreserved survives; everything else becomes %XX. A media id
/// may hold anything, but it also travels through logs, a regex in the phone
/// walk and (in round 52) a search query, so the artist key is kept to
/// bytes that cannot be mistaken for the grammar's own punctuation.
fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(*b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// Byte-wise on purpose: slicing a `&str` at `i + 1 .. i + 3` would panic on
/// a char boundary the moment a node id carried a stray `%` in front of
/// multibyte text.
fn pct_decode(s: &str) -> String {
fn hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
out.push(hi * 16 + lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/* ── the JNI door ──────────────────────────────────────────────────── */
/// `com.soulstream.mediasession.LibraryBridge.browse`.
///
/// The symbol is spelled from the package, the class and the method, which
/// is why `consumer-rules.pro` keeps all three by name: R8 renaming any of
/// them is an `UnsatisfiedLinkError` at the first browse in the car, with
/// nothing at all wrong at build time.
///
/// The Kotlin side declares this on an `object`, so the second argument is
/// the singleton and not a class — `JObject` either way, and ignored.
///
/// Nothing crosses this boundary but a string, and nothing unwinds across
/// it: a panic in the query layer would be undefined behaviour through JNI,
/// so the whole call is inside `catch_unwind` and a caught panic answers the
/// same empty-but-valid envelope every other failure does.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_LibraryBridge_browse<'local>(
mut env: jni::JNIEnv<'local>,
_this: jni::objects::JObject<'local>,
node: jni::objects::JString<'local>,
data_dir: jni::objects::JString<'local>,
cache_dir: jni::objects::JString<'local>,
) -> jni::sys::jstring {
fn read<'a>(env: &mut jni::JNIEnv<'a>, s: &jni::objects::JString<'a>) -> String {
env.get_string(s).map(Into::into).unwrap_or_default()
}
let node = read(&mut env, &node);
let data = read(&mut env, &data_dir);
let cache = read(&mut env, &cache_dir);
let json = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
browse_json(&node, &data, &cache)
}))
.unwrap_or_else(|_| empty_json(&node, "panicked"));
match env.new_string(json) {
Ok(s) => s.into_raw(),
/* a null answer is the one thing Kotlin is told to expect from a
bridge that could not speak at all */
Err(_) => std::ptr::null_mut(),
}
}