// 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\\ 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 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()); }