diff --git a/ANDROID.md b/ANDROID.md index 071b01a..7e0911b 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -1123,19 +1123,112 @@ The car binds `MediaService` with the app COLD — no Activity, no WebView, no it can, say what it cannot, and pick up where the phone left off. Four pieces, all of them landed 2026-09-18: -**What plays cold.** Library tracks always did. Now a playlist's streamed -rows play cold too, when their bytes are already on the phone: the page's -memory of which peer file a catalog track resolved to +**What plays cold: everything, the way the page plays it.** Library tracks +always did. A playlist's streamed rows play cold too — every one of them. +The page's memory of which peer file a catalog track resolved to (`soulstream.streamSources.v1` in localStorage) is mirrored to `/stream-sources.json` (`stream_sources.rs`) — one entry every time a source is remembered, and the whole store once at boot for memories made before the mirror existed — and `auto_browse.rs` looks a streamed 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. Such a row is -"artist · album" in the car; one that is not is "artist · needs the app", -which is the amber-dot row of the design. A playlist's subtitle counts -cached streams as "here". A tap on a row that needs the app is handed to -the app when it is up (`playRemote`), exactly as before. +there and computes its cache file with the same hash `playback::cache_path` +uses. A row whose file is whole plays from it and carries the host's +"downloaded" mark; a row whose bytes are not here is still a row that +plays, and the difference is only that mark. `resolve_playable` queues the +WHOLE playlist in order (`headless::PlayTrack.path` is `None` for a row +not here, `stream` its remembered peer file), and the engine's queue +(`headless.rs`) works like the page's: a row is FOUND when the queue +reaches it — the remembered peer file first, else one search through the +cold Soulseek session (`headless_slsk.rs`: the stored account, the +server's excluded phrases stripped, copies ranked close to +`songCandidates`, the best three tried with a fifteen-second first-byte +watchdog) — the bytes land in the stream cache and play as they arrive, +the next THREE rows are fetched ahead (`PREFETCH_DEPTH`, after the page's +four-second start delay), and when the immediate next is whole before the +current one ends it is appended to the sink for a gapless boundary +(`Queue.armed`, checked again at the boundary). A row nothing on the +network has moves the queue on, the page's rule; only the LAST row's +failure reaches the card. The card shows the title at once with a +BUFFERING spinner (`headlessState`) and the track once bytes play. One +Soulseek session per process: when the app opens over a car session, +`login_inner` ADOPTS the client (`headless_slsk::adopt` + `subscribe`) +rather than signing in again — two logins from one address in quick +succession is what the server throttles — and the fetches in flight become +the page's own preloads (`PlayerState.preloads`), so the row the car was +fetching ahead lands gaplessly under the page too. A playlist's subtitle +counts cached streams as "here". A tap while the app is UP is still handed +to the page (`AutoNative.describe` → `playMediaId`), which owns the queue +then. + +**Covers, always.** The car draws the app icon over a row with no cover, +and thirty of GhettoTechie's forty rows had none — pasted-tracklist rows +the catalog never matched. Two hands fill them. In the cold process, +`auto_browse::embedded_cover` extracts the picture from the file's own tag +(the desktop's `tagger::extract_art`), keyed by the AUDIO path under +`/.soulstream/stream-art/by-file/` with a miss remembered as an +empty file, for browse rows, the Continue row and the card; a row fetched +cold gets the same once 200 kB of it have landed (`art_after_bytes`, +re-notifying the card). On the phone, `coverart.svelte.js` is the desktop's +playlist backfill as a background WALK over every playlist: the file before +the catalog (`cachedStreamSourceFor` + `stream_embedded_art`, persisted as +the row's `art_path`), then the ONE strict ladder the import review uses +(`findCatalogTrack`, persisted as `art_url` + `track_id`) — never a loose +name lookup, a blank tile beats another record's cover. It runs 25 s after +boot, again when a playlist changes, every six hours, one row at a time, +and the open playlist page's rows go first; a miss is remembered for a day +in localStorage, only when the catalog answered. The player's +`ensureStreamArt` does the same for the track that is playing (embedded, +then catalog), makes the URL the entry's art key, and hands what it found +to the walk to persist. Because everything lands on the ROW, the car — +which reads rows straight out of the database — has it on the next browse. +And a car that starts the app COLD has no page to walk for it, so the +cold process asks the catalog itself: `cover_hunt.rs` is `catalogMatch.js` +in Rust (the same Deezer field queries through the metadata crate, a match +only when the artist agrees, the base title agrees, the version qualifiers +are compatible and a known duration does not contradict — keep the two in +step), fed by every browse and every queue built from a playlist: a blank +stream row is answered from `stream-art/catalog.json` when it has been +asked before, else queued for one thread that looks rows up one at a +time behind the metadata client's own Deezer pacing, writes the answer on +the row (`art_url`, and the track id; only a NULL is ever filled — the +library is WAL, the page's writer is another connection), and re-lists +the playlist on the car (`AutoNative.headlessCovers` → +`notifyChildrenChanged`). The track that is playing gets the same through +`art_from_catalog` / `art_after_bytes` and `AutoNative.headlessArt`, which +moves only the art, never the position. The cold Soulseek search is the +page's two-rung ladder too: artist + simplified title, then the title +alone with the artist required in the peer's path. The hunt's last rung is +the page's last rung as well — the artist's whole shelf flattened to +tracks (`catalog_artist_track_candidates`), for the records the search +index buries: Deezer's search answers "Nirvana Smells Like Teen Spirit" +with six live takes and never the studio cut, while Nevermind's tracklist +has it. That rung opens up to thirty tracklists behind the Deezer pacing, +so it is spent only on the track that is PLAYING (`lookup(…, deep)`), +bounded to forty seconds (a miss cut short by the clock is asked again +within the hour, not the day), and never on a browse row — the search +rungs answer those in half a second, and the phone's own walk, which does +run the shelf rung, writes what it finds on the row for the car to read. +The cover memory carries the matcher's version (`HUNT_VERSION`): a "no" +remembered by an older matcher is no answer once a rung is added. + +**A trap that cost the first cold fetch.** The Soulseek client spawns a +download's transfer task with `tokio::spawn`, which needs a runtime on the +CALLING thread. The engine's resolver thread is not a Tauri command; the +spawn panicked, the thread died without a word (a cold process has no +stderr in logcat), and the card sat on BUFFERING for as long as anyone +looked. Every client call from an engine thread now runs under +`headless_slsk::rt().enter()`. + +**The artist page's MusicBrainz pass.** The phone built its artist pages +from Deezer's list alone (`catalog_artist`), so the releases Deezer's +artist listing omits — EPs, and whole albums (My Bloody Valentine had four +releases) — never appeared, and neither did the authoritative release +types or the guest-appearance / co-tenant arbitration. The desktop's +identity merge now lives in `src/lib/artistIdentity.js` (moved out of +`state.svelte.js` verbatim; the desktop imports it back) and +`catalog.svelte.js` runs it after the page is on screen +(`applyIdentity`: `catalog_artist_identity`, one retry after eight seconds +on an empty answer, then `mergeArtistIdentity` into the page the screens +already read). One implementation for both surfaces, on purpose. **Keep offline.** A playlist's More sheet has "Keep offline" (`src/mobile/offline.svelte.js`, the list in `config.json` @@ -1175,9 +1268,9 @@ it over once, and `restoreSession` builds the bar from it — instead of restoring the page's own older session over live audio, which put the wrong title on the bar. -**What 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, a row -whose file is gone — used to be silence. `MediaHub.fail` now puts the reason +**What the car is told.** A tap that cannot play — a streamed row no peer +on the network has, a hand-off the resolver refused, a row whose file is +gone — used to be silence. `MediaHub.fail` now puts the reason in the playback state (`STATE_ERROR` with the message, which Auto draws as a card) and sends a `SessionError` carrying "Open SoulStream" as the resolution action; the next track that plays withdraws it. The page reports diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt index 3547796..14d71e3 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt @@ -862,6 +862,31 @@ object LibraryTree : MediaLibrarySession.Callback { * `page` and `pageSize` are ignored deliberately: Auto does not paginate * a browse, it asks for a node and draws what comes back. */ + /** + * A browser asking to be told when a parent's rows change. media3's + * DEFAULT answers "not supported", which means nobody is ever + * subscribed and `notifyChildrenChanged` reaches nobody — the cover + * hunt (cover_hunt.rs) found covers for a whole playlist and the car + * kept showing the blank list until it was left and re-entered + * (2026-09-18). Accepting is all it takes: the host re-lists the parent + * on every notice from then on. + */ + override fun onSubscribe( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + params: LibraryParams?, + ): ListenableFuture> { + android.util.Log.i("SoulStream", "auto subscribe $parentId by " + browser.packageName) + return Futures.immediateFuture(LibraryResult.ofVoid()) + } + + override fun onUnsubscribe( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + ): ListenableFuture> = Futures.immediateFuture(LibraryResult.ofVoid()) + override fun onGetChildren( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, @@ -1566,6 +1591,90 @@ object AutoNative { } } + /** + * The fallback queue ran out — called FROM Rust, on the audio pump, like + * [headlessTrack]. The card goes to "paused, at the end": media3 was + * extrapolating a PLAYING position over silence for as long as anyone + * looked, because nothing had told it the engine stopped. + */ + /** + * A stream's arrival, called FROM Rust on the audio pump: "buffering" + * while the first bytes are on their way (the card shows a spinner), + * "playing" once they sound, "failed" with the reason when no copy ever + * delivered — which becomes the error card, not silence. + */ + @JvmStatic + fun headlessState(json: String) { + try { + val ctx = MediaHub.appContext ?: return + val o = org.json.JSONObject(json) + when (o.optString("state")) { + "buffering" -> MediaHub.state?.let { it.buffering = true; MediaHub.update(ctx, it) } + "playing" -> MediaHub.state?.let { it.buffering = false; it.playing = true; MediaHub.update(ctx, it) } + "failed" -> { + MediaHub.state?.buffering = false + val why = if (o.isNull("message")) null else o.optString("message").ifEmpty { null } + MediaHub.fail(ctx, why ?: "SoulStream could not play that.") + } + } + } catch (e: Throwable) { + android.util.Log.w("SoulStream", "auto state card failed: " + e) + } + } + + @JvmStatic + fun headlessEnded(json: String) { + try { + val ctx = MediaHub.appContext ?: return + val np = MediaHub.state ?: return + np.playing = false + np.duration?.let { np.position = it } + np.hasNext = false + MediaHub.update(ctx, np) + android.util.Log.i("SoulStream", "auto queue ended") + } catch (e: Throwable) { + android.util.Log.w("SoulStream", "auto ended card failed: " + e) + } + } + + /** + * The cover under the track that is showing changed — a picture pulled + * out of the file once enough of it landed, or the catalog's answer + * (cover_hunt.rs). Only the art moves; the position stays. + */ + @JvmStatic + fun headlessArt(json: String) { + try { + val ctx = MediaHub.appContext ?: return + val np = MediaHub.state ?: return + val o = org.json.JSONObject(json) + val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null } ?: return + np.artKey = art + np.art = artB64(art) + MediaHub.update(ctx, np) + android.util.Log.i("SoulStream", "auto cover landed for " + np.title) + } catch (e: Throwable) { + android.util.Log.w("SoulStream", "auto art card failed: " + e) + } + } + + /** + * Rows of a browse parent have covers they did not have when the car + * last listed it (cover_hunt.rs): tell the host to list it again. + * Int.MAX_VALUE is media3's own "count unknown". + */ + @JvmStatic + fun headlessCovers(json: String) { + try { + val parent = org.json.JSONObject(json).optString("parent").ifEmpty { return } + val session = MediaHub.sessionOrNull() ?: return + session.notifyChildrenChanged(parent, Int.MAX_VALUE, null) + android.util.Log.i("SoulStream", "auto covers landed, re-listing " + parent) + } catch (e: Throwable) { + android.util.Log.w("SoulStream", "auto re-list failed: " + e) + } + } + @JvmStatic fun headlessTrack(json: String) { try { diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt index 8e124e6..2b42882 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt @@ -28,6 +28,10 @@ class NowPlayingArgs { var art: String? = null var hasNext: Boolean = true var hasPrev: Boolean = true + /** the bytes are still on their way (a cold fetch from the network); + * the car shows a spinner rather than a position. Never sent by the + * page — its own player folds buffering into `playing`. */ + var buffering: Boolean = false /** the queue around the current item — it and up to ten either side. * Since round forty-eight SoulStreamPlayer.getState() turns this into * the media3 playlist the card is built from, and the index into it. diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt index aea7a00..c22b521 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt @@ -123,19 +123,24 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa per rebuild and is always true. */ var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L var sounding = np.playing + var buffering = false if (headless()) { posMs = AutoNative.positionMs() sounding = AutoNative.sounding() + /* a cold fetch whose first bytes are still on their way: the car + draws a spinner, and media3 does not extrapolate a position + that is not moving */ + buffering = np.buffering && !sounding } return SimpleBasePlayer.State.Builder() .setAvailableCommands(commands(np)) /* STATE_READY even when paused. shouldShowNotification treats STATE_IDLE as "nothing to show" unless it has been prepared and stopped, so a paused player reporting IDLE loses its card. - There is no buffering state to report either: media.svelte.js - already folds `buffering` into `playing`. */ - .setPlaybackState(Player.STATE_READY) - .setPlayWhenReady(sounding, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE) + The page's own player folds buffering into `playing`; only the + headless fetch reports BUFFERING, above. */ + .setPlaybackState(if (buffering) Player.STATE_BUFFERING else Player.STATE_READY) + .setPlayWhenReady(sounding || buffering, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE) .setPlaylist(items) .setCurrentMediaItemIndex(at) /* The long overload, not a PositionSupplier: State's own @@ -381,10 +386,15 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa // Nothing on this phone can play it, and the process is // cold: the focus goes back rather than being held for // silence — and the car is TOLD, which it never was - // before. A streamed row with no cache copy needs the - // resolver, which needs the app. + // before. A streamed row is fetched from the network by + // this process now (headless_slsk.rs); `error` is its + // reason when that could not even start. MediaHub.dropFocus(ctx.applicationContext) - if (o.optBoolean("remote", false)) { + val why = if (o.isNull("error")) null else o.optString("error").ifEmpty { null } + if (why != null) { + android.util.Log.i("SoulStream", "auto cold fetch refused: $why") + MediaHub.fail(ctx.applicationContext, why) + } else if (o.optBoolean("remote", false)) { android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id") MediaHub.fail( ctx.applicationContext, @@ -412,6 +422,9 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa own position takes over from the first tick */ np.position = o.optLong("positionMs", 0L) / 1000.0 np.playing = true + /* a cold fetch: the title now, a spinner until the first + bytes play (headlessState turns it off) */ + np.buffering = o.optBoolean("resolving", false) np.hasNext = o.optBoolean("hasNext", queued > 1) np.hasPrev = false // The cover, which is also the colour: Android Auto washes diff --git a/src-tauri/src/auto_browse.rs b/src-tauri/src/auto_browse.rs index 57c1839..12cd8c6 100644 --- a/src-tauri/src/auto_browse.rs +++ b/src-tauri/src/auto_browse.rs @@ -86,7 +86,7 @@ impl Node { /// called here because it takes a `tauri::AppHandle`). The database itself /// always lives under `/.soulstream/`, which is the rule that keeps /// stored paths portable. -fn db_path(data_dir: &Path) -> PathBuf { +pub(crate) fn db_path(data_dir: &Path) -> PathBuf { let configured = std::fs::read_to_string(data_dir.join("config.json")) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) @@ -348,13 +348,75 @@ fn resume_row(data_dir: &Path) -> Option { subtitle: format!("Continue · {source}{at}"), browsable: false, playable: true, - art: t.art.clone(), - path: Some(t.path.to_string_lossy().into_owned()), + art: t + .art + .clone() + .or_else(|| crate::cover_hunt::cached(data_dir, &t.artist, &t.title)), + path: t.here().map(|p| p.to_string_lossy().into_owned()), }) } -/// A playlist row's playable file from this process: its local path when it -/// has one, else the byte-complete cache copy of its remembered peer file. +/// The cover embedded in an audio file, as a file the car can be handed. +/// +/// Thirty of GhettoTechie's forty rows have no catalog cover at all — the +/// import never matched them — and the car drew the app's icon over every +/// one (2026-09-18). Most of those files carry their real cover in the tag: +/// the desktop's `stream_embedded_art` extracts exactly this, keyed by the +/// bytes' hash. Here it is keyed by the AUDIO path, so "already extracted" +/// is one `stat` and a browse of forty rows reads forty tags once, ever. +/// Written beside the desktop's own extractions, under +/// `/.soulstream/stream-art/`, which the FileProvider roots reach. +pub(crate) fn embedded_cover(data_dir: &Path, audio: &Path) -> Option { + embedded_cover_from(data_dir, audio, audio, true) +} + +/// [`embedded_cover`] for a file that is still arriving: keyed by the +/// cache path it will be (`key`), read from the partial it is now +/// (`read`). A miss is remembered only when `remember_miss` — the tag of +/// a partial file may simply not all be here yet. +pub(crate) fn embedded_cover_from( + data_dir: &Path, + key: &Path, + read: &Path, + remember_miss: bool, +) -> Option { + let dir = db_path(data_dir).parent()?.join("stream-art").join("by-file"); + let mut hash: u64 = 0xcbf29ce484222325; + for b in key.to_string_lossy().bytes() { + hash ^= b as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + let out = dir.join(format!("{hash:016x}.img")); + if matches!(std::fs::metadata(&out), Ok(md) if md.len() > 0) { + return Some(out); + } + // A miss is remembered as an EMPTY file, so a tag with no picture is not + // re-read on every browse. (A file that later gains a cover is a case + // this library does not have.) + if std::fs::metadata(&out).is_ok() { + return None; + } + let bytes = match tagger::extract_art(read) { + Ok(Some(b)) if !b.is_empty() => b, + _ => { + if remember_miss { + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::write(&out, b""); + } + return None; + } + }; + std::fs::create_dir_all(&dir).ok()?; + let tmp = out.with_extension("img.tmp"); + std::fs::write(&tmp, &bytes).ok()?; + std::fs::rename(&tmp, &out).ok()?; + Some(out) +} + +/// A playlist row from this process: its local file when it has one; for a +/// streamed row, the byte-complete cache copy of its remembered peer file +/// when that is here, else `None` with the peer file itself so the queue +/// can fetch it again when it gets there (`headless.rs`). fn row_file( data_dir: &Path, kind: &str, @@ -362,23 +424,25 @@ fn row_file( track_id: Option<&str>, artist: &str, title: &str, -) -> Option<(PathBuf, Option)> { +) -> (Option, Option) { if kind == "local" { - return path.map(|p| (PathBuf::from(p), None)); + return (path.map(PathBuf::from), None); } if kind != "stream" { - return None; + return (None, None); } - let src = crate::stream_sources::lookup(data_dir, track_id, Some(artist), Some(title))?; - let file = crate::stream_sources::cached_file(data_dir, &src)?; - Some(( + let Some(src) = crate::stream_sources::lookup(data_dir, track_id, Some(artist), Some(title)) else { + return (None, None); + }; + let file = crate::stream_sources::cached_file(data_dir, &src); + ( file, Some(crate::resume::StreamRef { username: src.username, path: src.path, size: src.size, }), - )) + ) } /// Playlists, newest first, as browsable rows. @@ -446,7 +510,7 @@ fn playlist_counts(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusq for row in rows.flatten() { n += 1; let (kind, path, track_id, artist, title) = row; - if row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title).is_some() { + if row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title).0.is_some() { here += 1; } } @@ -459,7 +523,7 @@ fn playlist_counts(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusq /// so the list matches the phone's. fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result> { let mut stmt = conn.prepare( - "SELECT id, title, artist, album, path, COALESCE(art_path, art_url), kind, track_id + "SELECT id, title, artist, album, path, COALESCE(art_path, art_url), kind, track_id, duration_secs FROM playlist_items WHERE playlist_id = ?1 ORDER BY position", @@ -473,7 +537,8 @@ fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusql let art: Option = r.get(5)?; let kind: String = r.get(6)?; let track_id: Option = r.get(7)?; - let file = row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title); + let duration = r.get::<_, Option>(8)?.map(|s| s as f64); + let (file, _) = row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title); // Every real row is PLAYABLE, whether or not its file is here: a // non-playable, non-browsable item is drawn by the host as a bare // tile in a list of rows (the first head-unit run showed four rows @@ -481,17 +546,46 @@ fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusql // its subtitle, and a tap on it is the hand-off (warm) or the error // card (cold) — never a silent nothing. Kotlin marks the ones whose // file is here with the host's own "downloaded" indicator. + // A row whose bytes are not here yet is still a row that plays: the + // tap finds a copy on the network from this process now + // (headless_slsk.rs). It is not marked "downloaded"; that is the + // whole difference, and it thins out as rows are heard or kept. let unresolved = kind == "unresolved"; let sub = if unresolved { format!("{artist} · unresolved") - } else if file.is_none() { - format!("{artist} · needs the app") } else { match &album { Some(al) if !al.is_empty() => format!("{artist} · {al}"), _ => artist.clone(), } }; + // No catalog cover: the one in the file's own tag, if it has one; + // then what the cover hunt has found; and a row still blank is + // handed to the hunt, which re-lists this playlist on the car + // when it has an answer (cover_hunt.rs). + let art = art + .or_else(|| { + file.as_ref() + .and_then(|p| embedded_cover(data_dir, p)) + .map(|p| p.to_string_lossy().into_owned()) + }) + .or_else(|| { + if kind != "stream" { + return None; + } + let found = crate::cover_hunt::cached(data_dir, &artist, &title); + if found.is_none() { + crate::cover_hunt::request(crate::cover_hunt::Job { + data_dir: data_dir.to_path_buf(), + item_id: id, + artist: artist.clone(), + title: title.clone(), + duration, + parent: format!("ss:playlist:{playlist_id}"), + }); + } + found + }); Ok(Node { id: format!("ss:pitem:{id}"), title, @@ -499,7 +593,7 @@ fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusql browsable: false, playable: !unresolved, art, - path: file.map(|(p, _)| p.to_string_lossy().into_owned()), + path: file.map(|p| p.to_string_lossy().into_owned()), }) })?; rows.collect() @@ -592,7 +686,7 @@ pub(crate) fn resolve_playable( id, PlayTrack { id: Some(id.to_string()), - path: PathBuf::from(r.get::<_, String>(1)?), + path: Some(PathBuf::from(r.get::<_, String>(1)?)), title: r.get(2)?, artist: r.get(3)?, duration: r.get::<_, Option>(4)?.map(|s| s as f64), @@ -611,12 +705,13 @@ pub(crate) fn resolve_playable( let (rows, from) = album_tracks(id.parse::().ok()?).ok()?; return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from)); } - // A playlist plays the entries this process can: its local files and the - // streamed rows whose cache copy is whole, in order, skipping the ones - // that need the resolver. Silently dropping those is the right call - // here: the row that led here already said "4 of 9 here", so the gap was - // declared before the tap rather than discovered after it. - let playlist_here = |playlist_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> { + // A playlist plays EVERY row, in order, exactly as the page would: the + // local files and whole cache copies from their files, and the rows + // whose bytes are not here found on the network when the queue reaches + // them — the remembered peer file first, else a search — with the next + // few fetched ahead (`headless.rs`). Only a row the page itself could + // not play (`unresolved`, no title) is left out. + let playlist_rows = |playlist_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> { let name: String = conn .query_row("SELECT name FROM playlists WHERE id = ?1", [playlist_id], |r| r.get(0)) .unwrap_or_default(); @@ -641,11 +736,41 @@ pub(crate) fn resolve_playable( let mut out = Vec::new(); for row in rows.flatten() { let (id, title, artist, album, duration, path, art, kind, track_id) = row; - let Some((file, stream)) = - row_file(dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title) - else { + if title.trim().is_empty() || !(kind == "local" || kind == "stream") { continue; - }; + } + let (file, stream) = + row_file(dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title); + if kind == "local" && file.is_none() { + continue; + } + // No catalog cover: the file's own, so the playback screen and + // the colour the car washes it with are the record's, not the + // app icon's; else what the cover hunt has found, and a row + // still blank is handed to it. + let art = art + .or_else(|| { + file.as_ref() + .and_then(|f| embedded_cover(dir, f)) + .map(|p| p.to_string_lossy().into_owned()) + }) + .or_else(|| { + if kind != "stream" { + return None; + } + let found = crate::cover_hunt::cached(dir, &artist, &title); + if found.is_none() { + crate::cover_hunt::request(crate::cover_hunt::Job { + data_dir: dir.to_path_buf(), + item_id: id, + artist: artist.clone(), + title: title.clone(), + duration, + parent: format!("ss:playlist:{playlist_id}"), + }); + } + found + }); out.push(( id, PlayTrack { @@ -663,7 +788,7 @@ pub(crate) fn resolve_playable( Ok((out, name)) }; if let Some(id) = media_id.strip_prefix("ss:playlist:") { - let (rows, from) = playlist_here(id.parse::().ok()?).ok()?; + let (rows, from) = playlist_rows(id.parse::().ok()?).ok()?; return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from)); } if let Some(id) = media_id.strip_prefix("ss:pitem:") { @@ -675,10 +800,9 @@ pub(crate) fn resolve_playable( |r| r.get(0), ) .ok()?; - let (rows, from) = playlist_here(playlist_id).ok()?; - // The tapped row itself must be playable here, or the tap means a - // hand-off — starting the playlist from a different row than the one - // under the driver's finger would be a lie. + let (rows, from) = playlist_rows(playlist_id).ok()?; + // The tapped row is where the queue starts — the one under the + // driver's finger, never a neighbour of it. let idx = rows.iter().position(|(i, _)| *i == item_id)?; return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from)); } @@ -836,10 +960,12 @@ extern "C" { #[no_mangle] pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse( mut env: JNIEnv, - _this: JObject, + this: JObject, data_dir: JString, parent: JString, ) -> jstring { + // The class, for the pump's calls back into Kotlin — see headless.rs. + crate::headless::remember_auto_native(&mut env, &this); // Read both strings before anything else borrows `env`; a closure over it // would have to be `mut` and then collide with the hand-back below. let dir: Option = env.get_string(&data_dir).ok().map(Into::into); @@ -872,11 +998,12 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse( #[no_mangle] pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play( mut env: JNIEnv, - _this: JObject, + this: JObject, data_dir: JString, media_id: JString, start_ms: jni::sys::jlong, ) -> jstring { + crate::headless::remember_auto_native(&mut env, &this); let dir: Option = env.get_string(&data_dir).ok().map(Into::into); let id: Option = env.get_string(&media_id).ok().map(Into::into); let out = match (dir, id) { @@ -1052,9 +1179,10 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_shuffle( #[no_mangle] pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport( mut env: JNIEnv, - _this: JObject, + this: JObject, action: JString, ) { + crate::headless::remember_auto_native(&mut env, &this); let Some(a) = env.get_string(&action).ok().map(String::from) else { return; }; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 782b71d..04ff8ae 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -515,27 +515,62 @@ pub(crate) async fn login_inner( cfg.shared_folders = shares; cfg.description = "Shared with SoulStream".into(); - // The previous session's listener releases its port a moment after - // shutdown; retry briefly instead of failing with WSAEADDRINUSE. - let (client, mut events) = { - let mut attempt = 0; - loop { - match Client::connect(cfg.clone()).await { - Ok(pair) => break pair, - Err(e) if attempt < 8 && e.to_string().contains("10048") => { - attempt += 1; - tokio::time::sleep(std::time::Duration::from_millis(250)).await; + // A car may already have signed this process in (headless_slsk.rs) to + // stream a row before the page existed. Take that session over rather + // than logging in again — two logins from one address in quick + // succession is what the server throttles — and read its events from + // here on; the fetch in flight keeps going on the same client. + #[cfg(target_os = "android")] + let adopted = crate::headless_slsk::adopt().map(|(client, user)| { + log::info!("soulseek: adopting the car's session as {user}"); + (client, crate::headless_slsk::subscribe()) + }); + #[cfg(not(target_os = "android"))] + let adopted: Option<(Client, tokio::sync::mpsc::UnboundedReceiver)> = None; + + let (client, mut events, greeting) = match adopted { + Some((client, events)) => { + // The car's queue may be fetching the rows ahead of it. Those + // downloads are the page's preloads from here on: their + // completion reaches the page as `soulseek:preload`, the same + // way its own next-track fetches do, and the page's queue — + // which took the car's over (`restoreSession`'s handover) — + // appends them for the gapless boundary as they land. + #[cfg(target_os = "android")] + if let (Some(audio), Some(ps)) = + (crate::headless::audio_if_up(), app.try_state::()) + { + let mut preloads = ps.preloads.lock().unwrap(); + for (id, cache) in audio.fetches_in_flight() { + preloads.entry(id).or_insert(cache); } - Err(e) => return Err(format!("could not reach server: {e}")), } + (client, events, "adopted".to_string()) + } + None => { + // The previous session's listener releases its port a moment after + // shutdown; retry briefly instead of failing with WSAEADDRINUSE. + let (client, events) = { + let mut attempt = 0; + loop { + match Client::connect(cfg.clone()).await { + Ok(pair) => break pair, + Err(e) if attempt < 8 && e.to_string().contains("10048") => { + attempt += 1; + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + Err(e) => return Err(format!("could not reach server: {e}")), + } + } + }; + let greeting = client + .login(&username, &password) + .await + .map_err(|e| e.to_string())?; + (client, events, greeting) } }; - let greeting = client - .login(&username, &password) - .await - .map_err(|e| e.to_string())?; - if remember { let mut save = stored.clone(); save.username = username.clone(); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 7bff22c..fa48ec4 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -535,6 +535,14 @@ pub fn remember_secrets_dir(app: &tauri::AppHandle) { } } +/// The same, from a process that has no `AppHandle` — the media service a +/// car starts cold, which needs the stored password to sign in to Soulseek +/// for a row the phone does not hold yet (headless_slsk.rs). +#[cfg(target_os = "android")] +pub fn remember_secrets_dir_path(dir: &std::path::Path) { + let _ = SECRETS_DIR.set(dir.to_path_buf()); +} + #[cfg(target_os = "android")] fn secrets_path() -> Option { let dir = SECRETS_DIR.get()?; diff --git a/src-tauri/src/cover_hunt.rs b/src-tauri/src/cover_hunt.rs new file mode 100644 index 0000000..715ca21 --- /dev/null +++ b/src-tauri/src/cover_hunt.rs @@ -0,0 +1,771 @@ +//! Covers for rows the catalog was never asked about — in the process that +//! has no page to ask for them. +//! +//! The car draws the app icon over a row with no cover, and a browse of a +//! pasted-tracklist playlist is mostly such rows. The page fills them in +//! the background when the app is up (`src/mobile/coverart.svelte.js`); a +//! car that starts the app COLD has no page, so this does the same from +//! Rust: the catalog's track search (the metadata crate, the same Deezer +//! field queries the page's ladder asks), and a match only when the artist +//! agrees, the title agrees once its version wording is set aside, the +//! versions are compatible, and a known duration confirms rather than +//! contradicts. `src/lib/catalogMatch.js` is the reference for every rule +//! here and this is its twin for a process with no JavaScript — keep them +//! in step. A loose lookup once put one record's cover on seven unrelated +//! songs, so no match means no art; a blank tile is the honest answer. +//! +//! What is found is written on the ROW (`playlist_items.art_url`, and the +//! track id beside it) — the one place the page, the car and the resume +//! point all read. The library is WAL and this is a second connection +//! from a second process, which SQLite was built for; the page's own +//! backfill writes the same column through `playlist_relink_item`, and +//! both only ever fill a NULL. What was asked is remembered beside the +//! extracted covers (`/.soulstream/stream-art/catalog.json`), a +//! miss for a day, so a cold browse does not re-ask the catalog thirty +//! times about rows it already said no to. +//! +//! The browse answers at once with what is known; the rest is looked up +//! on one thread, one row at a time behind the metadata client's own +//! Deezer pacing, and each row found re-lists its parent on the car +//! (`AutoNative.headlessCovers` → `notifyChildrenChanged`). + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// A row that wants a cover. +#[derive(Clone)] +pub(crate) struct Job { + pub data_dir: PathBuf, + pub item_id: i64, + pub artist: String, + pub title: String, + pub duration: Option, + /// The browse parent to re-list on the car once this row has its cover. + pub parent: String, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize)] +struct Known { + /// `None` is a real answer: the catalog was asked and nothing cleared + /// the bar. + url: Option, + #[serde(default)] + track_id: Option, + /// Unix seconds; a miss expires after [`MISS_TTL`]. + at: u64, + /// The matcher that answered. A miss from an older one is no answer: + /// the shelf rung arrived after a "no" for Nirvana's studio cut had + /// been remembered for the day, and the new rung never got to run + /// (2026-09-18). Bump [`HUNT_VERSION`] whenever a rung is added. + #[serde(default)] + v: u32, +} + +const MISS_TTL: u64 = 24 * 3600; +const HUNT_VERSION: u32 = 2; + +static CACHE: Mutex)>> = Mutex::new(None); +static QUEUE: Mutex> = Mutex::new(VecDeque::new()); +static WORKING: Mutex = Mutex::new(false); +/// Behind an `Arc` because the shelf walk takes `self: &Arc`. +static CLIENT: OnceLock> = OnceLock::new(); +/// When the catalog last returned anything at all. A row every query +/// comes back empty for is a MISS while the catalog is demonstrably +/// answering (a single-word underground title, 2026-09-18: four empty +/// lists), and "unreachable" only when nothing has answered for a while. +static LAST_ANSWER: Mutex> = Mutex::new(None); +const REACHABLE_FOR: Duration = Duration::from_secs(120); + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn cache_file(data_dir: &Path) -> Option { + Some( + crate::auto_browse::db_path(data_dir) + .parent()? + .join("stream-art") + .join("catalog.json"), + ) +} + +/// The remembered answers, read once per process. +fn with_cache(data_dir: &Path, f: impl FnOnce(&mut HashMap) -> R) -> R { + let mut guard = lock(&CACHE); + let Some(file) = cache_file(data_dir) else { + let mut empty = HashMap::new(); + return f(&mut empty); + }; + if guard.as_ref().is_none_or(|(p, _)| *p != file) { + let map: HashMap = std::fs::read_to_string(&file) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + *guard = Some((file, map)); + } + let (_, map) = guard.as_mut().expect("cache just loaded"); + f(map) +} + +fn save(data_dir: &Path) { + let guard = lock(&CACHE); + let Some((file, map)) = guard.as_ref() else { + return; + }; + if cache_file(data_dir).as_ref() != Some(file) { + return; + } + if let Some(parent) = file.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(s) = serde_json::to_string(map) { + let tmp = file.with_extension("json.tmp"); + if std::fs::write(&tmp, s).is_ok() { + let _ = std::fs::rename(&tmp, file); + } + } +} + +pub(crate) fn key_of(artist: &str, title: &str) -> String { + format!("{}|{}", name_norm(artist), name_norm(title)) +} + +/// A cover already found for this track, if any. Never asks anything. +pub(crate) fn cached(data_dir: &Path, artist: &str, title: &str) -> Option { + if artist.trim().is_empty() || title.trim().is_empty() { + return None; + } + let key = key_of(artist, title); + with_cache(data_dir, |map| map.get(&key).and_then(|k| k.url.clone())) +} + +/// Answered already — a cover, or a miss that has not expired. +fn known(data_dir: &Path, artist: &str, title: &str) -> bool { + let key = key_of(artist, title); + let now = now_secs(); + with_cache(data_dir, |map| { + map.get(&key).is_some_and(|k| { + k.url.is_some() || (k.v >= HUNT_VERSION && now.saturating_sub(k.at) < MISS_TTL) + }) + }) +} + +/// Ask for this row's cover in the background. Nothing happens for a row +/// already answered or already waiting. +pub(crate) fn request(job: Job) { + if job.artist.trim().is_empty() || job.title.trim().is_empty() { + return; + } + if known(&job.data_dir, &job.artist, &job.title) { + return; + } + { + let mut q = lock(&QUEUE); + if q.iter().any(|j| j.item_id == job.item_id) { + return; + } + q.push_back(job); + } + let mut working = lock(&WORKING); + if *working { + return; + } + *working = true; + drop(working); + let _ = std::thread::Builder::new() + .name("cover-hunt".into()) + .spawn(work); +} + +fn work() { + // parents with a row newly covered, re-listed on the car at most every + // couple of seconds, and once more when the queue runs dry + let mut pending: HashSet = HashSet::new(); + let mut last_notice = Instant::now() - Duration::from_secs(10); + loop { + let job = lock(&QUEUE).pop_front(); + let Some(job) = job else { + break; + }; + // the same track may sit in several playlists; one answer serves all + let found = lookup(&job.data_dir, &job.artist, &job.title, job.duration, false); + if let Some((url, track_id)) = found { + persist(&job.data_dir, job.item_id, &job.artist, &job.title, &url, track_id); + pending.insert(job.parent.clone()); + } + if !pending.is_empty() && last_notice.elapsed() >= Duration::from_secs(2) { + for p in pending.drain() { + notify(&p); + } + last_notice = Instant::now(); + } + } + for p in pending.drain() { + notify(&p); + } + *lock(&WORKING) = false; + // a request that landed between the last pop and the flag + if !lock(&QUEUE).is_empty() { + let mut working = lock(&WORKING); + if !*working { + *working = true; + drop(working); + let _ = std::thread::Builder::new().name("cover-hunt".into()).spawn(work); + } + } +} + +fn notify(parent: &str) { + let json = serde_json::json!({ "parent": parent }).to_string(); + crate::headless::call_auto_native("headlessCovers", &json); +} + +/// How long the shelf walk may take for the track that is PLAYING. It +/// opens up to thirty tracklists behind the Deezer pacing, and a card +/// that gets its cover four minutes in has not really got it. +const SHELF_PATIENCE: Duration = Duration::from_secs(40); +/// A miss that ended in a timeout is asked again sooner than a real one. +const SHORT_MISS: u64 = 3600; + +/// The catalog's cover for this track, strictly — remembered either way. +/// Blocks for the lookups; call from a thread of your own. `deep` adds the +/// page's last rung, the artist's whole shelf flattened to tracks — spent +/// on the track someone is listening to, not on every row of a browse: +/// the search rungs answer most rows in half a second, the shelf walk is +/// the expensive exception, and the phone's own walk (which does run it) +/// writes what it finds on the row for the car to read. +pub(crate) fn lookup( + data_dir: &Path, + artist: &str, + title: &str, + duration: Option, + deep: bool, +) -> Option<(String, Option)> { + if artist.trim().is_empty() || title.trim().is_empty() { + return None; + } + let key = key_of(artist, title); + let now = now_secs(); + let remembered = with_cache(data_dir, |map| map.get(&key).cloned()); + if let Some(k) = remembered { + if let Some(url) = k.url { + return Some((url, k.track_id)); + } + if k.v >= HUNT_VERSION && now.saturating_sub(k.at) < MISS_TTL { + return None; + } + } + let client = CLIENT.get_or_init(|| std::sync::Arc::new(metadata::MetadataClient::new())); + let want_dur = duration.map(|d| d.round() as u32).filter(|d| *d > 0); + let mut hit: Option = None; + let mut answered = false; + for q in queries(artist, title) { + let tracks = crate::headless_slsk::rt().block_on(client.catalog_search_tracks(&q, 10)); + // the client folds "down" and "nothing" into one empty list; a + // list with anything in it proves the catalog is answering + if !tracks.is_empty() { + answered = true; + *lock(&LAST_ANSWER) = Some(Instant::now()); + } + if let Some(t) = pick(&tracks, artist, title, want_dur) { + hit = Some(t.clone()); + break; + } + } + // The page's last rung: the artist's whole shelf, flattened to tracks, + // for records the search index buries — Deezer's search answers + // "Nirvana Smells Like Teen Spirit" with six live takes and never the + // studio cut (2026-09-18), while Nevermind's own tracklist has it. + let mut ran_out = false; + if hit.is_none() && deep { + let lead = primary_artist(artist); + let walk = client.catalog_artist_track_candidates(&lead, title); + let shelf = crate::headless_slsk::rt() + .block_on(async { tokio::time::timeout(SHELF_PATIENCE, walk).await.ok() }); + match shelf { + Some(tracks) => { + if !tracks.is_empty() { + answered = true; + *lock(&LAST_ANSWER) = Some(Instant::now()); + } + hit = pick(&tracks, artist, title, want_dur).cloned(); + } + None => { + ran_out = true; + crate::auto_browse::warn(&format!( + "cover hunt: the shelf walk for '{artist}' ran out of time" + )); + } + } + } + let found = hit.and_then(|t| t.cover.as_deref().map(|c| (hq_cover(c), Some(t.id)))); + let reachable = answered + || lock(&LAST_ANSWER).is_some_and(|at| at.elapsed() < REACHABLE_FOR); + if found.is_none() && !reachable { + // offline, or the catalog unreachable: not a miss + crate::auto_browse::warn(&format!("cover hunt: catalog unreachable for '{title}'")); + return None; + } + with_cache(data_dir, |map| { + map.insert( + key, + Known { + url: found.as_ref().map(|(u, _)| u.clone()), + track_id: found.as_ref().and_then(|(_, id)| *id), + // a miss cut short by the clock is asked again within the + // hour, not the day + at: if ran_out && found.is_none() { now.saturating_sub(MISS_TTL - SHORT_MISS) } else { now }, + v: HUNT_VERSION, + }, + ); + }); + save(data_dir); + match &found { + Some((u, _)) => crate::auto_browse::warn(&format!("cover hunt: '{artist} — {title}' → {u}")), + None => crate::auto_browse::warn(&format!("cover hunt: nothing certain for '{artist} — {title}'")), + } + found +} + +/// Write the cover on the row — and on every other stream row of the same +/// track that has none. Only a NULL is ever filled. +fn persist(data_dir: &Path, item_id: i64, artist: &str, title: &str, url: &str, track_id: Option) { + let db = crate::auto_browse::db_path(data_dir); + let Ok(conn) = rusqlite::Connection::open(&db) else { + return; + }; + let _ = conn.busy_timeout(Duration::from_secs(3)); + let updated = conn.execute( + "UPDATE playlist_items SET art_url = ?1 + WHERE art_url IS NULL AND art_path IS NULL AND kind = 'stream' + AND (id = ?2 OR (lower(artist) = lower(?3) AND lower(title) = lower(?4)))", + rusqlite::params![url, item_id, artist, title], + ); + if let Some(id) = track_id { + let _ = conn.execute( + "UPDATE playlist_items SET track_id = ?1 + WHERE (track_id IS NULL OR track_id = '') AND kind = 'stream' + AND (id = ?2 OR (lower(artist) = lower(?3) AND lower(title) = lower(?4)))", + rusqlite::params![id.to_string(), item_id, artist, title], + ); + } + if let Err(e) = updated { + crate::auto_browse::warn(&format!("cover hunt: could not write row {item_id}: {e}")); + } +} + +/* ── the matcher: catalogMatch.js, in Rust ─────────────────────────── */ + +/// Fold to comparable letters and digits: case, accents and punctuation +/// all differ freely between a tag, a tracklist line and a catalog row. +/// (`nameNorm`: NFKD there; a table of the Latin accents here.) +pub(crate) fn name_norm(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut space = true; + let push = |c: char, out: &mut String, space: &mut bool| { + if c.is_alphanumeric() { + out.push(c); + *space = false; + } else if !*space { + out.push(' '); + *space = true; + } + }; + for c in s.chars() { + if c == '&' { + for a in " and ".chars() { + push(a, &mut out, &mut space); + } + continue; + } + for lc in c.to_lowercase() { + match fold(lc) { + Some(folded) => { + for fc in folded.chars() { + push(fc, &mut out, &mut space); + } + } + None => push(lc, &mut out, &mut space), + } + } + } + out.trim().to_owned() +} + +fn fold(c: char) -> Option<&'static str> { + Some(match c { + 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => "a", + 'æ' => "ae", + 'ç' | 'ć' | 'č' => "c", + 'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ė' | 'ę' | 'ě' => "e", + 'ì' | 'í' | 'î' | 'ï' | 'ī' | 'į' => "i", + 'ñ' | 'ń' | 'ň' => "n", + 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ő' => "o", + 'œ' => "oe", + 'ù' | 'ú' | 'û' | 'ü' | 'ū' | 'ů' | 'ű' => "u", + 'ý' | 'ÿ' => "y", + 'ß' => "ss", + 'š' | 'ś' => "s", + 'ž' | 'ź' | 'ż' => "z", + 'ł' => "l", + 'đ' => "d", + 'ř' => "r", + 'ť' => "t", + _ => return None, + }) +} + +/// A different MASTER of one performance: same recording, repackaged. +const EDITION_WORDS: &[&str] = &[ + "remaster", "remastered", "remasterd", "stereo", "mono", "deluxe", "anniversary", "reissue", + "expanded", "digital", "album version", "single version", "original version", "radio edit", + "explicit", "clean", "bonus", "mix master", "from the vault", +]; +/// A different PERFORMANCE: another take, another artist's hands on it. +const IDENTITY_WORDS: &[&str] = &[ + "remix", "live", "acoustic", "instrumental", "demo", "cover", "karaoke", "unplugged", "reprise", + "vip", "bootleg", "session", "rework", "flip", "mashup", "dub", "edit", "mix", "version", "take", + "alternate", "orchestral", "piano", "8 bit", "tribute", "originally", "made famous", +]; + +/// `phrase` as whole words inside an already-normalized string. +fn has_phrase(norm: &str, phrase: &str) -> bool { + format!(" {norm} ").contains(&format!(" {phrase} ")) +} + +fn is_year(norm: &str) -> bool { + norm.len() == 4 && norm.chars().all(|c| c.is_ascii_digit()) +} + +/// What a qualifier means for identity: "none", a different master +/// ("edition"), or a different performance ("identity"). An unrecognized +/// qualifier counts as a different performance — the strict reading. +fn qualifier_class(qualifier: &str) -> &'static str { + let n = name_norm(qualifier); + if n.is_empty() { + return "none"; + } + let first = n.split(' ').next().unwrap_or(""); + if ["feat", "featuring", "ft", "with", "w"].contains(&first) { + return "edition"; + } + if is_year(&n) { + return "edition"; + } + if EDITION_WORDS.iter().any(|w| has_phrase(&n, w)) { + return "edition"; + } + "identity" +} + +/// "Title (2011 Remaster)" / "Title - Remastered 2011" / "Title -album +/// version-" → the base name and the trailing qualifier(s). One trailing +/// group per pass, repeatedly. +pub(crate) fn split_version(title: &str) -> (String, String) { + let mut base = title.trim().to_owned(); + let mut parts: Vec = Vec::new(); + for _ in 0..4 { + let before = base.clone(); + if let Some((b, q)) = trailing_bracket(&base) { + base = b; + parts.push(q); + continue; + } + if let Some((b, q)) = dashes_both_sides(&base) { + base = b; + parts.push(q); + continue; + } + let dashed = base + .rsplit_once(" - ") + .map(|(b, tail)| (b.trim().to_owned(), tail.trim().to_owned())); + if let Some((b, tail)) = dashed { + let t = name_norm(&tail); + let qualifier = EDITION_WORDS.iter().any(|w| has_phrase(&t, w)) + || IDENTITY_WORDS.iter().any(|w| has_phrase(&t, w)) + || is_year(&t); + if qualifier && !b.is_empty() { + base = b; + parts.push(tail); + continue; + } + } + if base == before { + break; + } + } + parts.reverse(); + (base, parts.join(" ")) +} + +/// "Name (group)" at the very end, the group holding no opening bracket. +fn trailing_bracket(s: &str) -> Option<(String, String)> { + let t = s.trim_end(); + let close = t.chars().last()?; + let open = match close { + ')' => '(', + ']' => '[', + '}' => '{', + _ => return None, + }; + let start = t.rfind(open)?; + let inner = &t[start + open.len_utf8()..t.len() - close.len_utf8()]; + if inner.contains(['(', '[', '{']) { + return None; + } + let head = t[..start].trim_end(); + if head.is_empty() { + return None; + } + Some((head.to_owned(), inner.trim().to_owned())) +} + +/// "Title -album version-": dashes on both sides of the tail. +fn dashes_both_sides(s: &str) -> Option<(String, String)> { + let t = s.trim_end(); + let t = t.strip_suffix('-')?.trim_end(); + let cut = t.rfind(" -")?; + let head = t[..cut].trim_end(); + let tail = t[cut + 2..].trim(); + if head.is_empty() || tail.is_empty() || tail.contains('-') { + return None; + } + Some((head.to_owned(), tail.to_owned())) +} + +/// The comma-separated lead artist; never split on "&" or "and". +fn primary_artist(artist: &str) -> String { + let first = artist.split(',').next().unwrap_or("").trim(); + if first.is_empty() { + artist.trim().to_owned() + } else { + first.to_owned() + } +} + +/// The act without its backing band: "Joan Jett and the Blackhearts" → +/// "joan jett" (normalized). Only a trailing "and the …" / "with …" comes +/// off, never a bare word. +fn band_core(artist: &str) -> String { + let n = name_norm(artist); + let mut best: Option = None; + for sep in [" and ", " with "] { + for (i, _) in n.match_indices(sep) { + let rest = &n[i + sep.len()..]; + let rest = rest.strip_prefix("the ").unwrap_or(rest); + if i > 0 && !rest.trim().is_empty() { + best = Some(best.map_or(i, |b| b.min(i))); + } + } + } + match best { + Some(i) => n[..i].trim().to_owned(), + None => n, + } +} + +/// Do these two artist strings name the same act? +fn artist_agrees(want: &str, cand: &str) -> bool { + let w = name_norm(want); + let c = name_norm(cand); + if w.is_empty() || c.is_empty() { + return false; + } + if w == c { + return true; + } + let wp = name_norm(&primary_artist(want)); + let cp = name_norm(&primary_artist(cand)); + if wp == c || cp == w || (!wp.is_empty() && wp == cp) { + return true; + } + let wc = band_core(&wp); + let cc = band_core(&cp); + (!wc.is_empty() && (wc == c || wc == cc)) || (!cc.is_empty() && cc == w) +} + +/// Durations both sides know that disagree by more than this are +/// different cuts; within [`DURATION_CONFIRM_SECS`] the duration confirms. +const DURATION_TOLERANCE_SECS: u32 = 20; +const DURATION_CONFIRM_SECS: u32 = 7; + +/// The best catalog track for the wanted one, or `None` when nothing +/// clears the bar — `pickCatalogMatch`, rule for rule. +pub(crate) fn pick<'a>( + tracks: &'a [metadata::CatalogTrack], + want_artist: &str, + want_title: &str, + want_dur: Option, +) -> Option<&'a metadata::CatalogTrack> { + let (want_base_raw, want_qual_raw) = split_version(want_title); + let want_base = name_norm(&want_base_raw); + let want_class = qualifier_class(&want_qual_raw); + let want_qual = name_norm(&want_qual_raw); + if want_base.is_empty() || name_norm(want_artist).is_empty() { + return None; + } + let mut best: Option<&metadata::CatalogTrack> = None; + let mut best_score = f64::MIN; + for t in tracks { + if !artist_agrees(want_artist, &t.artist) { + continue; + } + let (cand_base_raw, cand_qual_raw) = split_version(&t.title); + let cand_base = name_norm(&cand_base_raw); + let cand_class = qualifier_class(&cand_qual_raw); + let cand_qual = name_norm(&cand_qual_raw); + if cand_base.is_empty() { + continue; + } + let shorter = cand_base.len().min(want_base.len()); + let longer = cand_base.len().max(want_base.len()); + let title_score = if cand_base == want_base { + 4.0 + } else if (cand_base.contains(&want_base) || want_base.contains(&cand_base)) + && shorter >= 6 + && (shorter as f64) / (longer as f64) >= 0.6 + { + 2.0 + } else { + continue; + }; + if want_class == "identity" || cand_class == "identity" { + let shared = !want_qual.is_empty() + && !cand_qual.is_empty() + && want_qual + .split(' ') + .any(|w| w.len() > 2 && cand_qual.contains(w)); + if !shared { + continue; + } + } + let both_know = want_dur.is_some() && t.duration_secs.is_some_and(|d| d > 0); + let delta = if both_know { + Some(t.duration_secs.unwrap_or(0).abs_diff(want_dur.unwrap_or(0))) + } else { + None + }; + if let Some(d) = delta { + if d > DURATION_TOLERANCE_SECS { + continue; + } + } + let mut score = title_score; + if delta.is_some_and(|d| d <= DURATION_CONFIRM_SECS) { + score += 2.0; + } + if want_class == cand_class { + score += 1.0; + } + score += (t.rank.unwrap_or(0) as f64 / 1_000_000.0).min(1.0); + if score > best_score { + best_score = score; + best = Some(t); + } + } + best +} + +/// Quote-safe value for Deezer's field search syntax. +fn q(s: &str) -> String { + s.replace(['"', '\\'], " ").trim().to_owned() +} + +/// The queries to try for one wanted track, strongest first — the page's +/// `catalogQueries`: the precise field syntax with the base name, the +/// band-core spelling, the full name, then the blunt way. +pub(crate) fn queries(artist: &str, title: &str) -> Vec { + let artist_q = q(&primary_artist(artist)); + let full = q(title); + let base = q(&split_version(title).0); + let core = q(&band_core(&artist_q)); + let mut out = Vec::new(); + if !artist_q.is_empty() && !base.is_empty() { + out.push(format!("artist:\"{artist_q}\" track:\"{base}\"")); + } + if !core.is_empty() && !base.is_empty() && core != name_norm(&artist_q) { + out.push(format!("artist:\"{core}\" track:\"{base}\"")); + } + if !artist_q.is_empty() && !full.is_empty() && full != base { + out.push(format!("artist:\"{artist_q}\" track:\"{full}\"")); + } + if !artist_q.is_empty() && !base.is_empty() { + out.push(format!("{artist_q} {base}")); + } + out +} + +/// The largest Deezer rendition of a cover URL (`hqCover`). +pub(crate) fn hq_cover(url: &str) -> String { + let host_ok = url + .split("://") + .nth(1) + .and_then(|rest| rest.split('/').next()) + .is_some_and(|h| h.ends_with("dzcdn.net")); + if !host_ok { + return url.to_owned(); + } + let Some((head, seg)) = url.rsplit_once('/') else { + return url.to_owned(); + }; + // "250x250-000000-80-0-0.jpg" → "1000x1000-000000-80-0-0.jpg" + let digits = |s: &str| s.chars().take_while(|c| c.is_ascii_digit()).count(); + let w = digits(seg); + if !(2..=4).contains(&w) || seg[w..].chars().next() != Some('x') { + return url.to_owned(); + } + let h = digits(&seg[w + 1..]); + if !(2..=4).contains(&h) { + return url.to_owned(); + } + let rest = &seg[w + 1 + h..]; + let ext_ok = rest.rsplit_once('.').is_some_and(|(_, e)| { + matches!(e.to_ascii_lowercase().as_str(), "jpg" | "png" | "webp") + }); + if !ext_ok || !(rest.starts_with('-') || rest.starts_with('.')) { + return url.to_owned(); + } + format!("{head}/1000x1000{rest}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_split_like_the_page() { + assert_eq!(split_version("Bohemian Rhapsody - Remastered 2011").0, "Bohemian Rhapsody"); + assert_eq!(split_version("Lola (Stereo Remastered 2020) [Bonus]").0, "Lola"); + assert_eq!(split_version("Kiss - Detroit Rock City").0, "Kiss - Detroit Rock City"); + assert_eq!(split_version("A-Punk").0, "A-Punk"); + let (b, qual) = split_version("Parking Lot - VIGILE Remix"); + assert_eq!(b, "Parking Lot"); + assert_eq!(qualifier_class(&qual), "identity"); + } + + #[test] + fn artists_agree_like_the_page() { + assert!(artist_agrees("Joan Jett and the Blackhearts", "Joan Jett")); + assert!(artist_agrees("Ennio Morricone, Joan Baez", "Ennio Morricone")); + assert!(!artist_agrees("Queen", "Queen Latifah")); + assert_eq!(band_core("Joan Jett and the Blackhearts"), "joan jett"); + } + + #[test] + fn covers_upscale() { + assert_eq!( + hq_cover("https://cdn-images.dzcdn.net/images/cover/abc/250x250-000000-80-0-0.jpg"), + "https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg" + ); + assert_eq!(hq_cover("https://i.ytimg.com/vi/x/maxresdefault.jpg"), "https://i.ytimg.com/vi/x/maxresdefault.jpg"); + } +} diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs index ccafe48..4632cd7 100644 --- a/src-tauri/src/headless.rs +++ b/src-tauri/src/headless.rs @@ -17,17 +17,25 @@ //! `playback::init` registers its forwarder and starts seeing events, without //! a second engine and without the events it missed being replayed at it. //! -//! The queue here is deliberately NOT the app's queue. The JS one keeps -//! shuffle, repeat, `alts` failover, provenance and the three-ahead preload; -//! this is a `Vec` and an index whose only job is that the album keeps -//! playing when there is no JS alive to advance it. +//! **The queue works the way the page's does.** It holds every row of the +//! record or playlist, in order, whether or not its bytes are on the phone. +//! A row that is here plays from its file; a row that is not is FOUND when +//! the queue reaches it — the stored account, one search, the bytes into +//! the stream cache and the engine on them as they land (`headless_slsk.rs`) +//! — and the next three rows are fetched ahead of time so the boundary is a +//! boundary and not a wait. When the next file is whole before the current +//! one ends it is appended to the sink and the engine crosses into it +//! gaplessly, the page's `preloadLocal` and `preloadCachedStream` in one. +//! What the page still owns and this does not: shuffle as a mode, repeat, +//! and the resolver's full ladder of queries. //! //! The pump is also the one place that always knows where the engine is, in //! both modes, so it is what keeps the **resume point** (`resume.rs`) honest: //! the page names the queue, the pump writes the position. use crate::resume::{ResumeItem, ResumePoint, StreamRef}; -use player::{Player, PlayerEvent}; +use player::{Player, PlayerEvent, StreamProgress}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::mpsc::Sender; use std::sync::{Arc, Mutex, OnceLock}; @@ -64,7 +72,10 @@ pub struct PlayTrack { /// The page's own id for the entry, so a handover keeps the identity — /// a library track's number, `pl` for a playlist row. pub id: Option, - pub path: PathBuf, + /// The file that plays it: a library track, or the (final) cache file of + /// a peer copy. `None` until a copy has been found; a cache file that is + /// still arriving is named here too, and is not a file yet. + pub path: Option, pub title: String, pub artist: String, pub album: String, @@ -75,18 +86,44 @@ pub struct PlayTrack { /// is given, which is why supplying the app icon tinted every record the /// same blue. pub art: Option, - /// The peer file this is a cache copy of, when it is one. Carried into - /// the resume point so a later cold start can check the cache again - /// rather than trust a path the pruner may have taken since. + /// The peer file this is (or will be) a cache copy of — remembered from a + /// past play, or found by the resolver. Carried into the resume point so + /// a later cold start can check the cache again rather than trust a path + /// the pruner may have taken since. pub stream: Option, } +impl PlayTrack { + /// The file is on this phone, whole. + pub(crate) fn here(&self) -> Option<&PathBuf> { + self.path.as_ref().filter(|p| p.is_file()) + } +} + +/// A peer copy on its way into the stream cache. +#[derive(Clone)] +struct Fetch { + partial: PathBuf, + progress: Arc, + size: u64, + id: u64, +} + #[derive(Default)] struct Queue { items: Vec, idx: usize, /// The record's or playlist's name, for the resume point. from: String, + /// What the engine has appended for a gapless boundary, if anything. + armed: Option, + /// Fetches in flight, by the cache file they are filling. By PATH and + /// not by index, so a shuffle of what is ahead cannot lose one and start + /// the same file from the same peer twice (which deadlocks both). + fetching: HashMap, + /// Bumped by every start. A resolver or prefetch thread carries the + /// generation it was born with and stands down when it moves. + gen: u64, } static AUDIO: OnceLock> = OnceLock::new(); @@ -98,6 +135,13 @@ static DATA_DIR: OnceLock = OnceLock::new(); /// How often the position is written while playing. Five seconds is the /// most a resumed track can be behind where it really was. const FLUSH_EVERY: Duration = Duration::from_secs(5); +/// The page's PRELOAD_DEPTH: how many rows ahead are fetched. +const PREFETCH_DEPTH: usize = 3; +/// The page's ALBUM_FILL_DELAY_MS: fetching ahead waits this long after a +/// start so it never competes with the track that is starting. +const PREFETCH_DELAY: Duration = Duration::from_secs(4); +/// A peer that has not sent a byte in this long is not about to. +const FIRST_BYTE: Duration = Duration::from_secs(15); pub fn set_data_dir(dir: &Path) { let _ = DATA_DIR.set(dir.to_path_buf()); @@ -134,8 +178,8 @@ pub fn audio(device: Option) -> Arc