android auto: the car's queue works like the page's, and every row gets its cover
"Needs the app" is gone. It named what the cold service could not do — the resolver and the Soulseek session live in the page, and a car that starts the app cold has no page — and Kyle's answer to the label was the right one: a tap should find a copy and play it, in the background, and a playlist should keep going through rows that are not here exactly as the page keeps going. headless_slsk.rs is a Soulseek session for a process with no page: the stored account, one search (artist + simplified title, the server's excluded phrases stripped), copies ranked by rules close to the page's songCandidates — the title in the filename, streamable formats, a duration within eight per cent, lossless over 320 over the rest, the artist in the path, a free upload slot before anything else — and a download into the same stream cache the page uses. Its own tokio runtime: a car-started process has no Tauri one. headless.rs is the page's queue in Rust. resolve_playable queues EVERY row of a playlist in order, a row not here with its remembered peer file and no path. The engine starts a row from its file when it is whole, from the fetch already filling its cache file when one is, and otherwise finds it on the network right then (the remembered peer first, then the search), playing the bytes as they land; a peer that sends nothing for fifteen seconds is dropped for the next copy, twice, and when a row has no copy anywhere the queue moves on — only the last row's failure reaches the card. The next three rows are fetched ahead of time, four seconds after a start so nothing competes with the track that is starting, and when the immediate next is whole before the current one ends it is appended to the sink and the boundary is gapless — the page's preloadLocal and preloadCachedStream in one. Fetches are keyed by cache file, not index, so a shuffle cannot start the same file from the same peer twice. The card shows the title at once with a spinner (STATE_BUFFERING through headlessState), then the track; every source is remembered in the mirror, so the row is "here" next time. One session per process: when the app opens over it, login_inner ADOPTS the client and reads its events through subscribe() instead of logging 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, so the row the car was fetching ahead lands gaplessly under the page too. Covers, always, like the desktop. Thirty of GhettoTechie's forty rows have no catalog cover, and the car drew the app icon over them. In the cold process the cover embedded in the file is extracted — the desktop's own tagger::extract_art, keyed by the audio path under <library>/.soulstream/stream-art/by-file/, a miss remembered as an empty file — for browse rows, the card and the Continue row, and a row fetched cold gets the same once 200 kB of it have landed. On the phone, coverart.svelte.js is the desktop's playlist backfill as a background walk over every playlist: the file before the catalog (the row's own cached copy, persisted as art_path), then the ONE strict ladder the import review uses (findCatalogTrack, persisted as art_url + track_id) — never a loose name lookup. After boot, on every playlist change, every six hours, the open page's rows first, a miss remembered for a day and only when the catalog answered. The player does the same for the track that is playing (ensureStreamArt: embedded, then catalog), makes the URL the entry's art key and hands it to the walk to persist. The page's own hole is closed too: a streamed row with no cover got no art key at all, and where it had one the extractor's FILE PATH was stored where a data URL belongs; such rows get a "stream:" key and the file is read as a thumbnail, and a stream row's art_path now draws on the phone. 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; the two are meant to stay in step — fed by every browse and every queue built from a playlist. A blank stream row is answered from what was asked before (stream-art/catalog.json, a miss for a day and only when the catalog answered), else queued for one thread that looks rows up one at a time behind the client's own Deezer pacing, writes the answer on the row (art_url and the track id, only ever filling a NULL; the library is WAL and the page's writer is another connection), and re-lists the playlist on the car. The track that is playing gets the same, through an entry that moves only the card's art and never its position. The cold Soulseek search is the page's two-rung ladder as well: artist plus the 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 too, the artist's whole shelf flattened to tracks, for the records the search index buries — spent only on the track that is playing, bounded to forty seconds, and the cover memory carries the matcher's version so a "no" from an older matcher is no answer once a rung is added. Two things the head unit found in the seam. media3's default onSubscribe answers "not supported", so nobody was ever subscribed and the re-list notice reached nobody; LibraryTree accepts subscriptions now, and covers land on the car's list within seconds of being found. And 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, and the card sat on BUFFERING; every client call from an engine thread runs under the cold runtime's enter() now. The phone's artist page never ran the MusicBrainz pass. It built its discography from Deezer's list alone, so the releases Deezer's artist listing omits — EPs, and whole albums — never appeared, nor the authoritative release types or the guest-appearance and co-tenant arbitration. The desktop's identity merge is a plain module now (lib/artistIdentity.js, moved out of state.svelte.js verbatim, the desktop importing it back), and the phone runs it after the page is on screen — one implementation for both surfaces. What the restarted head unit found, all in the seam between a car session and the page — and one bug older than this branch. The pump's calls into Kotlin never worked. "auto advanced to" has not appeared in a single log while cold queues advanced through whole albums under a card stuck on track one, and the new end-of-queue notice did not fire either. FindClass on the self-attached pump thread resolves through the system class loader, which cannot see com.soulstream.mediasession .AutoNative; the lookup failed and the exception was cleared, silently, every time. The class is taken from the object Kotlin hands every JNI entry — on a Java thread, through the app's own loader — and kept as a global reference; every pump call goes through call_auto_native. Autoplay on connection worked unprompted and resumed Starboy at 3:50 of 3:50: the page writes the position one last time when a track runs out, so a resume point can sit at the end. resume_queue treats a position within two seconds of the duration as "the next item from the top", keeps every entry (one not here is found on reach, like any other), and the headless engine reports end-of-queue to Kotlin, which turns the card to paused. A seek on the page reached the resume point only at the pump's next five-second flush; seekSecs writes the session at once. Opening the app over a cold car session handed the page the right queue and the page called it a paused session that needed re-sourcing: the transport snapshot it reads is built only from events after the page subscribes, and an engine already playing sends no Playing. The bar said paused, the card said PAUSED, the audio kept going. playback::init seeds the snapshot from the engine's own progress when it adopts a running engine, so the page follows what is sounding. preview::init opened two more output streams at every launch for the desktop's hover previews, which the phone never calls — three live AAudio tracks on our uid with one sounding. It is gated off on Android.
This commit is contained in:
1 parent
5ddb6d4d59
commit
9f7661afab
22 files changed
+3069
-485
No files matched your search
+105
-12
@@ -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
|
it can, say what it cannot, and pick up where the phone left off. Four
|
||||||
pieces, all of them landed 2026-09-18:
|
pieces, all of them landed 2026-09-18:
|
||||||
|
|
||||||
**What plays cold.** Library tracks always did. Now a playlist's streamed
|
**What plays cold: everything, the way the page plays it.** Library tracks
|
||||||
rows play cold too, when their bytes are already on the phone: the page's
|
always did. A playlist's streamed rows play cold too — every one of them.
|
||||||
memory of which peer file a catalog track resolved to
|
The page's memory of which peer file a catalog track resolved to
|
||||||
(`soulstream.streamSources.v1` in localStorage) is mirrored to
|
(`soulstream.streamSources.v1` in localStorage) is mirrored to
|
||||||
`<data>/stream-sources.json` (`stream_sources.rs`) — one entry every time a
|
`<data>/stream-sources.json` (`stream_sources.rs`) — one entry every time a
|
||||||
source is remembered, and the whole store once at boot for memories made
|
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
|
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`
|
there and 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
|
uses. A row whose file is whole plays from it and carries the host's
|
||||||
"artist · album" in the car; one that is not is "artist · needs the app",
|
"downloaded" mark; a row whose bytes are not here is still a row that
|
||||||
which is the amber-dot row of the design. A playlist's subtitle counts
|
plays, and the difference is only that mark. `resolve_playable` queues the
|
||||||
cached streams as "here". A tap on a row that needs the app is handed to
|
WHOLE playlist in order (`headless::PlayTrack.path` is `None` for a row
|
||||||
the app when it is up (`playRemote`), exactly as before.
|
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
|
||||||
|
`<library>/.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"
|
**Keep offline.** A playlist's More sheet has "Keep offline"
|
||||||
(`src/mobile/offline.svelte.js`, the list in `config.json`
|
(`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
|
restoring the page's own older session over live audio, which put the wrong
|
||||||
title on the bar.
|
title on the bar.
|
||||||
|
|
||||||
**What the car is told.** A tap that cannot play — a streamed row with no
|
**What the car is told.** A tap that cannot play — a streamed row no peer
|
||||||
cache copy while the app is cold, a hand-off the resolver refused, a row
|
on the network has, a hand-off the resolver refused, a row whose file is
|
||||||
whose file is gone — used to be silence. `MediaHub.fail` now puts the reason
|
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
|
in the playback state (`STATE_ERROR` with the message, which Auto draws as a
|
||||||
card) and sends a `SessionError` carrying "Open SoulStream" as the
|
card) and sends a `SessionError` carrying "Open SoulStream" as the
|
||||||
resolution action; the next track that plays withdraws it. The page reports
|
resolution action; the next track that plays withdraws it. The page reports
|
||||||
|
|||||||
+109
@@ -862,6 +862,31 @@ object LibraryTree : MediaLibrarySession.Callback {
|
|||||||
* `page` and `pageSize` are ignored deliberately: Auto does not paginate
|
* `page` and `pageSize` are ignored deliberately: Auto does not paginate
|
||||||
* a browse, it asks for a node and draws what comes back.
|
* 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<LibraryResult<Void>> {
|
||||||
|
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<LibraryResult<Void>> = Futures.immediateFuture(LibraryResult.ofVoid())
|
||||||
|
|
||||||
override fun onGetChildren(
|
override fun onGetChildren(
|
||||||
session: MediaLibrarySession,
|
session: MediaLibrarySession,
|
||||||
browser: MediaSession.ControllerInfo,
|
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
|
@JvmStatic
|
||||||
fun headlessTrack(json: String) {
|
fun headlessTrack(json: String) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+4
@@ -28,6 +28,10 @@ class NowPlayingArgs {
|
|||||||
var art: String? = null
|
var art: String? = null
|
||||||
var hasNext: Boolean = true
|
var hasNext: Boolean = true
|
||||||
var hasPrev: 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.
|
/** the queue around the current item — it and up to ten either side.
|
||||||
* Since round forty-eight SoulStreamPlayer.getState() turns this into
|
* Since round forty-eight SoulStreamPlayer.getState() turns this into
|
||||||
* the media3 playlist the card is built from, and the index into it.
|
* the media3 playlist the card is built from, and the index into it.
|
||||||
|
|||||||
+20
-7
@@ -123,19 +123,24 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
|||||||
per rebuild and is always true. */
|
per rebuild and is always true. */
|
||||||
var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
|
var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
|
||||||
var sounding = np.playing
|
var sounding = np.playing
|
||||||
|
var buffering = false
|
||||||
if (headless()) {
|
if (headless()) {
|
||||||
posMs = AutoNative.positionMs()
|
posMs = AutoNative.positionMs()
|
||||||
sounding = AutoNative.sounding()
|
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()
|
return SimpleBasePlayer.State.Builder()
|
||||||
.setAvailableCommands(commands(np))
|
.setAvailableCommands(commands(np))
|
||||||
/* STATE_READY even when paused. shouldShowNotification treats
|
/* STATE_READY even when paused. shouldShowNotification treats
|
||||||
STATE_IDLE as "nothing to show" unless it has been prepared and
|
STATE_IDLE as "nothing to show" unless it has been prepared and
|
||||||
stopped, so a paused player reporting IDLE loses its card.
|
stopped, so a paused player reporting IDLE loses its card.
|
||||||
There is no buffering state to report either: media.svelte.js
|
The page's own player folds buffering into `playing`; only the
|
||||||
already folds `buffering` into `playing`. */
|
headless fetch reports BUFFERING, above. */
|
||||||
.setPlaybackState(Player.STATE_READY)
|
.setPlaybackState(if (buffering) Player.STATE_BUFFERING else Player.STATE_READY)
|
||||||
.setPlayWhenReady(sounding, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
|
.setPlayWhenReady(sounding || buffering, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
|
||||||
.setPlaylist(items)
|
.setPlaylist(items)
|
||||||
.setCurrentMediaItemIndex(at)
|
.setCurrentMediaItemIndex(at)
|
||||||
/* The long overload, not a PositionSupplier: State's own
|
/* 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
|
// Nothing on this phone can play it, and the process is
|
||||||
// cold: the focus goes back rather than being held for
|
// cold: the focus goes back rather than being held for
|
||||||
// silence — and the car is TOLD, which it never was
|
// silence — and the car is TOLD, which it never was
|
||||||
// before. A streamed row with no cache copy needs the
|
// before. A streamed row is fetched from the network by
|
||||||
// resolver, which needs the app.
|
// this process now (headless_slsk.rs); `error` is its
|
||||||
|
// reason when that could not even start.
|
||||||
MediaHub.dropFocus(ctx.applicationContext)
|
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")
|
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
|
||||||
MediaHub.fail(
|
MediaHub.fail(
|
||||||
ctx.applicationContext,
|
ctx.applicationContext,
|
||||||
@@ -412,6 +422,9 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
|||||||
own position takes over from the first tick */
|
own position takes over from the first tick */
|
||||||
np.position = o.optLong("positionMs", 0L) / 1000.0
|
np.position = o.optLong("positionMs", 0L) / 1000.0
|
||||||
np.playing = true
|
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.hasNext = o.optBoolean("hasNext", queued > 1)
|
||||||
np.hasPrev = false
|
np.hasPrev = false
|
||||||
// The cover, which is also the colour: Android Auto washes
|
// The cover, which is also the colour: Android Auto washes
|
||||||
|
|||||||
+165
-37
@@ -86,7 +86,7 @@ impl Node {
|
|||||||
/// called here because it takes a `tauri::AppHandle`). The database itself
|
/// called here because it takes a `tauri::AppHandle`). The database itself
|
||||||
/// always lives under `<library>/.soulstream/`, which is the rule that keeps
|
/// always lives under `<library>/.soulstream/`, which is the rule that keeps
|
||||||
/// stored paths portable.
|
/// 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"))
|
let configured = std::fs::read_to_string(data_dir.join("config.json"))
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||||
@@ -348,13 +348,75 @@ fn resume_row(data_dir: &Path) -> Option<Node> {
|
|||||||
subtitle: format!("Continue · {source}{at}"),
|
subtitle: format!("Continue · {source}{at}"),
|
||||||
browsable: false,
|
browsable: false,
|
||||||
playable: true,
|
playable: true,
|
||||||
art: t.art.clone(),
|
art: t
|
||||||
path: Some(t.path.to_string_lossy().into_owned()),
|
.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
|
/// The cover embedded in an audio file, as a file the car can be handed.
|
||||||
/// has one, else the byte-complete cache copy of its remembered peer file.
|
///
|
||||||
|
/// 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
|
||||||
|
/// `<library>/.soulstream/stream-art/`, which the FileProvider roots reach.
|
||||||
|
pub(crate) fn embedded_cover(data_dir: &Path, audio: &Path) -> Option<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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(
|
fn row_file(
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
kind: &str,
|
kind: &str,
|
||||||
@@ -362,23 +424,25 @@ fn row_file(
|
|||||||
track_id: Option<&str>,
|
track_id: Option<&str>,
|
||||||
artist: &str,
|
artist: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
) -> Option<(PathBuf, Option<crate::resume::StreamRef>)> {
|
) -> (Option<PathBuf>, Option<crate::resume::StreamRef>) {
|
||||||
if kind == "local" {
|
if kind == "local" {
|
||||||
return path.map(|p| (PathBuf::from(p), None));
|
return (path.map(PathBuf::from), None);
|
||||||
}
|
}
|
||||||
if kind != "stream" {
|
if kind != "stream" {
|
||||||
return None;
|
return (None, None);
|
||||||
}
|
}
|
||||||
let src = crate::stream_sources::lookup(data_dir, track_id, Some(artist), Some(title))?;
|
let Some(src) = crate::stream_sources::lookup(data_dir, track_id, Some(artist), Some(title)) else {
|
||||||
let file = crate::stream_sources::cached_file(data_dir, &src)?;
|
return (None, None);
|
||||||
Some((
|
};
|
||||||
|
let file = crate::stream_sources::cached_file(data_dir, &src);
|
||||||
|
(
|
||||||
file,
|
file,
|
||||||
Some(crate::resume::StreamRef {
|
Some(crate::resume::StreamRef {
|
||||||
username: src.username,
|
username: src.username,
|
||||||
path: src.path,
|
path: src.path,
|
||||||
size: src.size,
|
size: src.size,
|
||||||
}),
|
}),
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playlists, newest first, as browsable rows.
|
/// 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() {
|
for row in rows.flatten() {
|
||||||
n += 1;
|
n += 1;
|
||||||
let (kind, path, track_id, artist, title) = row;
|
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;
|
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.
|
/// so the list matches the phone's.
|
||||||
fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result<Vec<Node>> {
|
fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result<Vec<Node>> {
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM playlist_items
|
||||||
WHERE playlist_id = ?1
|
WHERE playlist_id = ?1
|
||||||
ORDER BY position",
|
ORDER BY position",
|
||||||
@@ -473,7 +537,8 @@ fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusql
|
|||||||
let art: Option<String> = r.get(5)?;
|
let art: Option<String> = r.get(5)?;
|
||||||
let kind: String = r.get(6)?;
|
let kind: String = r.get(6)?;
|
||||||
let track_id: Option<String> = r.get(7)?;
|
let track_id: Option<String> = r.get(7)?;
|
||||||
let file = row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title);
|
let duration = r.get::<_, Option<i64>>(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
|
// 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
|
// 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
|
// 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
|
// 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
|
// card (cold) — never a silent nothing. Kotlin marks the ones whose
|
||||||
// file is here with the host's own "downloaded" indicator.
|
// 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 unresolved = kind == "unresolved";
|
||||||
let sub = if unresolved {
|
let sub = if unresolved {
|
||||||
format!("{artist} · unresolved")
|
format!("{artist} · unresolved")
|
||||||
} else if file.is_none() {
|
|
||||||
format!("{artist} · needs the app")
|
|
||||||
} else {
|
} else {
|
||||||
match &album {
|
match &album {
|
||||||
Some(al) if !al.is_empty() => format!("{artist} · {al}"),
|
Some(al) if !al.is_empty() => format!("{artist} · {al}"),
|
||||||
_ => artist.clone(),
|
_ => 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 {
|
Ok(Node {
|
||||||
id: format!("ss:pitem:{id}"),
|
id: format!("ss:pitem:{id}"),
|
||||||
title,
|
title,
|
||||||
@@ -499,7 +593,7 @@ fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusql
|
|||||||
browsable: false,
|
browsable: false,
|
||||||
playable: !unresolved,
|
playable: !unresolved,
|
||||||
art,
|
art,
|
||||||
path: file.map(|(p, _)| p.to_string_lossy().into_owned()),
|
path: file.map(|p| p.to_string_lossy().into_owned()),
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
rows.collect()
|
rows.collect()
|
||||||
@@ -592,7 +686,7 @@ pub(crate) fn resolve_playable(
|
|||||||
id,
|
id,
|
||||||
PlayTrack {
|
PlayTrack {
|
||||||
id: Some(id.to_string()),
|
id: Some(id.to_string()),
|
||||||
path: PathBuf::from(r.get::<_, String>(1)?),
|
path: Some(PathBuf::from(r.get::<_, String>(1)?)),
|
||||||
title: r.get(2)?,
|
title: r.get(2)?,
|
||||||
artist: r.get(3)?,
|
artist: r.get(3)?,
|
||||||
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
|
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
|
||||||
@@ -611,12 +705,13 @@ pub(crate) fn resolve_playable(
|
|||||||
let (rows, from) = album_tracks(id.parse::<i64>().ok()?).ok()?;
|
let (rows, from) = album_tracks(id.parse::<i64>().ok()?).ok()?;
|
||||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
|
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
|
||||||
}
|
}
|
||||||
// A playlist plays the entries this process can: its local files and the
|
// A playlist plays EVERY row, in order, exactly as the page would: the
|
||||||
// streamed rows whose cache copy is whole, in order, skipping the ones
|
// local files and whole cache copies from their files, and the rows
|
||||||
// that need the resolver. Silently dropping those is the right call
|
// whose bytes are not here found on the network when the queue reaches
|
||||||
// here: the row that led here already said "4 of 9 here", so the gap was
|
// them — the remembered peer file first, else a search — with the next
|
||||||
// declared before the tap rather than discovered after it.
|
// few fetched ahead (`headless.rs`). Only a row the page itself could
|
||||||
let playlist_here = |playlist_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> {
|
// not play (`unresolved`, no title) is left out.
|
||||||
|
let playlist_rows = |playlist_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> {
|
||||||
let name: String = conn
|
let name: String = conn
|
||||||
.query_row("SELECT name FROM playlists WHERE id = ?1", [playlist_id], |r| r.get(0))
|
.query_row("SELECT name FROM playlists WHERE id = ?1", [playlist_id], |r| r.get(0))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -641,11 +736,41 @@ pub(crate) fn resolve_playable(
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in rows.flatten() {
|
for row in rows.flatten() {
|
||||||
let (id, title, artist, album, duration, path, art, kind, track_id) = row;
|
let (id, title, artist, album, duration, path, art, kind, track_id) = row;
|
||||||
let Some((file, stream)) =
|
if title.trim().is_empty() || !(kind == "local" || kind == "stream") {
|
||||||
row_file(dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title)
|
|
||||||
else {
|
|
||||||
continue;
|
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((
|
out.push((
|
||||||
id,
|
id,
|
||||||
PlayTrack {
|
PlayTrack {
|
||||||
@@ -663,7 +788,7 @@ pub(crate) fn resolve_playable(
|
|||||||
Ok((out, name))
|
Ok((out, name))
|
||||||
};
|
};
|
||||||
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
|
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
|
||||||
let (rows, from) = playlist_here(id.parse::<i64>().ok()?).ok()?;
|
let (rows, from) = playlist_rows(id.parse::<i64>().ok()?).ok()?;
|
||||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
|
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
|
||||||
}
|
}
|
||||||
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
|
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
|
||||||
@@ -675,10 +800,9 @@ pub(crate) fn resolve_playable(
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.ok()?;
|
.ok()?;
|
||||||
let (rows, from) = playlist_here(playlist_id).ok()?;
|
let (rows, from) = playlist_rows(playlist_id).ok()?;
|
||||||
// The tapped row itself must be playable here, or the tap means a
|
// The tapped row is where the queue starts — the one under the
|
||||||
// hand-off — starting the playlist from a different row than the one
|
// driver's finger, never a neighbour of it.
|
||||||
// under the driver's finger would be a lie.
|
|
||||||
let idx = rows.iter().position(|(i, _)| *i == item_id)?;
|
let idx = rows.iter().position(|(i, _)| *i == item_id)?;
|
||||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from));
|
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from));
|
||||||
}
|
}
|
||||||
@@ -836,10 +960,12 @@ extern "C" {
|
|||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
|
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
|
||||||
mut env: JNIEnv,
|
mut env: JNIEnv,
|
||||||
_this: JObject,
|
this: JObject,
|
||||||
data_dir: JString,
|
data_dir: JString,
|
||||||
parent: JString,
|
parent: JString,
|
||||||
) -> 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
|
// 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.
|
// would have to be `mut` and then collide with the hand-back below.
|
||||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
let dir: Option<String> = 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]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
|
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
|
||||||
mut env: JNIEnv,
|
mut env: JNIEnv,
|
||||||
_this: JObject,
|
this: JObject,
|
||||||
data_dir: JString,
|
data_dir: JString,
|
||||||
media_id: JString,
|
media_id: JString,
|
||||||
start_ms: jni::sys::jlong,
|
start_ms: jni::sys::jlong,
|
||||||
) -> jstring {
|
) -> jstring {
|
||||||
|
crate::headless::remember_auto_native(&mut env, &this);
|
||||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||||
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
|
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
|
||||||
let out = match (dir, id) {
|
let out = match (dir, id) {
|
||||||
@@ -1052,9 +1179,10 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_shuffle(
|
|||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport(
|
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport(
|
||||||
mut env: JNIEnv,
|
mut env: JNIEnv,
|
||||||
_this: JObject,
|
this: JObject,
|
||||||
action: JString,
|
action: JString,
|
||||||
) {
|
) {
|
||||||
|
crate::headless::remember_auto_native(&mut env, &this);
|
||||||
let Some(a) = env.get_string(&action).ok().map(String::from) else {
|
let Some(a) = env.get_string(&action).ok().map(String::from) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
+51
-16
@@ -515,27 +515,62 @@ pub(crate) async fn login_inner(
|
|||||||
cfg.shared_folders = shares;
|
cfg.shared_folders = shares;
|
||||||
cfg.description = "Shared with SoulStream".into();
|
cfg.description = "Shared with SoulStream".into();
|
||||||
|
|
||||||
// The previous session's listener releases its port a moment after
|
// A car may already have signed this process in (headless_slsk.rs) to
|
||||||
// shutdown; retry briefly instead of failing with WSAEADDRINUSE.
|
// stream a row before the page existed. Take that session over rather
|
||||||
let (client, mut events) = {
|
// than logging in again — two logins from one address in quick
|
||||||
let mut attempt = 0;
|
// succession is what the server throttles — and read its events from
|
||||||
loop {
|
// here on; the fetch in flight keeps going on the same client.
|
||||||
match Client::connect(cfg.clone()).await {
|
#[cfg(target_os = "android")]
|
||||||
Ok(pair) => break pair,
|
let adopted = crate::headless_slsk::adopt().map(|(client, user)| {
|
||||||
Err(e) if attempt < 8 && e.to_string().contains("10048") => {
|
log::info!("soulseek: adopting the car's session as {user}");
|
||||||
attempt += 1;
|
(client, crate::headless_slsk::subscribe())
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
});
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let adopted: Option<(Client, tokio::sync::mpsc::UnboundedReceiver<Event>)> = 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::<PlayerState>())
|
||||||
|
{
|
||||||
|
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 {
|
if remember {
|
||||||
let mut save = stored.clone();
|
let mut save = stored.clone();
|
||||||
save.username = username.clone();
|
save.username = username.clone();
|
||||||
|
|||||||
@@ -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")]
|
#[cfg(target_os = "android")]
|
||||||
fn secrets_path() -> Option<PathBuf> {
|
fn secrets_path() -> Option<PathBuf> {
|
||||||
let dir = SECRETS_DIR.get()?;
|
let dir = SECRETS_DIR.get()?;
|
||||||
|
|||||||
@@ -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 (`<library>/.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<f64>,
|
||||||
|
/// 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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
track_id: Option<u64>,
|
||||||
|
/// 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<Option<(PathBuf, HashMap<String, Known>)>> = Mutex::new(None);
|
||||||
|
static QUEUE: Mutex<VecDeque<Job>> = Mutex::new(VecDeque::new());
|
||||||
|
static WORKING: Mutex<bool> = Mutex::new(false);
|
||||||
|
/// Behind an `Arc` because the shelf walk takes `self: &Arc<Self>`.
|
||||||
|
static CLIENT: OnceLock<std::sync::Arc<metadata::MetadataClient>> = 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<Option<Instant>> = Mutex::new(None);
|
||||||
|
const REACHABLE_FOR: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
fn lock<T>(m: &Mutex<T>) -> 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<PathBuf> {
|
||||||
|
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<R>(data_dir: &Path, f: impl FnOnce(&mut HashMap<String, Known>) -> 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<String, Known> = 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<String> {
|
||||||
|
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<String> = 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<f64>,
|
||||||
|
deep: bool,
|
||||||
|
) -> Option<(String, Option<u64>)> {
|
||||||
|
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<metadata::CatalogTrack> = 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<u64>) {
|
||||||
|
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<String> = 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<usize> = 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<u32>,
|
||||||
|
) -> 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<String> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+730
-157
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,436 @@
|
|||||||
|
//! A Soulseek session for a process with no page — so a row the phone does
|
||||||
|
//! not hold yet can be found and streamed from the car.
|
||||||
|
//!
|
||||||
|
//! "Needs the app" was the honest label for what the cold service could not
|
||||||
|
//! do: the resolver and the Soulseek session live in the page, and a car
|
||||||
|
//! that starts the app cold has no page. Kyle's answer to that label was the
|
||||||
|
//! right one — a tap should find a copy and play it, in the background —
|
||||||
|
//! and this is the smallest thing that does it: the stored account signs
|
||||||
|
//! in from here, one search, the best copy by rules close to the page's
|
||||||
|
//! (`src/lib/sources.js` songCandidates), a download into the same stream
|
||||||
|
//! cache the page uses, and the engine plays the bytes as they land, the
|
||||||
|
//! way `play_stream` does for the page.
|
||||||
|
//!
|
||||||
|
//! **One session per process, and the page adopts it.** When the app opens
|
||||||
|
//! over a car session, `commands::login_inner` takes this client instead of
|
||||||
|
//! logging in again (two logins from one address in quick succession is
|
||||||
|
//! what the server throttles), and every event from here on is forwarded to
|
||||||
|
//! the page's own pump through [`subscribe`]. The download in flight keeps
|
||||||
|
//! going: dropping a `Client` handle does not tear the connection down.
|
||||||
|
//!
|
||||||
|
//! Its own tokio runtime, because there is no Tauri one in a process the
|
||||||
|
//! car started; the two coexist when the app comes up.
|
||||||
|
|
||||||
|
use player::StreamProgress;
|
||||||
|
use soulseek_client::{Client, Config, DownloadState, Event};
|
||||||
|
use soulseek_proto::types::SearchResponse;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::auto_browse::warn;
|
||||||
|
|
||||||
|
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
|
||||||
|
/// The handle, until the page adopts it.
|
||||||
|
static CLIENT: Mutex<Option<Client>> = Mutex::new(None);
|
||||||
|
/// Signed in, from this process's point of view.
|
||||||
|
static CONNECTED: Mutex<Option<String>> = Mutex::new(None);
|
||||||
|
/// token → the responses that came back for it.
|
||||||
|
static SEARCHES: Mutex<Vec<(u32, Vec<SearchResponse>)>> = Mutex::new(Vec::new());
|
||||||
|
/// download id → the progress the engine is reading.
|
||||||
|
static DOWNLOADS: Mutex<Vec<(u64, Arc<StreamProgress>)>> = Mutex::new(Vec::new());
|
||||||
|
/// The server's excluded search phrases, lowercased — a query containing
|
||||||
|
/// one returns nothing network-wide.
|
||||||
|
static EXCLUDED: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||||
|
/// Where the page's pump listens once it exists.
|
||||||
|
static SUBSCRIBER: Mutex<Option<mpsc::UnboundedSender<Event>>> = Mutex::new(None);
|
||||||
|
|
||||||
|
pub(crate) fn rt() -> &'static tokio::runtime::Runtime {
|
||||||
|
RT.get_or_init(|| {
|
||||||
|
tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.thread_name("slsk-cold")
|
||||||
|
.build()
|
||||||
|
.expect("a tokio runtime for the cold Soulseek session")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||||
|
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once this process has signed in (and the page has not yet adopted
|
||||||
|
/// the session — after adoption the page's own state is the truth).
|
||||||
|
pub fn connected() -> bool {
|
||||||
|
lock(&CONNECTED).is_some() && lock(&CLIENT).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The library root, the way auto_browse derives it: `download_dir` from
|
||||||
|
/// config.json, else `<data_dir>/Library`.
|
||||||
|
fn library_dir(data_dir: &Path) -> PathBuf {
|
||||||
|
std::fs::read_to_string(data_dir.join("config.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||||
|
.and_then(|v| v.get("download_dir").and_then(|d| d.as_str()).map(str::to_owned))
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map_or_else(|| data_dir.join("Library"), PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign in with the stored account, once. Blocks the caller — a car tap
|
||||||
|
/// arrives on a worker thread that may wait — for up to ~20 s.
|
||||||
|
pub fn ensure_session(data_dir: &Path) -> Result<(), String> {
|
||||||
|
if connected() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
crate::config::remember_secrets_dir_path(data_dir);
|
||||||
|
let cfg_json = std::fs::read_to_string(data_dir.join("config.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let username = cfg_json
|
||||||
|
.get("username")
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.filter(|u| !u.is_empty())
|
||||||
|
.ok_or("no Soulseek account on this phone")?;
|
||||||
|
let password = crate::config::load_password(&username)
|
||||||
|
.ok_or("the Soulseek account is not signed in on this phone")?;
|
||||||
|
|
||||||
|
let mut cfg = Config::default();
|
||||||
|
if let Some(p) = cfg_json.get("listen_port").and_then(|p| p.as_u64()) {
|
||||||
|
cfg.listen_port = p as u16;
|
||||||
|
}
|
||||||
|
let downloads = library_dir(data_dir);
|
||||||
|
cfg.downloads_dir = downloads.clone();
|
||||||
|
cfg.shared_folders = vec![downloads];
|
||||||
|
cfg.description = "Shared with SoulStream".into();
|
||||||
|
|
||||||
|
let (client, events) = rt()
|
||||||
|
.block_on(async {
|
||||||
|
let (client, events) =
|
||||||
|
tokio::time::timeout(Duration::from_secs(15), Client::connect(cfg))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "could not reach the Soulseek server in time".to_string())?
|
||||||
|
.map_err(|e| format!("could not reach the Soulseek server: {e}"))?;
|
||||||
|
tokio::time::timeout(Duration::from_secs(15), client.login(&username, &password))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Soulseek did not answer the sign-in in time".to_string())?
|
||||||
|
.map_err(|e| format!("Soulseek refused the sign-in: {e}"))?;
|
||||||
|
Ok::<_, String>((client, events))
|
||||||
|
})?;
|
||||||
|
warn(&format!("cold soulseek: signed in as {username}"));
|
||||||
|
*lock(&CLIENT) = Some(client);
|
||||||
|
*lock(&CONNECTED) = Some(username);
|
||||||
|
rt().spawn(consume(events));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one consumer of the session's events: search replies and download
|
||||||
|
/// progress land in the tables above, and everything is forwarded to the
|
||||||
|
/// page's pump once one has subscribed.
|
||||||
|
async fn consume(mut events: mpsc::UnboundedReceiver<Event>) {
|
||||||
|
while let Some(ev) = events.recv().await {
|
||||||
|
match &ev {
|
||||||
|
Event::SearchResult { token, response } => {
|
||||||
|
let mut s = lock(&SEARCHES);
|
||||||
|
if let Some((_, list)) = s.iter_mut().find(|(t, _)| t == token) {
|
||||||
|
list.push(response.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Download(u) => {
|
||||||
|
let progress = lock(&DOWNLOADS)
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _)| *id == u.id)
|
||||||
|
.map(|(_, p)| p.clone());
|
||||||
|
if let Some(p) = progress {
|
||||||
|
let (done, failed) = match &u.state {
|
||||||
|
DownloadState::Complete { .. } => (true, None),
|
||||||
|
DownloadState::Failed { reason } => (false, Some(reason.clone())),
|
||||||
|
DownloadState::Cancelled => (false, Some("stream cancelled".to_string())),
|
||||||
|
_ => (false, None),
|
||||||
|
};
|
||||||
|
p.update(u.received, done, failed);
|
||||||
|
if done {
|
||||||
|
lock(&DOWNLOADS).retain(|(id, _)| *id != u.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ExcludedSearchPhrases(p) => {
|
||||||
|
*lock(&EXCLUDED) = p.iter().map(|s| s.to_lowercase()).collect();
|
||||||
|
}
|
||||||
|
Event::Disconnected { reason } => {
|
||||||
|
warn(&format!("cold soulseek: disconnected ({reason})"));
|
||||||
|
*lock(&CONNECTED) = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(tx) = lock(&SUBSCRIBER).as_ref() {
|
||||||
|
let _ = tx.send(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page taking the session over: the client handle, and the account.
|
||||||
|
/// `None` when there is nothing to adopt (the page then signs in itself).
|
||||||
|
pub fn adopt() -> Option<(Client, String)> {
|
||||||
|
let client = lock(&CLIENT).take()?;
|
||||||
|
let user = lock(&CONNECTED).clone()?;
|
||||||
|
Some((client, user))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the page's pump reads this session's events from after adoption.
|
||||||
|
pub fn subscribe() -> mpsc::UnboundedReceiver<Event> {
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
*lock(&SUBSCRIBER) = Some(tx);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── finding a copy ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/// Streamable, the page's list (RESOLVER_STREAMABLE).
|
||||||
|
const STREAMABLE: [&str; 8] = ["flac", "mp3", "m4a", "ogg", "opus", "wav", "aiff", "aif"];
|
||||||
|
const LOSSLESS: [&str; 4] = ["flac", "wav", "aiff", "aif"];
|
||||||
|
|
||||||
|
/// Lowercased, punctuation folded to spaces, whitespace collapsed — the
|
||||||
|
/// page's normText, near enough for a contains() test.
|
||||||
|
fn norm(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut space = true;
|
||||||
|
for c in s.chars().flat_map(|c| c.to_lowercase()) {
|
||||||
|
if c.is_alphanumeric() {
|
||||||
|
out.push(c);
|
||||||
|
space = false;
|
||||||
|
} else if !space {
|
||||||
|
out.push(' ');
|
||||||
|
space = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.trim().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page's `simplifyTitle`, for the query: the title up to the first
|
||||||
|
/// `; : ( [`, and its first six words. The full title still has to be in
|
||||||
|
/// the filename.
|
||||||
|
fn simplify(title: &str) -> String {
|
||||||
|
let first = title
|
||||||
|
.split(|c| c == ';' || c == ':' || c == '(' || c == '[')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let base = if first.is_empty() { title } else { first };
|
||||||
|
let words: Vec<&str> = base.split_whitespace().take(6).collect();
|
||||||
|
let out = words.join(" ");
|
||||||
|
if out.is_empty() {
|
||||||
|
title.to_owned()
|
||||||
|
} else {
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Pick {
|
||||||
|
pub username: String,
|
||||||
|
pub path: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub score: i64,
|
||||||
|
pub slot: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The best copies of a track on the network, best first. With
|
||||||
|
/// `require_identity` — the page's `requireIdentityEvidence`, for a rung
|
||||||
|
/// that searched by title alone — the artist has to appear somewhere in
|
||||||
|
/// the peer's path, or the copy is another artist's song of that name.
|
||||||
|
fn candidates(
|
||||||
|
responses: &[SearchResponse],
|
||||||
|
title: &str,
|
||||||
|
artist: &str,
|
||||||
|
expect_secs: Option<f64>,
|
||||||
|
require_identity: bool,
|
||||||
|
) -> Vec<Pick> {
|
||||||
|
let want = norm(title);
|
||||||
|
let want_short = norm(&simplify(title));
|
||||||
|
let artist_key = norm(artist);
|
||||||
|
let mut out: Vec<Pick> = Vec::new();
|
||||||
|
for r in responses {
|
||||||
|
for f in &r.files {
|
||||||
|
let name = f.filename.rsplit('\\').next().unwrap_or(&f.filename);
|
||||||
|
let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name);
|
||||||
|
let nname = norm(stem);
|
||||||
|
if !(nname.contains(&want) || (!want_short.is_empty() && nname.contains(&want_short))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ext = f.extension.to_lowercase();
|
||||||
|
if !STREAMABLE.contains(&ext.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if f.size == 0 || f.size > 400 * 1024 * 1024 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bitrate = f.attributes.iter().find(|(k, _)| *k == 0).map(|(_, v)| *v);
|
||||||
|
let duration = f.attributes.iter().find(|(k, _)| *k == 1).map(|(_, v)| *v as f64);
|
||||||
|
if let (Some(want), Some(got)) = (expect_secs, duration) {
|
||||||
|
// the page's durationMismatch: a copy that is not the same
|
||||||
|
// recording — a radio edit, a live take — is not this song
|
||||||
|
if (want - got).abs() > (want * 0.08).max(8.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lossless = LOSSLESS.contains(&ext.as_str());
|
||||||
|
let mut score: i64 = if lossless { 3000 } else { 1000 };
|
||||||
|
if let Some(b) = bitrate {
|
||||||
|
score += (b.min(400) as i64) * 2;
|
||||||
|
}
|
||||||
|
if f.size < 800 * 1024 {
|
||||||
|
score -= 2000; // a snippet, not the song
|
||||||
|
}
|
||||||
|
let path_n = norm(&f.filename);
|
||||||
|
let artist_evidence = !artist_key.is_empty() && path_n.contains(&artist_key);
|
||||||
|
if require_identity && !artist_key.is_empty() && !artist_evidence {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if artist_evidence {
|
||||||
|
score += 400;
|
||||||
|
}
|
||||||
|
if nname.ends_with(&want) {
|
||||||
|
score += 250;
|
||||||
|
}
|
||||||
|
if r.queue_length > 0 {
|
||||||
|
score -= (r.queue_length.min(50) as i64) * 20;
|
||||||
|
}
|
||||||
|
out.push(Pick {
|
||||||
|
username: r.username.clone(),
|
||||||
|
path: f.filename.clone(),
|
||||||
|
size: f.size,
|
||||||
|
duration,
|
||||||
|
score,
|
||||||
|
slot: r.has_free_upload_slot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| b.score.cmp(&a.score));
|
||||||
|
// Streaming needs bytes NOW: peers with a free slot first, best within
|
||||||
|
// each half (the page's rule, learned on "Da Funk" 2026-08-03).
|
||||||
|
let (free, busy): (Vec<Pick>, Vec<Pick>) = out.into_iter().partition(|p| p.slot);
|
||||||
|
free.into_iter().chain(busy).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_excluded(query: &str) -> String {
|
||||||
|
let excluded = lock(&EXCLUDED).clone();
|
||||||
|
let mut words: Vec<&str> = query.split_whitespace().collect();
|
||||||
|
for phrase in &excluded {
|
||||||
|
let lower = query.to_lowercase();
|
||||||
|
if lower.contains(phrase.as_str()) {
|
||||||
|
let bad: Vec<&str> = phrase.split_whitespace().collect();
|
||||||
|
words.retain(|w| !bad.iter().any(|b| w.eq_ignore_ascii_case(b)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
words.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every word of the artist still in the query, after the server's
|
||||||
|
/// excluded phrases came out of it.
|
||||||
|
fn keeps_artist(query: &str, artist: &str) -> bool {
|
||||||
|
let q = norm(query);
|
||||||
|
let words: Vec<&str> = q.split(' ').collect();
|
||||||
|
norm(artist)
|
||||||
|
.split(' ')
|
||||||
|
.filter(|w| !w.is_empty())
|
||||||
|
.all(|w| words.contains(&w))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page's ladder for a song (`streamSong`): artist + simplified title,
|
||||||
|
/// then the simplified title alone. Each rung is one search, a few seconds
|
||||||
|
/// of replies, the best copies; the first rung that produces any wins. A
|
||||||
|
/// rung without the artist in it — the second, or the first once the
|
||||||
|
/// server's excluded phrases took the artist out — matches by title
|
||||||
|
/// alone, so the artist has to appear in the peer's path instead.
|
||||||
|
pub fn find(title: &str, artist: &str, expect_secs: Option<f64>) -> Result<Vec<Pick>, String> {
|
||||||
|
let simple = simplify(title);
|
||||||
|
let rungs = [format!("{artist} {simple}"), simple.clone()];
|
||||||
|
for raw in rungs {
|
||||||
|
let stripped = strip_excluded(&raw);
|
||||||
|
let query = if stripped.trim().is_empty() { raw.clone() } else { stripped };
|
||||||
|
if query.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let require_identity = !keeps_artist(&query, artist);
|
||||||
|
// the client may spawn onto its runtime from any call; this thread
|
||||||
|
// is not on it (see `fetch`)
|
||||||
|
let _rt = rt().enter();
|
||||||
|
let token = {
|
||||||
|
let guard = lock(&CLIENT);
|
||||||
|
let client = guard.as_ref().ok_or("not signed in to Soulseek")?;
|
||||||
|
client.search(&query).map_err(|e| e.to_string())?
|
||||||
|
};
|
||||||
|
lock(&SEARCHES).push((token, Vec::new()));
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut best: Vec<Pick> = Vec::new();
|
||||||
|
let mut files = 0usize;
|
||||||
|
// Up to eight seconds; done early once a dozen usable copies are in.
|
||||||
|
while started.elapsed() < Duration::from_secs(8) {
|
||||||
|
std::thread::sleep(Duration::from_millis(400));
|
||||||
|
let responses = lock(&SEARCHES)
|
||||||
|
.iter()
|
||||||
|
.find(|(t, _)| *t == token)
|
||||||
|
.map(|(_, r)| r.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
files = responses.iter().map(|r| r.files.len()).sum();
|
||||||
|
best = candidates(&responses, title, artist, expect_secs, require_identity);
|
||||||
|
if best.len() >= 12 && started.elapsed() > Duration::from_millis(2500) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(client) = lock(&CLIENT).as_ref() {
|
||||||
|
client.stop_search(token);
|
||||||
|
}
|
||||||
|
lock(&SEARCHES).retain(|(t, _)| *t != token);
|
||||||
|
warn(&format!("cold soulseek: '{query}' → {files} files, {} copies", best.len()));
|
||||||
|
if !best.is_empty() {
|
||||||
|
return Ok(best);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start fetching a peer's copy into the stream cache. Returns the cache
|
||||||
|
/// path, the partial path the bytes land in, the progress the engine reads,
|
||||||
|
/// and the download id.
|
||||||
|
pub fn fetch(
|
||||||
|
data_dir: &Path,
|
||||||
|
username: &str,
|
||||||
|
path: &str,
|
||||||
|
size: u64,
|
||||||
|
) -> Result<(PathBuf, PathBuf, Arc<StreamProgress>, u64), String> {
|
||||||
|
let dir = crate::playback::cache_dir_in(data_dir);
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
let cache = crate::playback::cache_file_in(&dir, username, path);
|
||||||
|
let partial = crate::playback::partial_path(&cache);
|
||||||
|
let _ = std::fs::remove_file(&partial);
|
||||||
|
let progress = StreamProgress::new();
|
||||||
|
let id = {
|
||||||
|
// `download_to` spawns the transfer task with `tokio::spawn`, which
|
||||||
|
// needs a runtime on the CALLING thread — and the caller here is
|
||||||
|
// the engine's resolver thread, not a Tauri command. Without this
|
||||||
|
// the spawn panics, the thread dies without a word (a cold process
|
||||||
|
// has no stderr in logcat), and the card sits on BUFFERING for as
|
||||||
|
// long as anyone looks (2026-09-18, the first cold fetch).
|
||||||
|
let _rt = rt().enter();
|
||||||
|
let guard = lock(&CLIENT);
|
||||||
|
let client = guard.as_ref().ok_or("not signed in to Soulseek")?;
|
||||||
|
client.download_to(username, path, size, cache.clone())
|
||||||
|
};
|
||||||
|
lock(&DOWNLOADS).push((id, progress.clone()));
|
||||||
|
Ok((cache, partial, progress, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give up on a fetch that never delivered.
|
||||||
|
pub fn cancel(id: u64) {
|
||||||
|
let _rt = rt().enter();
|
||||||
|
if let Some(client) = lock(&CLIENT).as_ref() {
|
||||||
|
client.cancel_download(id);
|
||||||
|
}
|
||||||
|
lock(&DOWNLOADS).retain(|(i, _)| *i != id);
|
||||||
|
}
|
||||||
@@ -11,6 +11,15 @@ mod commands;
|
|||||||
/// process that has no Activity — and so there is never a second one.
|
/// process that has no Activity — and so there is never a second one.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod headless;
|
mod headless;
|
||||||
|
/// A Soulseek session for a process with no page, so a row the phone does
|
||||||
|
/// not hold yet is found and streamed from the car — and adopted by the
|
||||||
|
/// page when the app opens over it.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod headless_slsk;
|
||||||
|
/// Covers for rows the catalog was never asked about, from the process
|
||||||
|
/// with no page to ask — the page's cover walk, in Rust, for the car.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod cover_hunt;
|
||||||
mod community;
|
mod community;
|
||||||
mod config;
|
mod config;
|
||||||
mod cover_logo;
|
mod cover_logo;
|
||||||
@@ -121,6 +130,13 @@ pub fn run() {
|
|||||||
thumbbar::init(app.handle());
|
thumbbar::init(app.handle());
|
||||||
playback::init(app.handle());
|
playback::init(app.handle());
|
||||||
playback::prune_cache(app.handle());
|
playback::prune_cache(app.handle());
|
||||||
|
// Hover previews are the desktop's (ANDROID.md, "Off by design"),
|
||||||
|
// and their init opens TWO more output streams — on the phone
|
||||||
|
// that was three live AAudio streams per launch for one that
|
||||||
|
// ever sounds (seen 2026-09-18 in `dumpsys audio`). The phone
|
||||||
|
// never calls a preview command, so nothing here misses the
|
||||||
|
// state.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
preview::init(app.handle());
|
preview::init(app.handle());
|
||||||
loudness::init(app.handle());
|
loudness::init(app.handle());
|
||||||
diagnostics::init(app.handle());
|
diagnostics::init(app.handle());
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ pub fn init(app: &tauri::AppHandle) {
|
|||||||
plays next and two things advancing one engine would skip a track each
|
plays next and two things advancing one engine would skip a track each
|
||||||
time. Nothing about the desktop changes. */
|
time. Nothing about the desktop changes. */
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
let (player, events, handover) = {
|
let (player, events, handover, first) = {
|
||||||
if let Ok(dir) = crate::paths::app_data(app) {
|
if let Ok(dir) = crate::paths::app_data(app) {
|
||||||
crate::headless::set_data_dir(&dir);
|
crate::headless::set_data_dir(&dir);
|
||||||
}
|
}
|
||||||
@@ -221,12 +221,33 @@ pub fn init(app: &tauri::AppHandle) {
|
|||||||
// older session of its own over live audio.
|
// older session of its own over live audio.
|
||||||
let handover = if audio.driving() { audio.resume_point() } else { None };
|
let handover = if audio.driving() { audio.resume_point() } else { None };
|
||||||
audio.clear_queue();
|
audio.clear_queue();
|
||||||
(audio.player.clone(), rx, handover)
|
// What the engine is doing RIGHT NOW, for a page that is about to
|
||||||
|
// ask. The snapshot below is otherwise built from the events that
|
||||||
|
// arrive after this subscription — and an engine a car started was
|
||||||
|
// playing before there was anyone to tell, so no Playing ever
|
||||||
|
// arrives: the page read "idle", called the handover a paused
|
||||||
|
// session that needed re-sourcing, and pushed PAUSED to the card
|
||||||
|
// over audio that kept going (2026-09-18, Strictly Acid at 21 s).
|
||||||
|
let (position, sounding) = audio.progress();
|
||||||
|
let first = PlaybackSnapshot {
|
||||||
|
status: if sounding { "playing".into() } else { "idle".into() },
|
||||||
|
position: if sounding { position } else { 0.0 },
|
||||||
|
duration: None,
|
||||||
|
};
|
||||||
|
(audio.player.clone(), rx, handover, first)
|
||||||
};
|
};
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
let (player, events, handover) = {
|
let (player, events, handover, first) = {
|
||||||
let (p, e) = Player::spawn_with_device(output_device);
|
let (p, e) = Player::spawn_with_device(output_device);
|
||||||
(p, e, None)
|
(
|
||||||
|
p,
|
||||||
|
e,
|
||||||
|
None,
|
||||||
|
PlaybackSnapshot {
|
||||||
|
status: "idle".into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
app.manage(PlayerState {
|
app.manage(PlayerState {
|
||||||
handover: Mutex::new(handover),
|
handover: Mutex::new(handover),
|
||||||
@@ -238,10 +259,7 @@ pub fn init(app: &tauri::AppHandle) {
|
|||||||
waveform_jobs: Mutex::new(HashMap::new()),
|
waveform_jobs: Mutex::new(HashMap::new()),
|
||||||
preloads: Mutex::new(HashMap::new()),
|
preloads: Mutex::new(HashMap::new()),
|
||||||
deferred_saves: Mutex::new(HashMap::new()),
|
deferred_saves: Mutex::new(HashMap::new()),
|
||||||
last_transport: Mutex::new(PlaybackSnapshot {
|
last_transport: Mutex::new(first),
|
||||||
status: "idle".into(),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let handle = app.clone();
|
let handle = app.clone();
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// The artist-identity pass, as pure functions: what MusicBrainz (and the
|
||||||
|
// MB-verified Apple catalogue) add to a Deezer artist page, and how those
|
||||||
|
// extras are merged into it — the supplemented releases, the authoritative
|
||||||
|
// release types, the guest-appearance and co-tenant arbitration.
|
||||||
|
//
|
||||||
|
// It lived inside state.svelte.js, which only the desktop loads; the phone
|
||||||
|
// (src/mobile/catalog.svelte.js) built its artist pages from the Deezer
|
||||||
|
// list alone and My Bloody Valentine had four releases there. Both surfaces
|
||||||
|
// read this ONE implementation now — a second copy would only guarantee
|
||||||
|
// they disagree in new ways (the Now Playing panel learned that first).
|
||||||
|
import { asciiFold, normText } from "./sources.js";
|
||||||
|
|
||||||
|
export const artistIdentityKey = (value) => {
|
||||||
|
const text = typeof value === "string" ? value : value?.name;
|
||||||
|
return (text ?? "")
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/\p{M}/gu, "")
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Accept an exact artist credit or an explicit collaboration credit containing
|
||||||
|
* that exact artist. A mere prefix is never enough ("Crazy P" must not match
|
||||||
|
* "Crazy Penis"). Missing credit metadata is retained because there is no
|
||||||
|
* identity signal to evaluate. */
|
||||||
|
export function recordBelongsToArtist(record, artistName) {
|
||||||
|
const credited = typeof record?.artist === "string"
|
||||||
|
? record.artist
|
||||||
|
: record?.artist?.name;
|
||||||
|
if (!credited) return true;
|
||||||
|
const expected = artistIdentityKey(artistName);
|
||||||
|
if (!expected) return true;
|
||||||
|
if (artistIdentityKey(credited) === expected) return true;
|
||||||
|
return credited
|
||||||
|
.split(/\s+(?:feat(?:uring)?\.?|ft\.?|with|vs\.?|x)\s+|\s*(?:&|,|×)\s*/i)
|
||||||
|
.some((part) => artistIdentityKey(part) === expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function artistScopedRecords(records, artistName) {
|
||||||
|
return Array.isArray(records)
|
||||||
|
? records.filter((record) => recordBelongsToArtist(record, artistName))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The identity merge itself, as a pure function of the page and the identity
|
||||||
|
* payload.
|
||||||
|
*
|
||||||
|
* Extracted so the Now Playing panel can run the SAME merge the artist page
|
||||||
|
* runs. It used to live inside `applyArtistIdentity`, which only the artist
|
||||||
|
* page calls — so the panel showed the raw Deezer list: My Bloody Valentine
|
||||||
|
* appeared with four releases because Loveless and Isn't Anything exist only
|
||||||
|
* as MusicBrainz supplements, and Weezer filed differently because the panel
|
||||||
|
* never received `type_hints` either. Both surfaces read one implementation
|
||||||
|
* now; a second copy would only guarantee they disagree in new ways.
|
||||||
|
*/
|
||||||
|
export function mergeArtistIdentity(current, identity) {
|
||||||
|
// MB identity extras are always albums, so only rows that could BE
|
||||||
|
// that album may block one: a same-title SINGLE is a different
|
||||||
|
// release (the "Phantom Island" shape — a 2024 single and a 2025
|
||||||
|
// album), and letting it occupy the title made the real album vanish
|
||||||
|
// from the page.
|
||||||
|
const seen = new Set(
|
||||||
|
(current.albums ?? [])
|
||||||
|
.filter((a) => a.record_type !== "single")
|
||||||
|
.map((a) => normText(a.title)),
|
||||||
|
);
|
||||||
|
const identityAlbums = artistScopedRecords(identity?.albums, current.artist?.name);
|
||||||
|
// An MB row whose title is a PREFIX of an existing row's full title
|
||||||
|
// is the same work Deezer carries as a qualified variant —
|
||||||
|
// "Wonderful Crazy Night" beside the page's "Wonderful Crazy Night
|
||||||
|
// (Deluxe)". Injecting it adds a second card with no track count.
|
||||||
|
// Sequel tails ("Greatest Hits" vs "Greatest Hits II") stay
|
||||||
|
// distinct: a bare numeral/volume marker never counts as a variant
|
||||||
|
// qualifier. normText already strips brackets, so compare against
|
||||||
|
// the raw title normalized without stripping.
|
||||||
|
const rawNorm = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
// Same single-typed exemption as `seen`: only album-like rows may
|
||||||
|
// claim to be a variant of an MB album.
|
||||||
|
const existingRaw = (current.albums ?? [])
|
||||||
|
.filter((a) => a.record_type !== "single")
|
||||||
|
.map((a) => rawNorm(a.title));
|
||||||
|
const sequelTail = (tail) =>
|
||||||
|
/^(?:i{1,3}|iv|v|vi{0,3}|ix|x|\d{1,2}|vol(?:ume)?\s*\d*|pt\s*\d*|part\s*\d*)$/.test(
|
||||||
|
tail.trim(),
|
||||||
|
);
|
||||||
|
const coveredByVariant = (title) => {
|
||||||
|
const base = rawNorm(title);
|
||||||
|
if (base.length < 8) return false;
|
||||||
|
return existingRaw.some(
|
||||||
|
(t) =>
|
||||||
|
t.length > base.length
|
||||||
|
&& t.startsWith(base)
|
||||||
|
&& !sequelTail(t.slice(base.length)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// `normText` DELETES bracket contents, which is right for peer file
|
||||||
|
// matching and wrong here: MusicBrainz writes a qualifier after a colon
|
||||||
|
// where Deezer puts it in parentheses, so "World War 3 (Lean)" reduced
|
||||||
|
// to "worldwar3" while MB's "World War 3: Lean" reduced to
|
||||||
|
// "worldwar3lean" and the two never collided. The result was a second
|
||||||
|
// card for a record already on the page — and once mixtapes had a tab
|
||||||
|
// of their own, the same work appeared in Albums AND Mixtapes.
|
||||||
|
//
|
||||||
|
// This key keeps the qualifier and instead folds the three things the
|
||||||
|
// providers genuinely disagree about: the separator, the words
|
||||||
|
// introducing a number, and how that number is written. Measured on
|
||||||
|
// Gucci Mane it collapses ten such pairs, among them
|
||||||
|
// "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part Five",
|
||||||
|
// "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" /
|
||||||
|
// "Trap House Four".
|
||||||
|
//
|
||||||
|
// Single-letter roman numerals are deliberately NOT folded: "X", "V"
|
||||||
|
// and "I" are real album titles, and reading them as 10, 5 and 1 would
|
||||||
|
// merge unrelated records.
|
||||||
|
const ORDINALS = {
|
||||||
|
one: "1", two: "2", three: "3", four: "4", five: "5",
|
||||||
|
six: "6", seven: "7", eight: "8", nine: "9", ten: "10",
|
||||||
|
ii: "2", iii: "3", iv: "4", vi: "6", vii: "7", viii: "8", ix: "9",
|
||||||
|
};
|
||||||
|
const releaseKey = (title) =>
|
||||||
|
asciiFold(title ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[‘’']/g, "")
|
||||||
|
.replace(/\b(?:part|pt|vol|volume)\b\.?/g, " ")
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.split(" ")
|
||||||
|
.map((word) => ORDINALS[word] ?? word)
|
||||||
|
.join("");
|
||||||
|
// Every album-like row already on the page, by that key, so a matched
|
||||||
|
// MB row can be recognised as the one on screen rather than injected
|
||||||
|
// beside it.
|
||||||
|
// Every row sharing a key, not just the first: a provider sometimes
|
||||||
|
// carries the same record twice under its own spellings ("Brick Factory
|
||||||
|
// 2" beside "Brick Factory, Vol. 2"). Typing only one of them would put
|
||||||
|
// one copy in Mixtapes and leave the other in Albums, which is a worse
|
||||||
|
// page than leaving both alone.
|
||||||
|
const existingByKey = new Map();
|
||||||
|
for (const album of current.albums ?? []) {
|
||||||
|
if (album.record_type === "single") continue;
|
||||||
|
const key = releaseKey(album.title);
|
||||||
|
if (!key) continue;
|
||||||
|
if (!existingByKey.has(key)) existingByKey.set(key, []);
|
||||||
|
existingByKey.get(key).push(album);
|
||||||
|
}
|
||||||
|
// A MusicBrainz mixtape that IS a row already on the page contributes
|
||||||
|
// its TYPE, not a card. The Deezer row wins the slot because it is the
|
||||||
|
// playable one — it carries a real album id and tracklist — and MB's
|
||||||
|
// Mixtape/Street verdict re-files it, exactly as `type_hints` re-files
|
||||||
|
// a mislabelled compilation. That is what moves "World War 3 (Lean)"
|
||||||
|
// out of Albums instead of leaving one copy in each tab.
|
||||||
|
const adoptedMixtapeIds = new Set();
|
||||||
|
const extra = identityAlbums.filter((a) => {
|
||||||
|
if (!a?.title) return false;
|
||||||
|
const twins = a.is_mixtape ? existingByKey.get(releaseKey(a.title)) : undefined;
|
||||||
|
if (twins?.length) {
|
||||||
|
for (const twin of twins) {
|
||||||
|
if (!twin.is_mixtape) adoptedMixtapeIds.add(twin.id);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
!seen.has(normText(a.title))
|
||||||
|
&& !existingByKey.has(releaseKey(a.title))
|
||||||
|
&& !coveredByVariant(a.title)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const socials = identity?.socials ?? [];
|
||||||
|
// MB's authoritative primary/secondary types. The view matches these
|
||||||
|
// to displayed releases by title and re-files any the provider
|
||||||
|
// mislabeled "album" (compilations, soundtracks…).
|
||||||
|
const typeHints = identity?.type_hints ?? [];
|
||||||
|
// Guest appearances, dropped only where MusicBrainz can arbitrate.
|
||||||
|
//
|
||||||
|
// Deezer's /artist/{id}/albums returns records the artist merely
|
||||||
|
// contributed to — four Rooby Jeantal lo-fi releases sat in Mid-Air
|
||||||
|
// Thief's discography that way. The backend flags them (`appears_on`:
|
||||||
|
// the release's primary artist is someone else), but that flag ALONE
|
||||||
|
// must never delete a row: Deezer marks every contributor "Main", so a
|
||||||
|
// guest spot looks exactly like a genuine joint album — Kanye West's
|
||||||
|
// "Watch the Throne" carries primary artist JAY-Z and would be wrongly
|
||||||
|
// erased from his page.
|
||||||
|
//
|
||||||
|
// MusicBrainz separates them, because it credits a real collaboration
|
||||||
|
// under BOTH artists: querying Kanye's own MBID returns "Watch the
|
||||||
|
// Throne", while Mid-Air Thief's returns only his two real albums. So a
|
||||||
|
// flagged row survives if MB credits it and is dropped if MB does not
|
||||||
|
// — and ONLY when MB actually answered for this artist. No MB albums
|
||||||
|
// means no arbiter, and every row is kept rather than guessed at, which
|
||||||
|
// keeps thin-coverage artists (the obscure end, where this matters
|
||||||
|
// most) from losing real releases.
|
||||||
|
//
|
||||||
|
// The title check alone is not enough, and Kanye is the proof. MB files
|
||||||
|
// a collaboration under both parties only when the credit NAMES both
|
||||||
|
// ("Watch the Throne"); when the collaboration is its own MB entity it
|
||||||
|
// does not, and neither VULTURES 1, VULTURES 2 nor KIDS SEE GHOSTS
|
||||||
|
// appears anywhere in Ye's 315 release groups. All three were being
|
||||||
|
// erased from his page by the very rule written to protect them.
|
||||||
|
// `owned_album_ids` is the backend's wider answer to the same question
|
||||||
|
// — MB release groups of every type plus the MB-verified Apple
|
||||||
|
// catalogue — so a real collaboration survives on evidence instead of
|
||||||
|
// on MusicBrainz happening to duplicate the credit.
|
||||||
|
//
|
||||||
|
// `foreign_album_ids` is the other half: releases that belong to a
|
||||||
|
// DIFFERENT act sharing this Deezer artist record. Deezer files several
|
||||||
|
// unrelated performers under one id (there is exactly one Deezer
|
||||||
|
// "Wand", and it holds an LA psych band, a Brazilian funk MC, an
|
||||||
|
// Indonesian act and four more), which no amount of search ranking or
|
||||||
|
// credit-role reading can undo. The backend only fills this in when it
|
||||||
|
// can NAME the co-tenant, so on an ordinary artist it is empty and the
|
||||||
|
// page is untouched.
|
||||||
|
const mbTitles = new Set(identityAlbums.map((a) => normText(a.title)));
|
||||||
|
const owned = new Set(identity?.owned_album_ids ?? []);
|
||||||
|
const foreign = new Set(identity?.foreign_album_ids ?? []);
|
||||||
|
const ownAlbums = (current.albums ?? [])
|
||||||
|
.filter((a) => {
|
||||||
|
if (foreign.has(a?.id)) return false;
|
||||||
|
if (!a?.appears_on) return true;
|
||||||
|
if (owned.has(a?.id)) return true;
|
||||||
|
// No MB albums = no arbiter = keep everything, so thin-coverage
|
||||||
|
// artists never lose real releases.
|
||||||
|
return identityAlbums.length ? mbTitles.has(normText(a.title)) : true;
|
||||||
|
})
|
||||||
|
// Rows MusicBrainz typed Mixtape/Street under a title Deezer spells
|
||||||
|
// differently. A new object rather than a mutation: `current.albums`
|
||||||
|
// is shared with the page replay cache and with whatever is already
|
||||||
|
// rendered, and this must not edit either in place.
|
||||||
|
.map((a) =>
|
||||||
|
adoptedMixtapeIds.has(a?.id) ? { ...a, is_mixtape: true } : a,
|
||||||
|
);
|
||||||
|
const merged = {
|
||||||
|
...current,
|
||||||
|
identity_merged: true,
|
||||||
|
// A fused record's Deezer PORTRAIT is untrustworthy for exactly the
|
||||||
|
// reason its releases are: it is whichever co-tenant uploaded last.
|
||||||
|
// Wand's is a different artist entirely, and `deezerPortraitMissing`
|
||||||
|
// cannot catch it — it is a real photograph, just not of this band.
|
||||||
|
fused_record: !!identity?.fused_record,
|
||||||
|
albums: extra.length
|
||||||
|
? [...ownAlbums, ...extra].sort((a, b) =>
|
||||||
|
(b.release_date ?? "").localeCompare(a.release_date ?? ""))
|
||||||
|
: ownAlbums,
|
||||||
|
socials: socials.length ? socials : current.socials,
|
||||||
|
typeHints: typeHints.length ? typeHints : current.typeHints,
|
||||||
|
};
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
+1
-236
@@ -28,6 +28,7 @@ import {
|
|||||||
libraryAlbumIn,
|
libraryAlbumIn,
|
||||||
libraryTrackIn,
|
libraryTrackIn,
|
||||||
} from "./sources.js";
|
} from "./sources.js";
|
||||||
|
import { artistIdentityKey, recordBelongsToArtist, artistScopedRecords, mergeArtistIdentity } from "./artistIdentity.js";
|
||||||
export { RESOLVER_AUDIO_QUALITY, sourcePrefs, refreshSourcePrefs, albumDisambiguator, setExcludedSearchPhrases, sanitizeSearchQuery, collectSources } from "./sources.js";
|
export { RESOLVER_AUDIO_QUALITY, sourcePrefs, refreshSourcePrefs, albumDisambiguator, setExcludedSearchPhrases, sanitizeSearchQuery, collectSources } from "./sources.js";
|
||||||
import { nameNorm, pickCatalogMatch } from "./catalogMatch.js";
|
import { nameNorm, pickCatalogMatch } from "./catalogMatch.js";
|
||||||
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
|
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
|
||||||
@@ -1434,38 +1435,6 @@ const sameAlbumId = (left, right) => {
|
|||||||
return !!leftKey && leftKey === albumCacheKey(right);
|
return !!leftKey && leftKey === albumCacheKey(right);
|
||||||
};
|
};
|
||||||
|
|
||||||
const artistIdentityKey = (value) => {
|
|
||||||
const text = typeof value === "string" ? value : value?.name;
|
|
||||||
return (text ?? "")
|
|
||||||
.normalize("NFKD")
|
|
||||||
.replace(/\p{M}/gu, "")
|
|
||||||
.toLocaleLowerCase()
|
|
||||||
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Accept an exact artist credit or an explicit collaboration credit containing
|
|
||||||
* that exact artist. A mere prefix is never enough ("Crazy P" must not match
|
|
||||||
* "Crazy Penis"). Missing credit metadata is retained because there is no
|
|
||||||
* identity signal to evaluate. */
|
|
||||||
function recordBelongsToArtist(record, artistName) {
|
|
||||||
const credited = typeof record?.artist === "string"
|
|
||||||
? record.artist
|
|
||||||
: record?.artist?.name;
|
|
||||||
if (!credited) return true;
|
|
||||||
const expected = artistIdentityKey(artistName);
|
|
||||||
if (!expected) return true;
|
|
||||||
if (artistIdentityKey(credited) === expected) return true;
|
|
||||||
return credited
|
|
||||||
.split(/\s+(?:feat(?:uring)?\.?|ft\.?|with|vs\.?|x)\s+|\s*(?:&|,|×)\s*/i)
|
|
||||||
.some((part) => artistIdentityKey(part) === expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
function artistScopedRecords(records, artistName) {
|
|
||||||
return Array.isArray(records)
|
|
||||||
? records.filter((record) => recordBelongsToArtist(record, artistName))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sanitize every staged/full artist payload before it enters a shared cache.
|
/** Sanitize every staged/full artist payload before it enters a shared cache.
|
||||||
* Backend endpoints already anchor on id; this cheap name-credit boundary is a
|
* Backend endpoints already anchor on id; this cheap name-credit boundary is a
|
||||||
* second line of defense for additive provider records such as Apple/MB data. */
|
* second line of defense for additive provider records such as Apple/MB data. */
|
||||||
@@ -2543,210 +2512,6 @@ async function applyArtistIdentity(artistId, active) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The identity merge itself, as a pure function of the page and the identity
|
|
||||||
* payload.
|
|
||||||
*
|
|
||||||
* Extracted so the Now Playing panel can run the SAME merge the artist page
|
|
||||||
* runs. It used to live inside `applyArtistIdentity`, which only the artist
|
|
||||||
* page calls — so the panel showed the raw Deezer list: My Bloody Valentine
|
|
||||||
* appeared with four releases because Loveless and Isn't Anything exist only
|
|
||||||
* as MusicBrainz supplements, and Weezer filed differently because the panel
|
|
||||||
* never received `type_hints` either. Both surfaces read one implementation
|
|
||||||
* now; a second copy would only guarantee they disagree in new ways.
|
|
||||||
*/
|
|
||||||
function mergeArtistIdentity(current, identity) {
|
|
||||||
// MB identity extras are always albums, so only rows that could BE
|
|
||||||
// that album may block one: a same-title SINGLE is a different
|
|
||||||
// release (the "Phantom Island" shape — a 2024 single and a 2025
|
|
||||||
// album), and letting it occupy the title made the real album vanish
|
|
||||||
// from the page.
|
|
||||||
const seen = new Set(
|
|
||||||
(current.albums ?? [])
|
|
||||||
.filter((a) => a.record_type !== "single")
|
|
||||||
.map((a) => normText(a.title)),
|
|
||||||
);
|
|
||||||
const identityAlbums = artistScopedRecords(identity?.albums, current.artist?.name);
|
|
||||||
// An MB row whose title is a PREFIX of an existing row's full title
|
|
||||||
// is the same work Deezer carries as a qualified variant —
|
|
||||||
// "Wonderful Crazy Night" beside the page's "Wonderful Crazy Night
|
|
||||||
// (Deluxe)". Injecting it adds a second card with no track count.
|
|
||||||
// Sequel tails ("Greatest Hits" vs "Greatest Hits II") stay
|
|
||||||
// distinct: a bare numeral/volume marker never counts as a variant
|
|
||||||
// qualifier. normText already strips brackets, so compare against
|
|
||||||
// the raw title normalized without stripping.
|
|
||||||
const rawNorm = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
||||||
// Same single-typed exemption as `seen`: only album-like rows may
|
|
||||||
// claim to be a variant of an MB album.
|
|
||||||
const existingRaw = (current.albums ?? [])
|
|
||||||
.filter((a) => a.record_type !== "single")
|
|
||||||
.map((a) => rawNorm(a.title));
|
|
||||||
const sequelTail = (tail) =>
|
|
||||||
/^(?:i{1,3}|iv|v|vi{0,3}|ix|x|\d{1,2}|vol(?:ume)?\s*\d*|pt\s*\d*|part\s*\d*)$/.test(
|
|
||||||
tail.trim(),
|
|
||||||
);
|
|
||||||
const coveredByVariant = (title) => {
|
|
||||||
const base = rawNorm(title);
|
|
||||||
if (base.length < 8) return false;
|
|
||||||
return existingRaw.some(
|
|
||||||
(t) =>
|
|
||||||
t.length > base.length
|
|
||||||
&& t.startsWith(base)
|
|
||||||
&& !sequelTail(t.slice(base.length)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
// `normText` DELETES bracket contents, which is right for peer file
|
|
||||||
// matching and wrong here: MusicBrainz writes a qualifier after a colon
|
|
||||||
// where Deezer puts it in parentheses, so "World War 3 (Lean)" reduced
|
|
||||||
// to "worldwar3" while MB's "World War 3: Lean" reduced to
|
|
||||||
// "worldwar3lean" and the two never collided. The result was a second
|
|
||||||
// card for a record already on the page — and once mixtapes had a tab
|
|
||||||
// of their own, the same work appeared in Albums AND Mixtapes.
|
|
||||||
//
|
|
||||||
// This key keeps the qualifier and instead folds the three things the
|
|
||||||
// providers genuinely disagree about: the separator, the words
|
|
||||||
// introducing a number, and how that number is written. Measured on
|
|
||||||
// Gucci Mane it collapses ten such pairs, among them
|
|
||||||
// "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part Five",
|
|
||||||
// "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" /
|
|
||||||
// "Trap House Four".
|
|
||||||
//
|
|
||||||
// Single-letter roman numerals are deliberately NOT folded: "X", "V"
|
|
||||||
// and "I" are real album titles, and reading them as 10, 5 and 1 would
|
|
||||||
// merge unrelated records.
|
|
||||||
const ORDINALS = {
|
|
||||||
one: "1", two: "2", three: "3", four: "4", five: "5",
|
|
||||||
six: "6", seven: "7", eight: "8", nine: "9", ten: "10",
|
|
||||||
ii: "2", iii: "3", iv: "4", vi: "6", vii: "7", viii: "8", ix: "9",
|
|
||||||
};
|
|
||||||
const releaseKey = (title) =>
|
|
||||||
asciiFold(title ?? "")
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[‘’']/g, "")
|
|
||||||
.replace(/\b(?:part|pt|vol|volume)\b\.?/g, " ")
|
|
||||||
.replace(/[^a-z0-9]+/g, " ")
|
|
||||||
.trim()
|
|
||||||
.split(" ")
|
|
||||||
.map((word) => ORDINALS[word] ?? word)
|
|
||||||
.join("");
|
|
||||||
// Every album-like row already on the page, by that key, so a matched
|
|
||||||
// MB row can be recognised as the one on screen rather than injected
|
|
||||||
// beside it.
|
|
||||||
// Every row sharing a key, not just the first: a provider sometimes
|
|
||||||
// carries the same record twice under its own spellings ("Brick Factory
|
|
||||||
// 2" beside "Brick Factory, Vol. 2"). Typing only one of them would put
|
|
||||||
// one copy in Mixtapes and leave the other in Albums, which is a worse
|
|
||||||
// page than leaving both alone.
|
|
||||||
const existingByKey = new Map();
|
|
||||||
for (const album of current.albums ?? []) {
|
|
||||||
if (album.record_type === "single") continue;
|
|
||||||
const key = releaseKey(album.title);
|
|
||||||
if (!key) continue;
|
|
||||||
if (!existingByKey.has(key)) existingByKey.set(key, []);
|
|
||||||
existingByKey.get(key).push(album);
|
|
||||||
}
|
|
||||||
// A MusicBrainz mixtape that IS a row already on the page contributes
|
|
||||||
// its TYPE, not a card. The Deezer row wins the slot because it is the
|
|
||||||
// playable one — it carries a real album id and tracklist — and MB's
|
|
||||||
// Mixtape/Street verdict re-files it, exactly as `type_hints` re-files
|
|
||||||
// a mislabelled compilation. That is what moves "World War 3 (Lean)"
|
|
||||||
// out of Albums instead of leaving one copy in each tab.
|
|
||||||
const adoptedMixtapeIds = new Set();
|
|
||||||
const extra = identityAlbums.filter((a) => {
|
|
||||||
if (!a?.title) return false;
|
|
||||||
const twins = a.is_mixtape ? existingByKey.get(releaseKey(a.title)) : undefined;
|
|
||||||
if (twins?.length) {
|
|
||||||
for (const twin of twins) {
|
|
||||||
if (!twin.is_mixtape) adoptedMixtapeIds.add(twin.id);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
!seen.has(normText(a.title))
|
|
||||||
&& !existingByKey.has(releaseKey(a.title))
|
|
||||||
&& !coveredByVariant(a.title)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const socials = identity?.socials ?? [];
|
|
||||||
// MB's authoritative primary/secondary types. The view matches these
|
|
||||||
// to displayed releases by title and re-files any the provider
|
|
||||||
// mislabeled "album" (compilations, soundtracks…).
|
|
||||||
const typeHints = identity?.type_hints ?? [];
|
|
||||||
// Guest appearances, dropped only where MusicBrainz can arbitrate.
|
|
||||||
//
|
|
||||||
// Deezer's /artist/{id}/albums returns records the artist merely
|
|
||||||
// contributed to — four Rooby Jeantal lo-fi releases sat in Mid-Air
|
|
||||||
// Thief's discography that way. The backend flags them (`appears_on`:
|
|
||||||
// the release's primary artist is someone else), but that flag ALONE
|
|
||||||
// must never delete a row: Deezer marks every contributor "Main", so a
|
|
||||||
// guest spot looks exactly like a genuine joint album — Kanye West's
|
|
||||||
// "Watch the Throne" carries primary artist JAY-Z and would be wrongly
|
|
||||||
// erased from his page.
|
|
||||||
//
|
|
||||||
// MusicBrainz separates them, because it credits a real collaboration
|
|
||||||
// under BOTH artists: querying Kanye's own MBID returns "Watch the
|
|
||||||
// Throne", while Mid-Air Thief's returns only his two real albums. So a
|
|
||||||
// flagged row survives if MB credits it and is dropped if MB does not
|
|
||||||
// — and ONLY when MB actually answered for this artist. No MB albums
|
|
||||||
// means no arbiter, and every row is kept rather than guessed at, which
|
|
||||||
// keeps thin-coverage artists (the obscure end, where this matters
|
|
||||||
// most) from losing real releases.
|
|
||||||
//
|
|
||||||
// The title check alone is not enough, and Kanye is the proof. MB files
|
|
||||||
// a collaboration under both parties only when the credit NAMES both
|
|
||||||
// ("Watch the Throne"); when the collaboration is its own MB entity it
|
|
||||||
// does not, and neither VULTURES 1, VULTURES 2 nor KIDS SEE GHOSTS
|
|
||||||
// appears anywhere in Ye's 315 release groups. All three were being
|
|
||||||
// erased from his page by the very rule written to protect them.
|
|
||||||
// `owned_album_ids` is the backend's wider answer to the same question
|
|
||||||
// — MB release groups of every type plus the MB-verified Apple
|
|
||||||
// catalogue — so a real collaboration survives on evidence instead of
|
|
||||||
// on MusicBrainz happening to duplicate the credit.
|
|
||||||
//
|
|
||||||
// `foreign_album_ids` is the other half: releases that belong to a
|
|
||||||
// DIFFERENT act sharing this Deezer artist record. Deezer files several
|
|
||||||
// unrelated performers under one id (there is exactly one Deezer
|
|
||||||
// "Wand", and it holds an LA psych band, a Brazilian funk MC, an
|
|
||||||
// Indonesian act and four more), which no amount of search ranking or
|
|
||||||
// credit-role reading can undo. The backend only fills this in when it
|
|
||||||
// can NAME the co-tenant, so on an ordinary artist it is empty and the
|
|
||||||
// page is untouched.
|
|
||||||
const mbTitles = new Set(identityAlbums.map((a) => normText(a.title)));
|
|
||||||
const owned = new Set(identity?.owned_album_ids ?? []);
|
|
||||||
const foreign = new Set(identity?.foreign_album_ids ?? []);
|
|
||||||
const ownAlbums = (current.albums ?? [])
|
|
||||||
.filter((a) => {
|
|
||||||
if (foreign.has(a?.id)) return false;
|
|
||||||
if (!a?.appears_on) return true;
|
|
||||||
if (owned.has(a?.id)) return true;
|
|
||||||
// No MB albums = no arbiter = keep everything, so thin-coverage
|
|
||||||
// artists never lose real releases.
|
|
||||||
return identityAlbums.length ? mbTitles.has(normText(a.title)) : true;
|
|
||||||
})
|
|
||||||
// Rows MusicBrainz typed Mixtape/Street under a title Deezer spells
|
|
||||||
// differently. A new object rather than a mutation: `current.albums`
|
|
||||||
// is shared with the page replay cache and with whatever is already
|
|
||||||
// rendered, and this must not edit either in place.
|
|
||||||
.map((a) =>
|
|
||||||
adoptedMixtapeIds.has(a?.id) ? { ...a, is_mixtape: true } : a,
|
|
||||||
);
|
|
||||||
const merged = {
|
|
||||||
...current,
|
|
||||||
identity_merged: true,
|
|
||||||
// A fused record's Deezer PORTRAIT is untrustworthy for exactly the
|
|
||||||
// reason its releases are: it is whichever co-tenant uploaded last.
|
|
||||||
// Wand's is a different artist entirely, and `deezerPortraitMissing`
|
|
||||||
// cannot catch it — it is a real photograph, just not of this band.
|
|
||||||
fused_record: !!identity?.fused_record,
|
|
||||||
albums: extra.length
|
|
||||||
? [...ownAlbums, ...extra].sort((a, b) =>
|
|
||||||
(b.release_date ?? "").localeCompare(a.release_date ?? ""))
|
|
||||||
: ownAlbums,
|
|
||||||
socials: socials.length ? socials : current.socials,
|
|
||||||
typeHints: typeHints.length ? typeHints : current.typeHints,
|
|
||||||
};
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deferred footer links: merged whenever they arrive, never blocking the
|
/** Deferred footer links: merged whenever they arrive, never blocking the
|
||||||
* identity pass. Cached back into the session page cache like the identity
|
* identity pass. Cached back into the session page cache like the identity
|
||||||
* merge, so a warm revisit keeps its links. */
|
* merge, so a warm revisit keeps its links. */
|
||||||
|
|||||||
@@ -506,7 +506,7 @@
|
|||||||
{@const rows = shown.arg.items || []}
|
{@const rows = shown.arg.items || []}
|
||||||
{@const item = shown.arg.item}
|
{@const item = shown.arg.item}
|
||||||
<div class="sheet-head">
|
<div class="sheet-head">
|
||||||
<span class="art">{#if item.path && item.art_path}<Cover path={item.art_path} eager size={22} />{:else if item.art_url}<img src={item.art_url} alt="" />{:else}<Icon name="music" size={22} />{/if}</span>
|
<span class="art">{#if item.art_path}<Cover path={item.art_path} eager size={22} />{:else if item.art_url}<img src={item.art_url} alt="" />{:else}<Icon name="music" size={22} />{/if}</span>
|
||||||
<span class="b"><span class="t">{item.title}</span><span class="m">{item.artist}{item.album ? " · " + item.album : ""}{item.kind === "unresolved" ? " · unresolved" : item.missing ? " · file missing" : item.kind === "stream" ? " · streams from the network" : ""}</span></span>
|
<span class="b"><span class="t">{item.title}</span><span class="m">{item.artist}{item.album ? " · " + item.album : ""}{item.kind === "unresolved" ? " · unresolved" : item.missing ? " · file missing" : item.kind === "stream" ? " · streams from the network" : ""}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sheet-rows">
|
<div class="sheet-rows">
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js";
|
import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js";
|
||||||
import { xfernote, syncShade, bindXferNote } from "./xfernote.svelte.js";
|
import { xfernote, syncShade, bindXferNote } from "./xfernote.svelte.js";
|
||||||
import { offline, bindOffline, setOffline, isOffline, cancelOffline } from "./offline.svelte.js";
|
import { offline, bindOffline, setOffline, isOffline, cancelOffline } from "./offline.svelte.js";
|
||||||
|
import { coverart, bindCoverArt } from "./coverart.svelte.js";
|
||||||
import { syncStreamSourcesToBackend } from "../lib/sources.js";
|
import { syncStreamSourcesToBackend } from "../lib/sources.js";
|
||||||
import { home, bindHome, loadHome } from "./home.svelte.js";
|
import { home, bindHome, loadHome } from "./home.svelte.js";
|
||||||
import { lyr, loadLyrics } from "./lyrics.svelte.js";
|
import { lyr, loadLyrics } from "./lyrics.svelte.js";
|
||||||
@@ -311,6 +312,10 @@
|
|||||||
media service reads: memories made before the mirror existed reach
|
media service reads: memories made before the mirror existed reach
|
||||||
it once, here, so cache files a car could play are named */
|
it once, here, so cache files a car could play are named */
|
||||||
void bindOffline();
|
void bindOffline();
|
||||||
|
/* every streamed row's cover, in the background — the file's own
|
||||||
|
picture first, then the catalog, persisted on the row so the car
|
||||||
|
(which reads rows straight out of the database) has it too */
|
||||||
|
void bindCoverArt();
|
||||||
setTimeout(() => { try { syncStreamSourcesToBackend(); } catch { /* fine */ } }, 3000);
|
setTimeout(() => { try { syncStreamSourcesToBackend(); } catch { /* fine */ } }, 3000);
|
||||||
bindHome();
|
bindHome();
|
||||||
bindImporter();
|
bindImporter();
|
||||||
@@ -335,7 +340,7 @@
|
|||||||
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
|
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
|
||||||
/* the probe handle — the desktop has __soulstream for the same reason:
|
/* the probe handle — the desktop has __soulstream for the same reason:
|
||||||
tests/mobile/smoke.mjs drives the real app through it */
|
tests/mobile/smoke.mjs drives the real app through it */
|
||||||
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, card: mediacard, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms, offline, offlineCtl: { setOffline, isOffline, cancelOffline } };
|
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, card: mediacard, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms, offline, offlineCtl: { setOffline, isOffline, cancelOffline }, coverart };
|
||||||
return () => { if (unlisten) unlisten(); };
|
return () => { if (unlisten) unlisten(); };
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
exact-name matches, the most-followed wins, so a shared name resolves to
|
exact-name matches, the most-followed wins, so a shared name resolves to
|
||||||
the canonical artist and never to a namesake the search listed first. */
|
the canonical artist and never to a namesake the search listed first. */
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
|
import { mergeArtistIdentity } from "../lib/artistIdentity.js";
|
||||||
import { note } from "./notice.svelte.js";
|
import { note } from "./notice.svelte.js";
|
||||||
|
|
||||||
export const catalog = $state({
|
export const catalog = $state({
|
||||||
@@ -69,6 +70,7 @@ export function lookupArtist(name, seed = null) {
|
|||||||
catalog.artists[k] = { status: "loading", id: best.id, seed: best, page: null };
|
catalog.artists[k] = { status: "loading", id: best.id, seed: best, page: null };
|
||||||
const page = await api.catalogArtist(best.id);
|
const page = await api.catalogArtist(best.id);
|
||||||
catalog.artists[k] = { status: "ready", id: best.id, seed: best, page };
|
catalog.artists[k] = { status: "ready", id: best.id, seed: best, page };
|
||||||
|
void applyIdentity(k, best.id, page?.artist?.name ?? best.name ?? name);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* transport failures stay retryable — the next visit asks again */
|
/* transport failures stay retryable — the next visit asks again */
|
||||||
catalog.artists[k] = { status: "error", id: null, seed: null, page: null, error: String(e) };
|
catalog.artists[k] = { status: "error", id: null, seed: null, page: null, error: String(e) };
|
||||||
@@ -76,6 +78,33 @@ export function lookupArtist(name, seed = null) {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The identity pass — the desktop's applyArtistIdentity, after the page
|
||||||
|
* is on screen: what MusicBrainz (and the MB-verified Apple catalogue)
|
||||||
|
* add to the Deezer list — the releases Deezer's artist listing omits
|
||||||
|
* (EPs, and whole albums: My Bloody Valentine had four releases here
|
||||||
|
* before this), the authoritative release types, and the arbitration of
|
||||||
|
* guest appearances and co-tenants — merged by the ONE implementation
|
||||||
|
* both surfaces read (lib/artistIdentity.js). Best-effort: the page is
|
||||||
|
* complete without it, and a transient miss is asked once more after
|
||||||
|
* eight seconds, like the desktop. */
|
||||||
|
async function applyIdentity(k, id, name) {
|
||||||
|
const still = () => { const cur = catalog.artists[k]; return cur && cur.id === id && cur.status === "ready" && cur.page ? cur : null; };
|
||||||
|
try {
|
||||||
|
let identity = await api.catalogArtistIdentity(id, name);
|
||||||
|
const empty = !(identity?.albums?.length || identity?.type_hints?.length);
|
||||||
|
if (empty) {
|
||||||
|
await new Promise((r) => setTimeout(r, 8000));
|
||||||
|
if (!still()) return;
|
||||||
|
identity = await api.catalogArtistIdentity(id, name);
|
||||||
|
}
|
||||||
|
const cur = still();
|
||||||
|
if (!cur) return;
|
||||||
|
cur.page = mergeArtistIdentity(cur.page, identity);
|
||||||
|
} catch {
|
||||||
|
/* best-effort supplement: the page is complete without it */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Fans also like, the Last.fm profile and the bio — the desktop's
|
/** Fans also like, the Last.fm profile and the bio — the desktop's
|
||||||
* ArtistExtras, asked once per artist after the page is on screen. */
|
* ArtistExtras, asked once per artist after the page is on screen. */
|
||||||
export function loadExtras(id, name) {
|
export function loadExtras(id, name) {
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/* Every streamed row gets its cover.
|
||||||
|
|
||||||
|
The desktop fills a playlist's blank rows when the page is OPEN
|
||||||
|
(src/views/Playlist.svelte, backfillArt) and a playing track's when it
|
||||||
|
PLAYS (state.svelte.js, ensureStreamArt). The phone does both, and one
|
||||||
|
thing more: a walk over EVERY playlist in the background, so a row is
|
||||||
|
covered before anyone looks at it — the car's browse tree and playback
|
||||||
|
card read the rows straight out of the database with no page to fill
|
||||||
|
them (auto_browse.rs), and thirty of GhettoTechie's forty rows had
|
||||||
|
nothing there to read.
|
||||||
|
|
||||||
|
THE ORDER OF CERTAINTY is the desktop's, kept exactly: the file before
|
||||||
|
the catalog. A row already streamed once holds the peer's own copy, and
|
||||||
|
the cover embedded in those bytes belongs to the recording without any
|
||||||
|
matching at all — for the underground rows this walk exists for, the
|
||||||
|
file is the only place a cover exists. Only a file that carries none
|
||||||
|
(or is not here) asks the catalog, and then through the ONE ladder the
|
||||||
|
import review verifies rows with (catalogMatch.js findCatalogTrack): a
|
||||||
|
loose name 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 WRITTEN. A hit is persisted on the row (playlist_relink_item:
|
||||||
|
the extracted file as art_path, or the catalog cover as art_url with
|
||||||
|
the track id), which is what the page, the media card, the resume point
|
||||||
|
and the car all read. A miss is remembered for a day — in this browser,
|
||||||
|
not on the row — so a boot does not re-ask the catalog thirty times
|
||||||
|
about rows it has already said no to; it is remembered only when the
|
||||||
|
catalog ANSWERED, never when the phone was offline.
|
||||||
|
|
||||||
|
PACE. One row at a time, a beat between rows, after boot and again when
|
||||||
|
a playlist changes (an import adds rows) — never during the first
|
||||||
|
seconds of a start. The Deezer client paces itself underneath
|
||||||
|
(metadata crate); this only keeps the walk from being a burst. */
|
||||||
|
import * as api from "../lib/api.js";
|
||||||
|
import { listen } from "../lib/events.js";
|
||||||
|
import { lib } from "./lib.svelte.js";
|
||||||
|
import { findCatalogTrack, nameNorm } from "../lib/catalogMatch.js";
|
||||||
|
import { cachedStreamSourceFor, hqCover } from "../lib/sources.js";
|
||||||
|
|
||||||
|
export const coverart = $state({
|
||||||
|
/** a pass is running */
|
||||||
|
active: false,
|
||||||
|
/** rows waiting in this pass */
|
||||||
|
pending: 0,
|
||||||
|
/** rows covered since boot */
|
||||||
|
found: 0,
|
||||||
|
/** rows looked at since boot */
|
||||||
|
walked: 0,
|
||||||
|
/** why the last pass stopped early, for the diagnostics screen */
|
||||||
|
lastError: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── what is known ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** artist|title → { url, trackId }: covers found this session, by the walk
|
||||||
|
* or by the player, so the same track twice is one lookup and a row the
|
||||||
|
* player covered is persisted without another. */
|
||||||
|
const learned = new Map();
|
||||||
|
/** rows attempted this session, by item id — once each, like the desktop */
|
||||||
|
const attempted = new Set();
|
||||||
|
|
||||||
|
const MISS_KEY = "ss.coverMisses";
|
||||||
|
const MISS_TTL_MS = 24 * 3600 * 1000;
|
||||||
|
let misses = null;
|
||||||
|
function loadMisses() {
|
||||||
|
if (misses) return misses;
|
||||||
|
misses = new Map();
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(MISS_KEY) || "{}");
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, at] of Object.entries(raw)) if (now - at < MISS_TTL_MS) misses.set(k, at);
|
||||||
|
} catch { /* nothing remembered */ }
|
||||||
|
return misses;
|
||||||
|
}
|
||||||
|
function saveMisses() {
|
||||||
|
try { localStorage.setItem(MISS_KEY, JSON.stringify(Object.fromEntries(loadMisses()))); } catch { /* fine */ }
|
||||||
|
}
|
||||||
|
export const coverKey = (artist, title) => nameNorm(artist) + "|" + nameNorm(title);
|
||||||
|
const missedRecently = (key) => loadMisses().has(key);
|
||||||
|
function noteMiss(key) {
|
||||||
|
loadMisses().set(key, Date.now());
|
||||||
|
saveMisses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The catalog's cover for a track, strictly, or null. Shared by the walk
|
||||||
|
* and the player: one definition of "this row is that record", one
|
||||||
|
* memory of the answer. Rejects only when the catalog could not be
|
||||||
|
* asked at all (offline), so the caller can stop rather than record a
|
||||||
|
* miss it never heard. */
|
||||||
|
export async function catalogCoverFor(artist, title, durationSecs = null) {
|
||||||
|
if (!artist || !title) return null;
|
||||||
|
const key = coverKey(artist, title);
|
||||||
|
if (learned.has(key)) return learned.get(key);
|
||||||
|
if (missedRecently(key)) return null;
|
||||||
|
/* findCatalogTrack swallows a failed search into "no match"; the
|
||||||
|
search is wrapped so the difference survives — a miss is only a
|
||||||
|
miss when the catalog answered */
|
||||||
|
let answered = false;
|
||||||
|
let failed = null;
|
||||||
|
const search = async (q) => {
|
||||||
|
try {
|
||||||
|
const r = await api.catalogSearchTracks(q);
|
||||||
|
answered = true;
|
||||||
|
return r;
|
||||||
|
} catch (e) {
|
||||||
|
failed = e;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const shelf = async (a, t) => {
|
||||||
|
try {
|
||||||
|
const r = await api.catalogArtistTrackCandidates(a, t);
|
||||||
|
answered = true;
|
||||||
|
return r;
|
||||||
|
} catch (e) {
|
||||||
|
failed = e;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const hit = await findCatalogTrack(search, { title, artist, duration_secs: durationSecs ?? null }, shelf);
|
||||||
|
if (hit?.cover) {
|
||||||
|
const found = { url: hqCover(hit.cover) ?? hit.cover, trackId: hit.id != null ? String(hit.id) : null };
|
||||||
|
learned.set(key, found);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
if (!answered && failed) throw failed;
|
||||||
|
if (answered) noteMiss(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The player found a cover while playing (or the sheet, or an import):
|
||||||
|
* remembered, and the walk persists it on every row that is this track. */
|
||||||
|
export function learnCover(artist, title, url, trackId = null) {
|
||||||
|
if (!artist || !title || !url) return;
|
||||||
|
learned.set(coverKey(artist, title), { url, trackId });
|
||||||
|
schedule(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the rows ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** a stream row with nothing to draw */
|
||||||
|
export const wantsCover = (item) =>
|
||||||
|
item && item.kind === "stream" && !item.art_url && !item.art_path && !!item.artist && !!item.title;
|
||||||
|
|
||||||
|
/** { plId, item }, the open page's rows at the front */
|
||||||
|
const queue = [];
|
||||||
|
const listeners = new Set();
|
||||||
|
/** Hear about a row that was just covered: fn(plId, itemId, fields), where
|
||||||
|
* fields is what was persisted ({ artPath } or { artUrl, trackId }). The
|
||||||
|
* open playlist page patches its rows from this rather than re-reading. */
|
||||||
|
export function onCover(fn) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cover these rows of this playlist; `first` puts them ahead of the walk
|
||||||
|
* (the page someone is looking at). Rows already attempted this session
|
||||||
|
* are left alone. */
|
||||||
|
export function wantCovers(plId, items, first = false) {
|
||||||
|
const rows = (items || []).filter((i) => wantsCover(i) && !attempted.has(i.id) && !queue.some((q) => q.item.id === i.id));
|
||||||
|
if (!rows.length) return 0;
|
||||||
|
const jobs = rows.map((item) => ({ plId, item }));
|
||||||
|
if (first) queue.unshift(...jobs);
|
||||||
|
else queue.push(...jobs);
|
||||||
|
coverart.pending = queue.length;
|
||||||
|
kick(first ? 0 : 400);
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every playlist's blank rows, queued behind whatever is already waiting. */
|
||||||
|
export async function walkAll() {
|
||||||
|
for (const p of lib.playlists || []) {
|
||||||
|
let rows = [];
|
||||||
|
try { rows = (await api.playlistItems(p.id)) || []; } catch { continue; }
|
||||||
|
wantCovers(p.id, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(plId, row, fields) {
|
||||||
|
try {
|
||||||
|
await api.playlistRelinkItem(plId, row.id, fields);
|
||||||
|
} catch {
|
||||||
|
return false; // the row vanished mid-walk — nothing to persist onto
|
||||||
|
}
|
||||||
|
coverart.found++;
|
||||||
|
for (const fn of listeners) {
|
||||||
|
try { fn(plId, row.id, fields); } catch { /* a page that went away */ }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row: the file, then what is known, then the catalog. */
|
||||||
|
async function fill(plId, row) {
|
||||||
|
attempted.add(row.id);
|
||||||
|
coverart.walked++;
|
||||||
|
/* the file before the catalog */
|
||||||
|
try {
|
||||||
|
const src = await cachedStreamSourceFor(row.track_id ?? null, row.artist, row.title);
|
||||||
|
if (src) {
|
||||||
|
const embedded = await api.streamEmbeddedArt(src.username, src.path);
|
||||||
|
if (embedded) return persist(plId, row, { artPath: embedded });
|
||||||
|
}
|
||||||
|
} catch { /* no file here, or an unreadable one — the catalog is next */ }
|
||||||
|
const found = await catalogCoverFor(row.artist, row.title, row.duration_secs ?? null);
|
||||||
|
if (!found) return false;
|
||||||
|
return persist(plId, row, {
|
||||||
|
artUrl: found.url,
|
||||||
|
trackId: found.trackId && !row.track_id ? found.trackId : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let running = false;
|
||||||
|
let kickTimer = null;
|
||||||
|
function kick(delayMs) {
|
||||||
|
clearTimeout(kickTimer);
|
||||||
|
kickTimer = setTimeout(() => { void run(); }, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
coverart.active = true;
|
||||||
|
coverart.lastError = "";
|
||||||
|
try {
|
||||||
|
while (queue.length) {
|
||||||
|
const { plId, item } = queue.shift();
|
||||||
|
coverart.pending = queue.length;
|
||||||
|
try {
|
||||||
|
await fill(plId, item);
|
||||||
|
} catch (e) {
|
||||||
|
/* offline, or the catalog unreachable: this row is not a
|
||||||
|
miss and the rest of the pass can wait — a minute, then
|
||||||
|
from here */
|
||||||
|
attempted.delete(item.id);
|
||||||
|
queue.unshift({ plId, item });
|
||||||
|
coverart.pending = queue.length;
|
||||||
|
coverart.lastError = String(e?.message || e);
|
||||||
|
kick(60_000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
coverart.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── when ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
let walkTimer = null;
|
||||||
|
export function schedule(delayMs) {
|
||||||
|
clearTimeout(walkTimer);
|
||||||
|
walkTimer = setTimeout(() => { void walkAll(); }, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bound = false;
|
||||||
|
/** After boot, a while after — the first screen and a restored track come
|
||||||
|
* first — then whenever a playlist changes, and every six hours for rows
|
||||||
|
* whose miss has expired or whose file has since arrived. */
|
||||||
|
export async function bindCoverArt() {
|
||||||
|
if (bound) return;
|
||||||
|
bound = true;
|
||||||
|
schedule(25_000);
|
||||||
|
try {
|
||||||
|
await listen("soulseek:playlists-changed", () => schedule(8000));
|
||||||
|
} catch { /* not a phone */ }
|
||||||
|
setInterval(() => {
|
||||||
|
attempted.clear();
|
||||||
|
schedule(0);
|
||||||
|
}, 6 * 3600_000);
|
||||||
|
}
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import { listen } from "../lib/events.js";
|
import { listen } from "../lib/events.js";
|
||||||
import { ARRIVE_HOLD_MS, ARRIVE_MS } from "../lib/waveshape.js";
|
import { ARRIVE_HOLD_MS, ARRIVE_MS } from "../lib/waveshape.js";
|
||||||
import { loadArt, art } from "./art.svelte.js";
|
import { loadArt, art, isStreamKey } from "./art.svelte.js";
|
||||||
|
import { catalogCoverFor, learnCover } from "./coverart.svelte.js";
|
||||||
import { lib, tracksOf } from "./lib.svelte.js";
|
import { lib, tracksOf } from "./lib.svelte.js";
|
||||||
import { shuffled } from "./util.js";
|
import { shuffled } from "./util.js";
|
||||||
|
|
||||||
@@ -237,16 +238,50 @@ function streamStory(g, e) {
|
|||||||
} catch { /* nothing to fold yet */ }
|
} catch { /* nothing to fold yet */ }
|
||||||
if (!artTried && (player.stream.received > 200_000 || done)) {
|
if (!artTried && (player.stream.received > 200_000 || done)) {
|
||||||
artTried = true;
|
artTried = true;
|
||||||
const key = e.album?.art_path;
|
void ensureStreamArt(g, e, username, path);
|
||||||
if (key && art[key] === undefined) {
|
|
||||||
api.streamEmbeddedArt(username, path).then((u) => { art[key] = u || ""; }, () => { art[key] = ""; });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (g === gen && !done) streamTimer = setTimeout(tick, 2500);
|
if (g === gen && !done) streamTimer = setTimeout(tick, 2500);
|
||||||
};
|
};
|
||||||
streamTimer = setTimeout(tick, 900);
|
streamTimer = setTimeout(tick, 900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A streamed track showing no artwork: find it some, in order of certainty
|
||||||
|
* — the desktop's ensureStreamArt. The bytes the peer is sending ARE the
|
||||||
|
* recording, so a cover embedded in them belongs to it: no matching, no
|
||||||
|
* doubt, no network. Only a file that carries none asks the catalog, and
|
||||||
|
* strictly (coverart.svelte.js: the one ladder, one memory of the answer;
|
||||||
|
* a loose lookup once put one record's cover on seven unrelated songs).
|
||||||
|
* A hit becomes the entry's art key — what the bar, the sheet, the media
|
||||||
|
* card and the resume point all read — and the walk persists it on the
|
||||||
|
* playlist row. */
|
||||||
|
async function ensureStreamArt(g, e, username, path) {
|
||||||
|
const key = e.album?.art_path;
|
||||||
|
if (!key || !isStreamKey(key)) return; // a cover already: a URL, or a file
|
||||||
|
if (art[key] === undefined) {
|
||||||
|
/* the extractor answers with the FILE it wrote, and the art store
|
||||||
|
holds data URLs — the thumbnail read turns one into the other,
|
||||||
|
the way a library cover is loaded */
|
||||||
|
let u = "";
|
||||||
|
try {
|
||||||
|
const p = await api.streamEmbeddedArt(username, path);
|
||||||
|
u = p ? (await api.albumArt(p, 360)) || "" : "";
|
||||||
|
} catch { u = ""; }
|
||||||
|
art[key] = u;
|
||||||
|
}
|
||||||
|
if (art[key]) return;
|
||||||
|
let found = null;
|
||||||
|
try {
|
||||||
|
found = await catalogCoverFor(e.t.artist, e.t.title, e.t.duration_secs ?? null);
|
||||||
|
} catch { return; /* offline: a later play asks again */ }
|
||||||
|
if (!found) return;
|
||||||
|
e.album.art_path = found.url;
|
||||||
|
learnCover(e.t.artist, e.t.title, found.url, found.trackId);
|
||||||
|
void loadArt(found.url, true);
|
||||||
|
if (g !== gen) return;
|
||||||
|
mediaSync();
|
||||||
|
writeSession();
|
||||||
|
}
|
||||||
|
|
||||||
/** The index the engine will play next, in this order, with these modes. */
|
/** The index the engine will play next, in this order, with these modes. */
|
||||||
function nextIndex() {
|
function nextIndex() {
|
||||||
if (!player.queue.length) return -1;
|
if (!player.queue.length) return -1;
|
||||||
@@ -544,6 +579,10 @@ export function seekSecs(s) {
|
|||||||
if (player.needsResource) { startEntry(current(), s > 1 ? s : null); return; }
|
if (player.needsResource) { startEntry(current(), s > 1 ? s : null); return; }
|
||||||
api.playerControl("seek", s).catch(setErr);
|
api.playerControl("seek", s).catch(setErr);
|
||||||
mediaSync();
|
mediaSync();
|
||||||
|
/* the resume point too, now: the engine's own flush is every five
|
||||||
|
seconds, and a phone put down (or a car started) right after a scrub
|
||||||
|
would otherwise continue from before it (2026-09-18) */
|
||||||
|
writeSession();
|
||||||
}
|
}
|
||||||
export function seekFrac(f) {
|
export function seekFrac(f) {
|
||||||
if (player.duration) seekSecs(f * player.duration);
|
if (player.duration) seekSecs(f * player.duration);
|
||||||
@@ -694,7 +733,9 @@ function resumeItem(e) {
|
|||||||
artist: t.artist || a.artist || "",
|
artist: t.artist || a.artist || "",
|
||||||
album: a.title || "",
|
album: a.title || "",
|
||||||
duration: t.duration_secs ?? null,
|
duration: t.duration_secs ?? null,
|
||||||
art: a.art_path || null,
|
/* a "stream:" key is this page's own name for a cover it fills from
|
||||||
|
the file's tag; the Rust side reads the tag itself when given none */
|
||||||
|
art: a.art_path && !String(a.art_path).startsWith("stream:") ? a.art_path : null,
|
||||||
path: local ? t.path : null,
|
path: local ? t.path : null,
|
||||||
stream: t.stream?.username && t.path ? { username: t.stream.username, path: t.path, size: t.stream.size || 0 } : null,
|
stream: t.stream?.username && t.path ? { username: t.stream.username, path: t.path, size: t.stream.size || 0 } : null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,11 +81,17 @@ function albumOfItem(item, p) {
|
|||||||
const k = String(item.artist || "").toLowerCase();
|
const k = String(item.artist || "").toLowerCase();
|
||||||
const own = item.album ? lib.albums.find((a) => a.title === item.album && a.artist.toLowerCase() === k) : null;
|
const own = item.album ? lib.albums.find((a) => a.title === item.album && a.artist.toLowerCase() === k) : null;
|
||||||
if (own) return own;
|
if (own) return own;
|
||||||
|
/* a streamed row with no catalog cover gets a "stream:" key: the player
|
||||||
|
fills it from the file's own tag once enough of it is here (thirty of
|
||||||
|
GhettoTechie's forty rows have no cover URL, and the bar drew nothing) */
|
||||||
|
/* a stream row's art_path is the cover the walk pulled out of its own
|
||||||
|
file (coverart.svelte.js), its art_url the catalog's — either draws */
|
||||||
|
const art = item.art_path ?? item.art_url ?? null;
|
||||||
return {
|
return {
|
||||||
id: null, catalogId: null,
|
id: null, catalogId: null,
|
||||||
title: item.album || p?.name || null,
|
title: item.album || p?.name || null,
|
||||||
artist: item.artist,
|
artist: item.artist,
|
||||||
art_path: item.path ? (item.art_path ?? null) : (item.art_url ?? null),
|
art_path: art ?? (item.kind === "stream" ? "stream:pl" + item.id : null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,13 +44,17 @@ async function ensureOnline() {
|
|||||||
* library id: the catalog id rides in `catalogId`, and the cover URL is the
|
* library id: the catalog id rides in `catalogId`, and the cover URL is the
|
||||||
* art key (art.svelte.js routes https through the catalog art proxy). */
|
* art key (art.svelte.js routes https through the catalog art proxy). */
|
||||||
function albumMeta(o, artist) {
|
function albumMeta(o, artist) {
|
||||||
|
/* no catalog cover: a "stream:" key, which the player fills from the
|
||||||
|
file's own tag once enough of it is here — one key per record, so the
|
||||||
|
first track's picture serves the rest */
|
||||||
|
const fallback = "stream:cat" + (o.albumId ?? o.trackId ?? artist ?? "");
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
catalogId: o.albumId ?? null,
|
catalogId: o.albumId ?? null,
|
||||||
title: o.album ?? null,
|
title: o.album ?? null,
|
||||||
artist: o.albumArtist ?? artist,
|
artist: o.albumArtist ?? artist,
|
||||||
year: o.year ?? null,
|
year: o.year ?? null,
|
||||||
art_path: hqCover(o.cover) ?? null,
|
art_path: hqCover(o.cover) ?? fallback,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function streamEntry(title, artist, src, o, album) {
|
function streamEntry(title, artist, src, o, album) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { openHalf } from "../nav.svelte.js";
|
import { openHalf } from "../nav.svelte.js";
|
||||||
import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick, moveItem } from "../playlists.svelte.js";
|
import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick, moveItem } from "../playlists.svelte.js";
|
||||||
import { offline, isOffline, cancelOffline } from "../offline.svelte.js";
|
import { offline, isOffline, cancelOffline } from "../offline.svelte.js";
|
||||||
|
import { wantCovers, onCover } from "../coverart.svelte.js";
|
||||||
import { say } from "../trouble.js";
|
import { say } from "../trouble.js";
|
||||||
import { mmss, longDur, stamp } from "../util.js";
|
import { mmss, longDur, stamp } from "../util.js";
|
||||||
import { reorder } from "../reorder.js";
|
import { reorder } from "../reorder.js";
|
||||||
@@ -41,10 +42,20 @@
|
|||||||
if (pls.dragging) return;
|
if (pls.dragging) return;
|
||||||
const g = ++gen;
|
const g = ++gen;
|
||||||
api.playlistItems(p.id).then(
|
api.playlistItems(p.id).then(
|
||||||
(r) => { if (g === gen) { items = r || []; loaded = true; loadErr = ""; } },
|
(r) => { if (g === gen) { items = r || []; loaded = true; loadErr = ""; wantCovers(p.id, items, true); } },
|
||||||
(e) => { if (g === gen) { loaded = true; loadErr = String(e); } },
|
(e) => { if (g === gen) { loaded = true; loadErr = String(e); } },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
/* a row the cover walk (coverart.svelte.js) filled while this page is
|
||||||
|
open — the file's own picture or the catalog's — patched in place */
|
||||||
|
$effect(() => onCover((plId, itemId, f) => {
|
||||||
|
if (plId !== pl?.id) return;
|
||||||
|
const row = items.find((i) => i.id === itemId);
|
||||||
|
if (!row) return;
|
||||||
|
if (f.artPath) row.art_path = f.artPath;
|
||||||
|
if (f.artUrl) row.art_url = f.artUrl;
|
||||||
|
if (f.trackId && !row.track_id) row.track_id = f.trackId;
|
||||||
|
}));
|
||||||
/* the bar's sheet (playlist-more) reads the open page's rows here */
|
/* the bar's sheet (playlist-more) reads the open page's rows here */
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const p = pl;
|
const p = pl;
|
||||||
@@ -178,7 +189,7 @@
|
|||||||
<button class="trk" class:playing={now} class:unresolved={item.kind === "unresolved"} class:missing={!!item.missing} onclick={() => tap(item)} oncontextmenu={(e) => { e.preventDefault(); sheet(item); }}>
|
<button class="trk" class:playing={now} class:unresolved={item.kind === "unresolved"} class:missing={!!item.missing} onclick={() => tap(item)} oncontextmenu={(e) => { e.preventDefault(); sheet(item); }}>
|
||||||
<span class="n" class:stamped={isSetlist(pl) && stamped}>{#if isSetlist(pl) && stamped}<span class="stamp">{stamp(item.start_ms)}</span>{:else}{i + 1}{/if}</span>
|
<span class="n" class:stamped={isSetlist(pl) && stamped}>{#if isSetlist(pl) && stamped}<span class="stamp">{stamp(item.start_ms)}</span>{:else}{i + 1}{/if}</span>
|
||||||
<span class="thumb">
|
<span class="thumb">
|
||||||
{#if item.path && item.art_path}<Cover path={item.art_path} size={14} />
|
{#if item.art_path}<Cover path={item.art_path} size={14} />
|
||||||
{:else if item.art_url}<img src={item.art_url} alt="" loading="lazy" />
|
{:else if item.art_url}<img src={item.art_url} alt="" loading="lazy" />
|
||||||
{:else}<Icon name="music" size={14} />{/if}
|
{:else}<Icon name="music" size={14} />{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in new issue
Block a user