Files
SoulStream/src/lib/api.js
T
KeelrLeek 17942fc04b android auto: the car plays what is here, resumes, and is told the rest — soulseek dials back, and the storm is over
Four things the car could not do, all from a process with no Activity.

A streamed playlist row plays cold when its bytes are already here. The
page's memory of which peer file a catalog track resolved to lived only in
localStorage; it is mirrored now to `<data>/stream-sources.json`
(stream_sources.rs) — every remembered source, and the whole store once at
boot — and auto_browse.rs looks a row up there, computes its cache file
with the same hash playback::cache_path uses, and calls it playable when
the file is byte-complete. GhettoTechie reads "8 of 40 here" cold and a
tapped row plays from the cache with its catalog cover and colour.

The car resumes, the way every other player does. Where playback was is in
`<data>/resume.json` (resume.rs): the page names the queue whenever it
writes its session, the engine's pump keeps the position. media3's
MediaButtonReceiver is declared, so a PLAY key into a dead app — a
Bluetooth head unit, the Quick Settings card, Auto's autoplay on
connection — starts the service and lands in onPlaybackResumption, which
answers with the `ss:resume` row; the RECENT root serves the same row, and
the Recent tab lists it first. A force-stopped app came back PLAYING where
it left off from one `cmd media_session dispatch play`.

The car is told. A tap that cannot play — a streamed row with no cache copy
while the app is cold, a hand-off the resolver refused — used to be
silence. MediaHub.fail puts the reason in the playback state (STATE_ERROR,
which Auto draws as a card) and sends a SessionError carrying "Open
SoulStream"; the next track withdraws it. The page reports its own verdict
through the new `remote_failed` command.

Every tap while the app is UP is the page's. A library row tapped in the
car used to start on the engine underneath the page's own queue — the bar
naming one track while another played. `AutoNative.describe` spells the
row for the page and `playMediaId` plays it as the page's queue; the plugin
disarms the hand-off in onDestroy, and with the page gone the card's
transport goes to the engine. When the app opens over a car session, the
headless queue is handed to the page once (`playback::init` takes it before
clearing) and restoreSession builds the bar from it.

Every playlist row is playable: Auto draws a non-playable, non-browsable
item as a tile and BROWSES it on tap ("No items"). Rows that need the app
say so and fail on tap with the card; rows whose file is here wear the
host's own downloaded mark. The Recent tab's album subtitle counted played
tracks; it counts the album's now. The page never clears the resume point
and only drops its own session once it has booted — a visibility change
under a launch with the screen off wiped both.

The Soulseek session dials itself back after a drop. A server connection
dies for reasons that are nobody's — the phone changes networks, Android
freezes a backgrounded process past the five-minute keepalive — and until
now every one of those ended as `Event::Disconnected`, the page stamped
offline, and every streamed track after it refused with "sign in on
Community"; in a car nobody opens the login card. `schedule_reconnect`
redials with the stored credentials at 5, 15, 45, 120, then 300 s with
jitter, under the server's rapid-login throttle. Every session carries a
generation: an explicit login or disconnect bumps it, a redial loop stands
down when the user acts, and a drop reported by an OLDER session's pump
(login_inner shuts the old client down and its reader reports after the new
session is up) no longer stamps the fresh session offline — that race was
there all along. The page shows "connecting" on `reconnecting`, the
resolver waits up to twenty seconds for a dial in progress, and debug
builds expose `debug_drop_connection`. Verified by the debug command (drop →
redial 1 in 5 s → online at 7 s) and by a real drop when the head unit
connected: redial 1 failed on DNS, redial 2 at +15 s logged in.

