Files
SoulStream/src/lib/api.js
T
KeelrLeek c4ff6269a3 import: records named by their own tags, and covers resampled where the pixels are
Two threads from one session, both of them the same shape: something was
being decided from the wrong source.

THE PROVIDERS WERE ASKED ABOUT A FOLDER. The picker sent the directory name
to Deezer and MusicBrainz — the ripper's naming, carrying catalogue numbers,
years and initials. "[WARP CD 156] Battles - Mirrored - 2007" matches
nothing anywhere; "Mirrored" matches instantly. Across the reference library
501 of 515 records carry a tag album and 315 disagree with their folder, and
no amount of cleaning turns "ACPP" into "Prospect Park, Brooklyn NY". The
record's own claim now leads and the folder is the fallback, in one place
(src/lib/import-title.js) that apply and the picker both read — apply
already resolved it this way, and only the picker did not. The verdict judge
had the same split: it compared candidates against the folder too, so an
exact answer scored as mere containment and a clean match wore a warning.

Identification also stops searching when searching cannot work. A folder
named "1989 Pauls Boutique" is a hopeless query and an easy comparison, so
after Deezer and MusicBrainz come back empty the artist's own catalogue is
ranked by title similarity. Scoring rather than stripping is the point: 1989,
1984 and 2001 are real albums, and any cleaner aggressive enough to rescue
Paul's Boutique would have destroyed them. Words split on whitespace with
punctuation folded inside them, so "Paul's" stays one token; pressing
vocabulary is ignored for scoring only; coverage is measured from both sides.
15 assertions pin the cases that would break it.

Cached verdicts now carry the engine that produced them. A cached match is
good forever, a cached "nothing found" only as good as the thing that
looked — and nothing ever re-asked. Negatives from an older engine, or from
a query that has since changed, are dropped and asked again.

Soulseek was asked the same wrong question, with the artist said twice:
"battles battles - mirrored - 2007". The server matches on ALL terms, so
every leftover token narrows the search — that copy was only found because
the peer happened to have 2007 in its path. The ladder now leads with the
record's name and keeps the folder as the narrower rung behind it.

AVAILABILITY OUTRANKS ENCODING. Candidate copies were ranked with avgBitrate
above hasFreeSlot, and queue depth — captured on every candidate — was never
read at all. A peer with no free slot and 277 people ahead beat a free peer
with a queue of two, because its files reported a bitrate and the others
reported none, and `?? 0` scores "didn't say" as the worst possible answer.
Bitrate is now ignored between two fully-lossless copies, where it describes
the material rather than the quality.

And nothing ever gave up. Track 1 is requested alone so it is not queued
behind eleven of our own files, the rest follow once the peer proves it will
serve us, and a copy that makes no progress for 45s is abandoned for the next
ranked one — cancelling the transfers it leaves, which used to trickle on
forever. A refusal falls through to the next peer instead of ending the
record. "Try another peer" is reachable while one is loading, which it never
was.

RE-IMPORTING BUILT A DUPLICATE FACTORY. Apply decided a track was already
filed by comparing the destination's size to its source — but apply itself
rewrites that file, embedding the chosen cover, so it is 582 KB larger from
the moment it is written and the sizes can never match again. Every run laid
down another " (n)" copy with its own library row: three runs left three of
everything, 2.16 GB. A file at the destination that this run did not write is
now simply an earlier import; within one run a repeated name is still a
collision, which is what `kept` is for.

ARTWORK FOLLOWS THE RECORD, ONLY THE AUDIO FOLLOWS THE CHOICE. Apply read
art from whichever folder the audio came from, which for a kept found copy is
the pen — and a downloaded album often carries no picture, so three records
imported with no cover while their own files held a 1500px one. It also only
ever looked at tags, never at the Folder.jpg the review had just offered as
"yours".

COVERS ARE RESAMPLED BY RUST, NOT BY THE COMPOSITOR. The webview was handed
2000px masters to paint 132px tiles — a 15x single-step reduction that reads
as aliasing on photographic covers, and carries megabytes of base64 to draw a
thumbnail. Artist portraits already had this exact fix; album covers never
got it. getArt is now bounded by default and getArtMaster is the deliberate
act, because ~60 places paint a cover and only two want real pixels: with the
polarity the other way round, every surface anyone forgets is silently wrong.
The fullscreen viewer also framed non-square art inside a square element, so
its rounded corners and shadow wrapped 96px of nothing; it takes the
artwork's own ratio now, bounded on both axes.

