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:
KeelrLeek committed 2026-09-18 15:00:11 -05:00
1 parent 5ddb6d4d59
commit 9f7661afab
22 files changed
+3069 -485

No files matched your search

+247
View File
@@ -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
View File
@@ -28,6 +28,7 @@ import {
libraryAlbumIn,
libraryTrackIn,
} from "./sources.js";
import { artistIdentityKey, recordBelongsToArtist, artistScopedRecords, mergeArtistIdentity } from "./artistIdentity.js";
export { RESOLVER_AUDIO_QUALITY, sourcePrefs, refreshSourcePrefs, albumDisambiguator, setExcludedSearchPhrases, sanitizeSearchQuery, collectSources } from "./sources.js";
import { nameNorm, pickCatalogMatch } from "./catalogMatch.js";
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
@@ -1434,38 +1435,6 @@ const sameAlbumId = (left, 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.
* 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. */
@@ -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
* identity pass. Cached back into the session page cache like the identity
* merge, so a warm revisit keeps its links. */
+1 -1
View File
@@ -506,7 +506,7 @@
{@const rows = shown.arg.items || []}
{@const item = shown.arg.item}
<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>
</div>
<div class="sheet-rows">
+6 -1
View File
@@ -38,6 +38,7 @@
import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js";
import { xfernote, syncShade, bindXferNote } from "./xfernote.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 { home, bindHome, loadHome } from "./home.svelte.js";
import { lyr, loadLyrics } from "./lyrics.svelte.js";
@@ -311,6 +312,10 @@
media service reads: memories made before the mirror existed reach
it once, here, so cache files a car could play are named */
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);
bindHome();
bindImporter();
@@ -335,7 +340,7 @@
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
/* the probe handle — the desktop has __soulstream for the same reason:
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(); };
});
</script>
+29
View File
@@ -7,6 +7,7 @@
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. */
import * as api from "../lib/api.js";
import { mergeArtistIdentity } from "../lib/artistIdentity.js";
import { note } from "./notice.svelte.js";
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 };
const page = await api.catalogArtist(best.id);
catalog.artists[k] = { status: "ready", id: best.id, seed: best, page };
void applyIdentity(k, best.id, page?.artist?.name ?? best.name ?? name);
} catch (e) {
/* transport failures stay retryable — the next visit asks again */
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
* ArtistExtras, asked once per artist after the page is on screen. */
export function loadExtras(id, name) {
+272
View File
@@ -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);
}
+47 -6
View File
@@ -27,7 +27,8 @@
import * as api from "../lib/api.js";
import { listen } from "../lib/events.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 { shuffled } from "./util.js";
@@ -237,16 +238,50 @@ function streamStory(g, e) {
} catch { /* nothing to fold yet */ }
if (!artTried && (player.stream.received > 200_000 || done)) {
artTried = true;
const key = e.album?.art_path;
if (key && art[key] === undefined) {
api.streamEmbeddedArt(username, path).then((u) => { art[key] = u || ""; }, () => { art[key] = ""; });
}
void ensureStreamArt(g, e, username, path);
}
if (g === gen && !done) streamTimer = setTimeout(tick, 2500);
};
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. */
function nextIndex() {
if (!player.queue.length) return -1;
@@ -544,6 +579,10 @@ export function seekSecs(s) {
if (player.needsResource) { startEntry(current(), s > 1 ? s : null); return; }
api.playerControl("seek", s).catch(setErr);
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) {
if (player.duration) seekSecs(f * player.duration);
@@ -694,7 +733,9 @@ function resumeItem(e) {
artist: t.artist || a.artist || "",
album: a.title || "",
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,
stream: t.stream?.username && t.path ? { username: t.stream.username, path: t.path, size: t.stream.size || 0 } : null,
};
+7 -1
View File
@@ -81,11 +81,17 @@ function albumOfItem(item, p) {
const k = String(item.artist || "").toLowerCase();
const own = item.album ? lib.albums.find((a) => a.title === item.album && a.artist.toLowerCase() === k) : null;
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 {
id: null, catalogId: null,
title: item.album || p?.name || null,
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),
};
}
+5 -1
View File
@@ -44,13 +44,17 @@ async function ensureOnline() {
* 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). */
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 {
id: null,
catalogId: o.albumId ?? null,
title: o.album ?? null,
artist: o.albumArtist ?? artist,
year: o.year ?? null,
art_path: hqCover(o.cover) ?? null,
art_path: hqCover(o.cover) ?? fallback,
};
}
function streamEntry(title, artist, src, o, album) {
+13 -2
View File
@@ -15,6 +15,7 @@
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 { offline, isOffline, cancelOffline } from "../offline.svelte.js";
import { wantCovers, onCover } from "../coverart.svelte.js";
import { say } from "../trouble.js";
import { mmss, longDur, stamp } from "../util.js";
import { reorder } from "../reorder.js";
@@ -41,10 +42,20 @@
if (pls.dragging) return;
const g = ++gen;
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); } },
);
});
/* 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 */
$effect(() => {
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); }}>
<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">
{#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}<Icon name="music" size={14} />{/if}
</span>