The notification storm was media3 restarting the service into
onStartCommand. The morning's logcat had the card re-posted six times a
second for over a minute ("Package enqueue rate is 6.6 … Shedding"), then
"Too many PendingIntent created for uid" (media3 mints a fresh one for the
custom button on every rebuild), then twenty refused foreground starts in
two hundred milliseconds. media3 starts this service ITSELF on every
foreground notification update — MediaNotificationManager.startForeground
calls startForegroundService(startSelfIntent), a bare component intent with
nothing but a uid extra — and onStartCommand promoted on every intent it
saw, which re-posted the card, which made media3 start the service again.
It ran for as long as the service was foreground: while a track played,
and for the ten-minute tail after a pause (the capture stops at pause +
10:00 exactly). onStartCommand now promotes only for an intent that carries
an action or a data uri. A 250 ms coalescer on card rebuilds
(MediaHub.refreshSoon) logs the rate; it stayed silent through the storm,
which is what pointed at the restart. A full minute of playback after the
fix: zero shedding lines, zero PendingIntent complaints, zero refused
starts.

Verified on the Desktop Head Unit and the Pixel: the tree, the Continue
row, cold cached playback, the error card and recovery from it, the warm
hand-off, the media-key resume, the redial; mock walk 255/255, phone walk
142/142.
2026-09-18 12:35:54 -05:00

