//! 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: browsable //! ss:artist: browsable //! ss:playlist: browsable //! ss:track: playable tracks.path //! ss:plitem: 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, duration_ms: Option, } /// 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/` — /// 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 = 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(), ¬e, 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::>(), }) .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) { 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::().ok(); } } (head, limit) } } } fn root_tabs(limit: Option) -> Vec { /* 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, 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. */ /// `/.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 `/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::(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 { 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 { 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, art: Option, artist_art: Option, 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> { 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, 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, String> { let mut mine: Vec = 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, String> { struct Group { name: String, key: String, albums: i64, tracks: i64, art: Option, cover: Option, } let albums = all_albums(conn).map_err(sql_err)?; let mut groups: Vec = Vec::new(); let mut at: HashMap = 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, 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, String> { let cover: Option = 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>(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, String> { let mut seeds: HashMap = 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>(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, 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>(3)?, r.get::<_, Option>(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 { 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 = 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(), } }