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.
812 lines
32 KiB
Plaintext
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(), ¬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::<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(),
|
|
}
|
|
}
|