644 lines
41 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 "./events.js";
import { mockInvoke } from "./mockLibrary.js";
import { countBoot } from "./boot.js";
// mockInvoke is inert (returns undefined) unless localStorage.mockLibrary === "1".
/* the boot census (src/lib/boot.js) counts what the app asks for before its
first frame and stops there, so this is one comparison for the rest of the
run. Inert on the desktop: nothing seals it there and nothing reads it. */
const invoke = (cmd, args) => { countBoot(cmd); return 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 });
// Byte length of the cached copy of one peer file (null when uncached) —
// compared against the peer-stated size to tell "finished" from "prefix".
export const streamCacheLen = (username, path) =>
invoke("stream_cache_len", { username, path });
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, startSecs = null) =>
invoke("play_local", { path, duration, startSecs });
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");
// The mini player window (see mini/ and miniBridge.svelte.js).
export const miniOpen = (width, height) => invoke("mini_open", { width, height });
export const miniClose = () => invoke("mini_close");
export const waveform = (path, buckets, startSecs = null, endSecs = null) =>
invoke("waveform", { path, buckets, startSecs, endSecs });
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 });
// ---- the Soulseek hub: rooms / chat / buddies / browse ----
export const slskJoinRoom = (room) => invoke("slsk_join_room", { room });
export const slskLeaveRoom = (room) => invoke("slsk_leave_room", { room });
export const slskSayRoom = (room, message) => invoke("slsk_say_room", { room, message });
export const slskMessageUser = (username, message) =>
invoke("slsk_message_user", { username, message });
export const slskRoomList = () => invoke("slsk_room_list");
export const slskBrowseUser = (username) => invoke("slsk_browse_user", { username });
export const slskUserInfo = (username) => invoke("slsk_user_info", { username });
export const slskUserInterests = (username) => invoke("slsk_user_interests", { username });
export const slskSimilarUsers = () => invoke("slsk_similar_users");
export const slskInterestsMine = () => invoke("slsk_interests_mine");
export const slskInterestSet = (thing, like, on) => invoke("slsk_interest_set", { thing, like, on });
export const slskLinkStatus = () => invoke("slsk_link_status");
export const slskLinkSelf = () => invoke("slsk_link_self");
export const slskLinkUnlink = () => invoke("slsk_link_unlink");
export const slskLinkCheck = (username) => invoke("slsk_link_check", { username });
export const slskLinkForget = (username) => invoke("slsk_link_forget", { username });
export const friendsList = () => invoke("friends_list");
export const friendRequest = (username) => invoke("friend_request", { username });
export const friendRequestByKey = (personaKey) => invoke("friend_request_by_key", { personaKey });
export const friendAccept = (id) => invoke("friend_accept", { id });
export const friendDecline = (id) => invoke("friend_decline", { id });
export const friendRemove = (id) => invoke("friend_remove", { id });
export const friendForget = (id) => invoke("friend_forget", { id });
export const sharedPlaylistsMine = () => invoke("shared_playlists_mine");
export const sharedPlaylistPublish = (playlistId) => invoke("shared_playlist_publish", { playlistId });
export const sharedPlaylistWithdraw = (playlistId) => invoke("shared_playlist_withdraw", { playlistId });
export const sharedPlaylistFetch = (owner, uuid) => invoke("shared_playlist_fetch", { owner, uuid });
export const sharedPlaylistImport = (owner, uuid) => invoke("shared_playlist_import", { owner, uuid });
export const shareSend = (username, playlistId) => invoke("share_send", { username, playlistId });
export const sharesInbox = () => invoke("shares_inbox");
export const shareDismiss = (id) => invoke("share_dismiss", { id });
export const sharedPlaylistsFollowing = () => invoke("shared_playlists_following");
export const sharedPlaylistFollow = (owner, uuid, playlistId) => invoke("shared_playlist_follow", { owner, uuid, playlistId });
export const sharedPlaylistUnfollow = (owner, uuid) => invoke("shared_playlist_unfollow", { owner, uuid });
export const sharedPlaylistsCheck = () => invoke("shared_playlists_check");
export const sharedPlaylistFollowUpdate = (owner, uuid) => invoke("shared_playlist_follow_update", { owner, uuid });
export const inboxStatus = () => invoke("inbox_status");
export const inboxPublish = () => invoke("inbox_publish");
export const inboxCheck = () => invoke("inbox_check");
export const spacesList = () => invoke("spaces_list");
export const spaceCreate = (name, description, isPrivate) => invoke("space_create", { name, description, private: isPrivate });
export const spaceOpen = (owner, space) => invoke("space_open", { owner, space });
export const spacePost = (owner, space, text) => invoke("space_post", { owner, space, text });
export const spaceInvite = (owner, space, personaKey) => invoke("space_invite", { owner, space, personaKey });
export const spaceRemoveMember = (owner, space, personaKey) => invoke("space_remove_member", { owner, space, personaKey });
export const spaceSetPinned = (owner, space, playlistOwner, playlistUuid) => invoke("space_set_pinned", { owner, space, playlistOwner, playlistUuid });
export const spaceLeave = (owner, space) => invoke("space_leave", { owner, space });
export const spaceSetShareListens = (owner, space, on) => invoke("space_set_share_listens", { owner, space, on });
export const spaceReportListen = (title, artist, album, durationSecs, trackId) =>
invoke("space_report_listen", { title, artist, album, durationSecs, trackId });
export const spaceBoard = (owner, space) => invoke("space_board", { owner, space });
export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note });
export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
export const overlapList = () => invoke("overlap_list");
export const holdingsSetShare = (owner, space, on) => invoke("holdings_set_share", { owner, space, on });
export const holdingsRoom = (owner, space, refresh = false) => invoke("holdings_room", { owner, space, refresh });
export const holdingsAsk = (items) => invoke("holdings_ask", { items });
export const holdingsFindFolder = (username, artist, album) => invoke("holdings_find_folder", { username, artist, album });
export const holdingsRepublish = () => invoke("holdings_republish");
export const holdingsWaits = () => invoke("holdings_waits");
export const holdingsWaitAdd = (username, artist, album, via = null) => invoke("holdings_wait_add", { username, artist, album, via });
export const holdingsWaitRemove = (username, artist, album) => invoke("holdings_wait_remove", { username, artist, album });
export const overlapCompute = (username) => invoke("overlap_compute", { username });
export const overlapForget = (username) => invoke("overlap_forget", { username });
export const buddyList = () => invoke("buddy_list");
export const buddyAdd = (username) => invoke("buddy_add", { username });
export const buddyRemove = (username) => invoke("buddy_remove", { username });
// `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 });
/** Warm the disk thumbnail cache for the whole library at the given edges —
* [{warm, decoded}] per edge. Slow by design (one decode at a time); callers
* fire-and-forget it. */
export const libraryArtPrewarm = (edges) => invoke("library_art_prewarm", { edges });
/** The cover embedded in a streamed track's own cache file, extracted to a
* local image path — null while the transfer is too young to read, or when
* the peer's file simply carries no picture. */
export const streamEmbeddedArt = (username, path) =>
invoke("stream_embedded_art", { username, path });
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);
// The in-app updater (updater.rs): status, a check on the current channel,
// settings (channel / autoCheck / endpointStable / endpointNightly), and the
// install — which on Windows ends this process when it succeeds.
export const updaterStatus = () => invoke("updater_status");
export const updaterCheck = () => invoke("updater_check");
export const updaterSet = (patch) => invoke("updater_set", patch);
export const updaterInstall = () => invoke("updater_install");
export const windowGlass = (on) => invoke("window_glass", { on });
export const forgetCredentials = () => invoke("forget_credentials");
export const pickFolder = () => invoke("pick_folder");
// ---- playlists (see src-tauri/src/playlists.rs) ---------------------------
// Rows are OCCURRENCES with their own ids: the same recording may appear
// twice, and every mutation addresses item ids, never positions.
export const listPlaylists = () => invoke("list_playlists");
export const createPlaylist = (name, kind = null) => invoke("create_playlist", { name, kind });
export const renamePlaylist = (id, name) => invoke("rename_playlist", { id, name });
export const setPlaylistDescription = (id, description) =>
invoke("set_playlist_description", { id, description: description ?? null });
export const setPlaylistPinned = (id, pinned) => invoke("set_playlist_pinned", { id, pinned });
export const duplicatePlaylist = (id, name) => invoke("duplicate_playlist", { id, name });
export const deletePlaylist = (id) => invoke("delete_playlist", { id });
export const playlistItems = (id) => invoke("playlist_items", { id });
export const playlistAddItems = (playlistId, items, at = null) =>
invoke("playlist_add_items", { playlistId, items, at });
export const playlistRemoveItems = (playlistId, itemIds) =>
invoke("playlist_remove_items", { playlistId, itemIds });
export const playlistSetOrder = (playlistId, orderedIds) =>
invoke("playlist_set_order", { playlistId, orderedIds });
// Repair one row: fields null = leave as stored; path non-null relinks the
// row to a playable file.
export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
invoke("playlist_relink_item", {
playlistId,
itemId,
title: fields.title ?? null,
artist: fields.artist ?? null,
album: fields.album ?? null,
durationSecs: fields.durationSecs ?? null,
path: fields.path ?? null,
artPath: fields.artPath ?? null,
trackId: fields.trackId ?? null,
artUrl: fields.artUrl ?? null,
});
// Cover as a data URL: custom master, else composed mosaic, else null.
export const playlistCover = (id, maxEdge = null) =>
invoke("playlist_cover", { id, maxEdge });
export const playlistCoverPick = (id) => invoke("playlist_cover_pick", { id });
export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });
export const playlistCoverSet = (id, path) => invoke("playlist_cover_set", { id, path });
// Import: probe stages a reviewed draft from a playlist file; text stages
// a pasted tracklist; commit writes the accepted draft in one transaction.
// The desktop passes neither argument and opens its own dialog; the phone
// picks with the storage plugin and hands the path in — with the picked
// document's own name, which is what chooses the parser and titles the
// draft (a copied file is not always named what the document was).
export const playlistImportProbe = (path = null, name = null) =>
invoke("playlist_import_probe", { path, name });
// The desktop opens its own folder dialog (no path); the phone picks with
// the storage plugin and hands the resolved path in.
export const playlistImportFolder = (path = null) => invoke("playlist_import_folder", { path });
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
export const playlistImportYoutubePlaylist = (url) =>
invoke("playlist_import_youtube_playlist", { url });
// One door for every service link: YouTube, Spotify, Apple Music, TIDAL, Deezer.
export const playlistImportLink = (url) => invoke("playlist_import_link", { url });
// A pasted block of TRACK links (any supported service), looked up one page
// each — the way past Spotify's 100-track public ceiling.
export const playlistImportTrackLinks = (text, name = null) =>
invoke("playlist_import_track_links", { text, name });
// The set LISTENER (src-tauri/src/setlist_scan.rs + ytdlp.rs): recognize a
// mix's tracks from its audio. Progress rides "playlist:scan-progress".
export const playlistScanYoutube = (url) => invoke("playlist_scan_youtube", { url });
// The local door is split pick/scan so the modal can collect an optional
// pasted tracklist between the two.
export const playlistPickSetFile = () => invoke("playlist_pick_set_file");
export const playlistScanFile = (path, tracklist = null) =>
invoke("playlist_scan_file", { path, tracklist });
export const playlistScanCancel = () => invoke("playlist_scan_cancel");
export const ytdlpStatus = () => invoke("ytdlp_status");
export const ytdlpDownload = () => invoke("ytdlp_download");
export const denoDownload = () => invoke("deno_download");
export const playlistImportCommit = (name, description, kind, entries, coverUrl = null) =>
invoke("playlist_import_commit", {
name,
description: description ?? null,
kind,
entries,
coverUrl,
});
// Export through whichever save door the platform has; format ∈ m3u8 |
// xspf | pls | csv | json. Resolves to where the file went — the written
// path on the desktop, the document's name on the phone (Android's
// create-document sheet answers a content:// URI, not a path) — or null
// when that sheet was dismissed.
export const playlistExport = (id, format) => invoke("playlist_export", { id, format });
// ---- lyric word-timing preparation (src-tauri/src/lyric_prep.rs) ----------
export const lyricModelStatus = () => invoke("lyric_model_status");
export const lyricModelDownload = () => invoke("lyric_model_download");
// Vocal separation model (src-tauri/src/vocal_sep.rs) — the ctc-v5 stem tier.
export const vocalSepStatus = () => invoke("vocal_sep_status");
export const vocalSepDownload = () => invoke("vocal_sep_download");
export const lyricPrepRequest = (path, durationSecs, lines, stream = null) =>
invoke("lyric_prep_request", {
path: path ?? null,
stream,
durationSecs: durationSecs ?? null,
lines,
});
// Local-library candidates for the unresolved-row repair panel.
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
// The whole index, for a list that filters on the device (the phone's Tracks tab).
export const libraryTrackIndex = () => invoke("library_track_index");
// ---- lyrics (see src-tauri/src/lyrics.rs) ----------------------------------
export const lyricsGet = (args) =>
invoke("lyrics_get", {
title: args.title,
artist: args.artist,
album: args.album ?? null,
durationSecs: args.durationSecs ?? null,
path: args.path ?? null,
refresh: args.refresh ?? null,
});
export const lyricsSearch = (title, artist) => invoke("lyrics_search", { title, artist });
export const lyricsSelect = (title, artist, durationSecs, candidateId) =>
invoke("lyrics_select", { title, artist, durationSecs: durationSecs ?? null, candidateId });
export const lyricsSetOffset = (title, artist, durationSecs, offsetMs) =>
invoke("lyrics_set_offset", { title, artist, durationSecs: durationSecs ?? null, offsetMs });
export const lyricsSaveText = (title, artist, durationSecs, text) =>
invoke("lyrics_save_text", { title, artist, durationSecs: durationSecs ?? null, text });
export const lyricsClear = (title, artist, durationSecs) =>
invoke("lyrics_clear", { title, artist, durationSecs: durationSecs ?? null });
export const lyricsSetAutoSync = (title, artist, durationSecs, enabled) =>
invoke("lyrics_set_auto_sync", { title, artist, durationSecs: durationSecs ?? null, enabled });
// Offline vocal-reduction render (band-limited center cancel — honest DSP,
// not a learned separator). Resolves to { path, cached } of a playable WAV.
export const lyricsPrepareInstrumental = (path) =>
invoke("lyrics_prepare_instrumental", { path });
// Vocal-band activity envelope for the sync editor ({ frameMs, frames }).
export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { path });
// Persist hand-synced lines as the track's own document (wins the waterfall).
export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
export const catalogSearch = (query) => invoke("catalog_search", { query });
// Track rows only — one Deezer call, and the query may use Deezer's field
// syntax (artist:"…" track:"…"). The identity lookups use this; the search
// PAGE still uses catalogSearch, which also wants artists and albums.
export const catalogSearchTracks = (query, limit = 10) =>
invoke("catalog_search_tracks", { query, limit });
// The artist page's whole shelf — Deezer plus the MusicBrainz/Apple
// supplements — flattened to tracks, for lookups the search index failed.
export const catalogArtistTrackCandidates = (artist, title) =>
invoke("catalog_artist_track_candidates", { artist, title });
export const catalogTrackByIsrc = (isrc) => invoke("catalog_track_by_isrc", { isrc });
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 });
// The phone's hero door: a photo the storage plugin already copied into the
// app's cache, installed the way the desktop's dialog pick installs one.
export const setArtistHero = (artistId, path) => invoke("set_artist_hero", { artistId, path });
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 });
/** The page's memory of a streamed track's peer file, mirrored to the Rust
* side (`stream_sources.rs`) so a process with no page — the media service
* a car starts — can tell which cache files are whole playlist rows. One
* entry when a source is remembered, the whole store once at boot. */
export const streamSourceRemember = (entries) => invoke("stream_source_remember", { entries });
/** Pin (or unpin) a track's remembered source for the cache pruner. */
export const streamSourceKeep = (trackId, artist, title, keep) =>
invoke("stream_source_keep", { trackId: trackId != null ? String(trackId) : null, artist, title, keep });
/** Name the queue for the resume point (`resume.rs`): what a cold PLAY
* would put back on. */
export const playbackNoteQueue = (items, idx, position, from) =>
invoke("playback_note_queue", { items, idx, position, from });
/** Where playback was left, as the Rust side holds it — null when nothing. */
export const playbackResumePoint = () => invoke("playback_resume_point");
/** The playlists kept offline, and the switch. */
export const playlistOfflineList = () => invoke("playlist_offline_list");
export const playlistOfflineSet = (id, keep) => invoke("playlist_offline_set", { id, keep });
/** Tell the media session that a row the car handed over did not play. */
export const remoteFailed = (message) => invoke("plugin:media-session|remote_failed", { failure: { message } });
/** DEBUG builds only: sever the server connection like a network drop would. */
export const debugDropConnection = () => invoke("debug_drop_connection");
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");
// The source folder relative to the library folder, and back again. Saving
// both is what lets a review started on one machine resume on another after
// the drive lands at a different letter.
export const importPortableRoot = (root) => invoke("import_portable_root", { root });
export const importResolveRoot = (saved, rel) => invoke("import_resolve_root", { saved, rel });
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.
// `title` is the record's own claim (tags); `folder` is the second witness,
// asked after it — a record whose tags are wrong still has its folder name.
// `shape` is everything ELSE the scan knows — track count, track titles, year,
// and where the files are so Rust can hash the record's own cover. That is what
// the discography pass compares when no phrase can find the record; the cover
// itself never crosses IPC (a master can be 8 MB).
export const importIdentify = (artist, title, folder = null, shape = {}) =>
invoke("import_identify", {
artist, title, folder,
tracks: shape.tracks ?? null,
trackTitles: shape.trackTitles ?? null,
year: shape.year ?? null,
sourceRoot: shape.sourceRoot ?? null,
rel: shape.rel ?? null,
// Bounds the cover hash to the record's own audio — a folded release
// gathers from these, an ordinary one from its folder alone.
parts: shape.parts ?? null,
});
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, parts) =>
invoke("import_local_art", { sourceRoot, rel, parts });
// Prune rows whose files are gone, then index anything new — the real rescan.
export const rescanLibrary = () => invoke("rescan_library");
// The phone's storage plugin (crates/tauri-plugin-storage): a picked folder
// as { uri, path } (path null when the tree has none), and Android's
// all-files access — { granted }, and the system screen that grants it.
export const pickLibraryFolder = () => invoke("plugin:storage|pick_folder");
export const allFilesAccess = () => invoke("plugin:storage|all_files_access");
export const requestAllFilesAccess = () => invoke("plugin:storage|request_all_files_access");
// The phone's share plugin (crates/tauri-plugin-share): what another app
// shared to SoulStream, { text } — read once.
export const pendingShare = () => invoke("plugin:share|pending_share");
// The phone's downloads notice (crates/tauri-plugin-transfer-notice): what
// Save is still fetching, mirrored into the shade; `clear` takes it down.
export const transferNotice = (state) => invoke("plugin:transfer-notice|update", { state });
export const clearTransferNotice = () => invoke("plugin:transfer-notice|clear");
// A photo from the phone (the storage plugin): { path } in the app's cache.
export const pickImage = () => invoke("plugin:storage|pick_image");
// A FILE from the phone (the storage plugin): { path, name, size, copied }.
// `mimeTypes` is the document sheet's filter, comma-separated; `maxCopyBytes`
// is how big a document may be copied into the app's cache when it has no
// path of its own on this phone — 0 refuses the copy, which is what an
// audio-sized pick wants. The name comes back beside the path because a
// copy is named after the document and only the document's name is it.
export const pickFile = (mimeTypes, maxCopyBytes = 0) =>
invoke("plugin:storage|pick_file", { mimeTypes, maxCopyBytes });
// 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));
// Which of a found copy's files the pen does not (fully) hold. Transfers die
// with the app and the arrived-count dies with them; the files on disk are
// what is left to ask.
export const importAuditionMissing = (rel, files) =>
invoke("import_audition_missing", { rel, files });
// Empty a record's pen when its peer is abandoned; returns the bytes freed.
// The pen is keyed by RECORD, so without this the dropped peer's files sit
// beside the replacement's until the session is cleared.
export const importAuditionClear = (rel) => invoke("import_audition_clear", { rel });
// 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());
}
// ---- the Community area: Freenet layer, persona, profile, local contacts ----
export const freenetStatus = () => invoke("freenet_status");
export const freenetSetEnabled = (enabled) => invoke("freenet_set_enabled", { enabled });
export const freenetDetect = () => invoke("freenet_detect");
export const freenetSetLoadRemoteImages = (on) => invoke("freenet_set_load_remote_images", { on });
export const freenetNodeInfo = () => invoke("freenet_node_info");
export const freenetNodeStart = () => invoke("freenet_node_start");
export const freenetNodeStop = () => invoke("freenet_node_stop");
export const personaStatus = () => invoke("persona_status");
export const personaCreate = () => invoke("persona_create");
export const personaExport = (passphrase, path) => invoke("persona_export", { passphrase, path });
export const personaImport = (passphrase, path, force = false) =>
invoke("persona_import", { passphrase, path, force });
export const personaRotate = () => invoke("persona_rotate");
export const personaRemove = () => invoke("persona_remove");
export const profileGet = (persona = null, force = false) => invoke("profile_get", { persona, force });
export const profilePublish = (displayName, bio) => invoke("profile_publish", { displayName, bio });
export const profileSetAvatar = (imagePath) => invoke("profile_set_avatar", { imagePath });
export const profileClearAvatar = () => invoke("profile_clear_avatar");
export const contactsMetaList = () => invoke("contacts_meta_list");
export const contactMetaSet = (meta) => invoke("contact_meta_set", { meta });
/** kind: "image" | "recovery-open" | "recovery-save" -> a path, or null. */
export const communityPickFile = (kind) => invoke("community_pick_file", { kind });