Smaller, from the same passes: the preview play button is a real transport
rather than a restart, and leaving a record silences its own preview without
touching music started elsewhere; bucket chips navigate with per-lens memory
instead of overwriting the saved resume position; Next counts from the
record's real place in a queue that re-sorts under it, which was skipping one
record for every one reviewed under a filter; a cover URL with nothing behind
it is shown as the empty answer it is rather than a broken image in a
pickable frame; write_tags returns early when there is nothing to change,
instead of rewriting the whole file to write values it already had; and the
import window has a Start over, which says what it costs before it costs it.
2026-08-10 18:08:58 -05:00

304 lines
18 KiB
JavaScript

// Thin wrapper over Tauri IPC so components never import tauri directly.
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { mockInvoke } from "./mockLibrary.js";
// mockInvoke is inert (returns undefined) unless localStorage.mockLibrary === "1".
const invoke = (cmd, args) => mockInvoke(cmd, args) ?? tauriInvoke(cmd, args);
// Artist-page payloads are keyed by a Deezer id all the way through the
// backend. Reject a mismatched response at the IPC boundary so a provider,
// cache, or future command regression cannot populate another artist's page.
// String comparison deliberately accepts route-string ids and numeric Tauri
// ids as the same identity without coercing the u64 argument sent to Rust.
const artistIdKey = (value) => (value == null ? "" : String(value).trim());
const sameArtistId = (left, right) => {
const leftKey = artistIdKey(left);
return !!leftKey && leftKey === artistIdKey(right);
};
const checkedArtistPayload = (command, artistId) =>
invoke(command, { artistId }).then((payload) => {
if (!sameArtistId(payload?.artist?.id, artistId)) {
throw new Error(`${command} returned a mismatched artist identity`);
}
return payload;
});
const checkedArtistIdentity = (artistId, artist) =>
invoke("catalog_artist_identity", { artistId, artist }).then((payload) => {
if (!sameArtistId(payload?.artist_id ?? payload?.artistId, artistId)) {
throw new Error("catalog_artist_identity returned a mismatched artist identity");
}
return payload;
});
// The id is COERCED to a number here, not merely passed along. Deezer album
// ids reach the frontend as strings from two places — SQLite stores the
// recently-played anchor as TEXT, and page seeds carry `String(id)` — and the
// Rust command takes a u64, so a string argument is rejected outright with
// "invalid type: string … expected u64". That made every streamed row in the
// sidebar's history dead on click (2026-08-01). The identity check below still
// compares through `sameArtistId`, which normalizes both sides, so a numeric
// id and its string form remain the same album.
const checkedAlbumPayload = (albumId) => {
const numeric = typeof albumId === "number" ? albumId : Number(String(albumId).trim());
if (!Number.isFinite(numeric)) {
return Promise.reject(new Error(`catalog_album: not an album id: ${albumId}`));
}
return invoke("catalog_album", { albumId: numeric }).then((payload) => {
if (!sameArtistId(payload?.album?.id, albumId)) {
throw new Error("catalog_album returned a mismatched album identity");
}
return payload;
});
};
export const appStatus = () => invoke("app_status");
export const getCredentials = () => invoke("get_credentials");
export const connectAndLogin = (username, password, remember) =>
invoke("connect_and_login", { username, password, remember });
export const connectStored = () => invoke("connect_stored");
export const disconnect = () => invoke("disconnect");
export const startSearch = (query) => invoke("start_search", { query });
export const excludedSearchPhrases = () => invoke("excluded_search_phrases");
export const stopSearch = (token) => invoke("stop_search", { token });
export const downloadFile = (username, path, size) =>
invoke("download_file", { username, path, size });
export const copyStreamToFolder = (files, folder) =>
invoke("copy_stream_to_folder", { files, folder });
export const saveToLibrary = (files) => invoke("save_to_library", { files });
export const cancelDownload = (id) => invoke("cancel_download", { id });
export const showInFolder = (path) => invoke("show_in_folder", { path });
export const refreshArtistArt = () => invoke("refresh_artist_art");
export const playStream = (username, path, size, duration) =>
invoke("play_stream", { username, path, size, duration });
export const playLocal = (path, duration) => invoke("play_local", { path, duration });
export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration });
export const playerControl = (action, value, rate) =>
invoke("player_control", { action, value: value ?? null, rate: rate ?? null });
// What the audio thread is doing right now. The webview only ever learns about
// playback from events, so after a reload this is the only way to find out that
// audio is still running — see `restoreSession`.
export const playerState = () => invoke("player_state");
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
export const libraryAlbums = () => invoke("library_albums");
export const libraryAlbumStats = (minSecs) => invoke("library_album_stats", { minSecs });
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
// `path` anchors the play to a library album via tracks.path (UNIQUE). Without
// it the row still records, but with no album — so it cannot appear in recents.
// `cat` carries catalog identity for a STREAMED play, which never touches the
// local library: { albumId, albumTitle, albumArtist, coverUrl }.
export const recordPlay = (artist, title, path, cat = {}) =>
invoke("record_play", {
artist,
title,
path: path ?? null,
catalogAlbumId: cat.albumId != null ? String(cat.albumId) : null,
albumTitle: cat.albumTitle ?? null,
albumArtist: cat.albumArtist ?? null,
coverUrl: cat.coverUrl ?? null,
});
// Destructive, not undoable: wipes `plays` only. Returns rows removed.
export const clearPlayHistory = () => invoke("clear_play_history");
export const playRowCount = () => invoke("play_row_count");
export const recentAlbums = (limit, minSecs) =>
invoke("recent_albums", { limit: limit ?? null, minSecs: minSecs ?? null });
export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs });
export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist });
// Followed artists — the home redesign's future "new releases by artists you
// follow" reads the same list, which is why the rows carry name and portrait.
export const followedArtists = () => invoke("followed_artists");
export const followArtist = (artistId, name, portrait) =>
invoke("follow_artist", { artistId, name, portrait: portrait ?? null });
export const unfollowArtist = (artistId) => invoke("unfollow_artist", { artistId });
// Home landing page. `homeWarm` walks the follows through the same metadata
// entry points the pages use so every cache the feed reads is hot (measured
// 48s cold for 9 artists — the page itself must never pay that); `homeArtistNews`
// is one paced Google News query per followed artist, answered from a 6h cache.
export const homeWarm = () => invoke("home_warm");
export const homeArtistNews = (artists) => invoke("home_artist_news", { artists });
// The OS media card (Windows SMTC). Only the metadata crosses IPC and only on
// a track change — status and position are pushed by the backend's own player
// event pump, which already has both. See src-tauri/src/smtc.rs.
export const smtcMetadata = (title, artist, album, art) =>
invoke("smtc_metadata", {
title: title ?? "",
artist: artist ?? "",
album: album ?? "",
art: art ?? null,
});
// The WebView blocks target="_blank"; external links open via the backend.
export const openExternal = (url) => invoke("open_external", { url });
// `maxEdge` asks the backend for a Lanczos3 thumbnail bounded to that many
// pixels on the long side. Omit it only where true pixels matter (the
// fullscreen viewer, the import picker's comparison and loupe).
export const albumArt = (path, maxEdge = null) => invoke("album_art", { path, maxEdge });
export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumId });
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
export const getSettings = () => invoke("get_settings");
export const setSettings = (patch) => invoke("set_settings", patch);
export const forgetCredentials = () => invoke("forget_credentials");
export const pickFolder = () => invoke("pick_folder");
export const listPlaylists = () => invoke("list_playlists");
export const createPlaylist = (name) => invoke("create_playlist", { name });
export const deletePlaylist = (id) => invoke("delete_playlist", { id });
export const playlistTracks = (id) => invoke("playlist_tracks", { id });
export const addToPlaylist = (playlistId, tracks) =>
invoke("add_to_playlist", { playlistId, tracks });
export const removeFromPlaylist = (playlistId, position) =>
invoke("remove_from_playlist", { playlistId, position });
export const catalogSearch = (query) => invoke("catalog_search", { query });
export const rememberFusedArtistPortrait = (artistId, portrait) =>
invoke("remember_fused_artist_portrait", { artistId, portrait });
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
export const catalogArtistArt = (artistId) =>
checkedArtistPayload("catalog_artist_art", artistId);
export const catalogArtistLogos = (artistId) => invoke("catalog_artist_logos", { artistId });
// Deferred MusicBrainz pass (socials + MB-only releases). Rate-limited at
// 1 req/s upstream, so it is fetched after the page renders, never before.
export const catalogArtistSocials = (artistId) =>
invoke("catalog_artist_socials", { artistId });
export const catalogArtistBio = (artistId, artist) =>
invoke("catalog_artist_bio", { artistId: artistId ?? null, artist });
export const catalogArtistIdentity = (artistId, artist) =>
checkedArtistIdentity(artistId, artist);
export const catalogCachedCoverLogo = (artistId) =>
invoke("catalog_cached_cover_logo", { artistId });
export const catalogCoverLogo = (artistId, artist, covers) =>
invoke("catalog_cover_logo", { artistId, artist, covers });
export const catalogArtistHeroSeed = (artistId) =>
invoke("catalog_artist_hero_seed", { artistId });
export const catalogArtistOverview = (artistId) =>
checkedArtistPayload("catalog_artist_overview", artistId);
// Popular's fast lane: the Last.fm-ordered rows with listener counts, waiting
// on nothing but the linked MBID and one Last.fm call. Patched over the
// overview's budgeted (cold: unranked) list; the full page still lands last.
export const catalogArtistPopular = (artistId) =>
invoke("catalog_artist_popular", { artistId });
export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId });
export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });
export const catalogRelated = (artistId) => invoke("catalog_related", { artistId });
// Deferred Last.fm pass for search results: `tracks` is [{ artistId, title }]
// and the answer comes back in the same order. Kept OUT of catalog_search,
// which the typeahead calls per keystroke and which must never wait on
// MusicBrainz's 1 req/s gate.
export const catalogTrackListeners = (tracks) =>
invoke("catalog_track_listeners", { tracks });
// Genres + Last.fm totals for the search hero's artist. Deferred, like the rest.
export const catalogArtistProfile = (artistId) =>
invoke("catalog_artist_profile", { artistId });
export const catalogAlbum = (albumId) => checkedAlbumPayload(albumId);
export const catalogAlbumInfo = (artistId, artist, title) =>
invoke("catalog_album_info", { artistId: artistId ?? null, artist, title });
export const catalogArt = (url) => invoke("catalog_art", { url });
export const catalogCover = (artistId, artist, title, cover) =>
invoke("catalog_cover", { artistId: artistId ?? null, artist, title, cover });
export const invalidateCatalogCover = (artistId, artist, title, cover) =>
invoke("invalidate_catalog_cover", { artistId: artistId ?? null, artist, title, cover });
export const catalogHeroPreview = (url) => invoke("catalog_hero_preview", { url });
export const catalogHeroGpuSource = (url, plan = null) =>
invoke("catalog_hero_gpu_source", { url, plan });
export const catalogCachedHeroArt = (url) => invoke("catalog_cached_hero_art", { url });
export const catalogHeroArt = (url, plan = null) =>
invoke("catalog_hero_art", { url, plan });
export const preloadStream = (username, path, size) =>
invoke("preload_stream", { username, path, size });
export const preloadCachedStream = (username, path, size, duration) =>
invoke("preload_cached_stream", { username, path, size, duration });
export const streamWaveform = (username, path, size, buckets) =>
invoke("stream_waveform", { username, path, size, buckets });
export const streamCacheReady = (username, path, size) =>
invoke("stream_cache_ready", { username, path, size });
// Preview tracks (scrollable-track-previews): a fully separate audio path.
// previewResolve returns { ok, lease | failure, retryable } — the signed URL
// never crosses this boundary, only an opaque lease id does.
export const previewResolve = (trackId) => invoke("preview_resolve", { trackId });
// Fire-and-forget session prewarm: pulls every listed clip onto disk ahead
// of the user (paced, capped, cancelled by dispose). The invoke resolves
// when the walk finishes — callers void it.
export const previewPrewarm = (trackIds) => invoke("preview_prewarm", { trackIds });
export const previewLoad = (leaseId, startPaused, crossfadeMs = 0, startAtSecs = null) =>
invoke("preview_load", { leaseId, startPaused, crossfadeMs, startAtSecs });
export const playerFadePauseAcked = (fadeMs) =>
invoke("player_fade_pause_acked", { fadeMs });
export const playerFadeResume = (fadeMs) =>
invoke("player_fade_resume", { fadeMs });
export const debugStats = () => invoke("debug_stats");
// ---- the import tool (see src-tauri/src/import.rs) ------------------------
// Scan is read-only; apply COPIES into the managed root and registers — the
// source folder is never written to.
export const importScan = (root) => invoke("import_scan", { root });
export const importSessionGet = () => invoke("import_session_get");
export const importSessionSet = (state) => invoke("import_session_set", { state });
export const importSessionClear = () => invoke("import_session_clear");
export const importGetDest = () => invoke("import_get_dest");
export const importSetDest = (dir, layout = null) => invoke("import_set_dest", { dir, layout });
export const importApply = (sourceRoot, albums) => invoke("import_apply", { sourceRoot, albums });
// On-demand per-record identification (Deezer try-list → MB fallback) and
// the artwork panel's cover report. Both read-only.
export const importIdentify = (artist, title) => invoke("import_identify", { artist, title });
export const importCoverReport = (artist, title, referenceUrl) =>
invoke("import_cover_report", { artist, title, referenceUrl });
// The record's own artwork, measured (embedded first, else folder images).
export const importLocalArt = (sourceRoot, rel) => invoke("import_local_art", { sourceRoot, rel });
// Prune rows whose files are gone, then index anything new — the real rescan.
export const rescanLibrary = () => invoke("rescan_library");
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
// comparison. Called track 1 first, then the rest — `startIndex` keeps the
// second batch's indices continuing, since index 0 means "track 1".
export const importAudition = (username, rel, files, startIndex = 0) =>
invoke("import_audition", { username, rel, files, startIndex });
export const onImportAudition = (cb) => listen("import:audition", (e) => cb(e.payload));
// Measured facts about one audio file (the version card's quality chips).
export const importProbeFile = (path) => invoke("import_probe_file", { path });
export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload));
export const onImportScanFolder = (cb) => listen("import:scan-folder", (e) => cb(e.payload));
export const onImportApply = (cb) => listen("import:apply", (e) => cb(e.payload));
export const previewControl = (action, value = null) =>
invoke("preview_control", { action, value });
export const previewDispose = () => invoke("preview_dispose");
// Main-player silence acknowledgement (audio-focus contract). Throws on
// timeout — callers keep preview parked; they never assume silence.
export const playerPauseAcked = () => invoke("player_pause_acked");
// Window chrome (the app draws its own titlebar; decorations are off).
// Guarded like onEvent below: getCurrentWindow() THROWS outside Tauri, and
// it runs at mount via initWindowActivity — un-guarded it made every plain
// `vite` browser load a blank page, which also blocked browser-side preview
// fixtures. In-Tauri behavior is unchanged.
import { getCurrentWindow } from "@tauri-apps/api/window";
const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
const noWin = {
minimize: () => {},
toggleMaximize: () => {},
close: () => {},
isMinimized: async () => false,
isFocused: async () => true,
isMaximized: async () => false,
onResized: async () => () => {},
onFocusChanged: async () => () => {},
};
export const win = !inTauri
? noWin
: {
minimize: () => getCurrentWindow().minimize(),
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
close: () => getCurrentWindow().close(),
isMinimized: () => getCurrentWindow().isMinimized(),
isFocused: () => getCurrentWindow().isFocused(),
isMaximized: () => getCurrentWindow().isMaximized(),
// returns Promise<unlisten>
onResized: (handler) => getCurrentWindow().onResized(handler),
onFocusChanged: (handler) => getCurrentWindow().onFocusChanged(handler),
};
// Subscribe to a backend event; returns an unlisten function.
// No-ops outside Tauri (e.g. plain-browser preview).
export function onEvent(name, handler) {
const un = listen(name, (e) => handler(e.payload)).catch(() => () => {});
return () => un.then((f) => f());
}