mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bda2822db5 |
-203
@@ -40,65 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Offline — unified local playback, library index join, and favorites sync
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)**
|
||||
|
||||
* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket.
|
||||
* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution.
|
||||
* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header.
|
||||
* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind.
|
||||
* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically.
|
||||
* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist.
|
||||
* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count.
|
||||
|
||||
|
||||
|
||||
### Themes — community Theme Store
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020), [#1036](https://github.com/Psychotoxical/psysonic/pull/1036), [#1038](https://github.com/Psychotoxical/psysonic/pull/1038), [#1041](https://github.com/Psychotoxical/psysonic/pull/1041)**
|
||||
|
||||
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
|
||||
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
|
||||
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
|
||||
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
|
||||
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
|
||||
* Each store theme shows its **total downloads** and a **last-changed** date, and can be sorted by most popular, newest or name; the catalogue now has numbered page navigation. These stats refresh once a day.
|
||||
* The **Now Playing** page now follows the active theme end to end — light themes render it legibly instead of washed-out, with no per-theme tweaks needed.
|
||||
* The sidebar now shows a small notice when one of your installed themes has an update available — click it to jump straight to the Theme Store, or dismiss it until the next update. Themes with an update also show an update control right on their card under **Settings → Themes** to update them in place with one click, and the store refreshes once on startup so new themes and updates show up without hitting refresh.
|
||||
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
|
||||
|
||||
|
||||
|
||||
### Offline — local-bytes browse when the server is down
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1017](https://github.com/Psychotoxical/psysonic/pull/1017)**
|
||||
|
||||
* When the active server is unreachable, browse and detail pages read from **local playback bytes** and the **library index** instead of Subsonic — albums, artists, tracks, cached playlists, and cross-server favorites.
|
||||
* Single integration contract: `offlineBrowseContext`, `offlineActionPolicy`, and `resolveAlbum` / `resolveArtist` / `resolvePlaylist` resolvers; context menus and detail toolbars block server mutations offline.
|
||||
* Disconnect navigation forks by offline capability (stay on page, stay-reload, or redirect); Home reuses the last cached feed snapshot; DEV offline toggle simulates full disconnect for testing.
|
||||
* PlayerBar hides star rating and favorite controls while offline browse is active.
|
||||
|
||||
|
||||
|
||||
### Startup — themed loading splash before the app bundle loads
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1030](https://github.com/Psychotoxical/psysonic/pull/1030)**
|
||||
|
||||
* Inline splash in `index.html` (progress bar + P logo) shows while the Vite bundle loads in dev and production — no empty or black window on launch.
|
||||
* Splash colours follow the persisted theme (built-in palettes, day/night scheduler, and installed community themes); the logo uses each theme's accent gradient instead of a hardcoded white asset.
|
||||
* The native window stays hidden until the splash has painted (`visible: false` + deferred `show` from Rust/JS); window-state restore no longer overrides startup visibility.
|
||||
|
||||
|
||||
|
||||
### Servers — software and version on each server card
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1045](https://github.com/Psychotoxical/psysonic/pull/1045)**
|
||||
|
||||
* Each server card under **Settings → Servers** now shows the server software and version (e.g. `Navidrome 0.62.0`) under the server name. The value comes from the existing connection ping, so no extra request is made; it is hidden for servers that don't report it (plain Subsonic without OpenSubsonic).
|
||||
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Dependencies — npm and Rust refresh
|
||||
@@ -119,68 +60,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Playback — Preload Next Track setting removed
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1007](https://github.com/Psychotoxical/psysonic/pull/1007)**
|
||||
|
||||
* The **Preload Next Track** toggle and timing modes under **Settings → Storage → Buffering** are gone — ranged streaming now starts playback without that extra RAM prefetch.
|
||||
* Gapless and crossfade still prefetch the next track internally when Hot Cache is off; Hot Cache is unchanged.
|
||||
|
||||
|
||||
|
||||
### PsyLab — Performance Probe rename, Tuning tab, and log tools
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1027](https://github.com/Psychotoxical/psysonic/pull/1027)**
|
||||
|
||||
* **Ctrl+Shift+D** opens **PsyLab** (formerly Performance Probe). Cover backfill thread tuning moved to a new **Tuning** tab.
|
||||
* **Logs** tab: selectable text, toolbar copy/export, and a context-menu **Copy** for the current selection.
|
||||
* Runtime log lines are sanitized before they enter the buffer — Subsonic/auth tokens and remote hostnames are redacted or partially masked; LAN and localhost addresses stay readable.
|
||||
|
||||
|
||||
|
||||
### PsyLab — Connections tab and Navidrome admin role
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
|
||||
|
||||
* New **Connections** tab: session/endpoint status, active-server capability readout (OpenSubsonic, AudioMuse detection, provider/strategy, detection trust, resolved call route, and AudioMuse mode), and queue-playback server when it differs from the active profile.
|
||||
* Navidrome **admin vs standard user** badge via native login probe — useful when diagnosing plugin/settings visibility.
|
||||
|
||||
|
||||
|
||||
### Servers — capability framework with AudioMuse sonic routing (Navidrome ≥ 0.62)
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
|
||||
|
||||
* New declarative **server-capability framework** (`src/serverCapabilities/`): a catalog picks a feature strategy per server generation, runs only the needed probes, and routes API calls — replacing scattered version checks in the UI and call sites.
|
||||
* Navidrome **0.62+**: detect the AudioMuse-AI plugin from `getOpenSubsonicExtensions` when `sonicSimilarity` is advertised — the first reliable signal. Settings shows an **auto-managed status indicator** (no manual toggle); older Navidrome keeps the manual toggle and the legacy `getSimilarSongs` Instant Mix probe.
|
||||
* **Path routing**: Instant Mix and Lucky Mix prefer the OpenSubsonic `getSonicSimilarTracks` endpoint when the plugin is present, falling back to legacy `getSimilarSongs`.
|
||||
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
### Now Playing — cards no longer blank out on track change
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1042](https://github.com/Psychotoxical/psysonic/pull/1042)**
|
||||
|
||||
* The "from this album", "discography" and "most played" sections on the Now Playing page disappeared after a track change once the next track started playing from the local cache, and didn't come back. The page now keeps loading that info whenever the server is reachable, regardless of whether the audio plays from cached or offline bytes.
|
||||
|
||||
|
||||
|
||||
### Now Playing — metadata reads from the local library index first
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1049](https://github.com/Psychotoxical/psysonic/pull/1049)**
|
||||
|
||||
* The "from this album", "discography", "most played" and song details on the Now Playing page now come from the local library index when it has them, only falling back to the server when the index can't serve a row. Cards and fields (genre, play count, contributors) stay populated during cached and offline playback, with fewer server requests.
|
||||
|
||||
|
||||
|
||||
### Library DB — named slow-write ops for stall diagnosis
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1043](https://github.com/Psychotoxical/psysonic/pull/1043)**
|
||||
|
||||
* Production `library-db` write paths now log stable `module.action` op names instead of the generic `misc`, so the next `[library-db] SLOW write` line on macOS (or elsewhere) identifies the call site — diagnostic step for playback stalls under long write-lock holds ([#1040](https://github.com/Psychotoxical/psysonic/issues/1040)).
|
||||
|
||||
### Servers — complete border on the active server card
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#998](https://github.com/Psychotoxical/psysonic/pull/998)**
|
||||
@@ -198,90 +79,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Playback — macOS stutter from background device checks
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1039](https://github.com/Psychotoxical/psysonic/pull/1039)**
|
||||
|
||||
* On some macOS setups playback stuttered at a steady ~3-second cadence: a background check that scans every audio output device ran on each poll and briefly contended with playback. It now runs only when a specific output device is pinned; with the system default (the common case) a single lightweight check runs instead.
|
||||
|
||||
|
||||
|
||||
### Track preview — Symphonia 0.6 format hints and fast stream start
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
|
||||
|
||||
* Preview resolves container format from HTTP headers, Subsonic `suffix`, and magic-byte sniff so Symphonia 0.6 no longer fails with `.unknown` demuxer errors.
|
||||
* Preview opens via ranged HTTP when the server supports byte ranges — audio starts after ~384 KiB buffered instead of waiting for a full-file download; buffered fallback uses the same probe seek-gate as main playback.
|
||||
* Player bar cover guard while preview metadata loads; progress ring leaves the loading spinner once the engine emits `audio:preview-start`.
|
||||
|
||||
|
||||
|
||||
### Mainstage — hero backdrop stays in sync when skipping albums quickly
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1021](https://github.com/Psychotoxical/psysonic/pull/1021)**
|
||||
|
||||
* Rapid prev/next clicks on the Mainstage hero no longer leave the blurred cover-art background on the previous album while the foreground cover and metadata already show the next one.
|
||||
|
||||
|
||||
|
||||
### Song rails — multi-artist credits link to each artist
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1023](https://github.com/Psychotoxical/psysonic/pull/1023)**
|
||||
|
||||
* **Random Picks**, **Discover Songs**, and other song cards now split OpenSubsonic `artists[]` into individually clickable names — the same behaviour as album track rows and the player bar, instead of one link for the whole joined credit string.
|
||||
* Album cards and the rest of the app share the same artist-ref helper, including when Subsonic returns a single ref object instead of a one-element array.
|
||||
|
||||
|
||||
|
||||
### Fullscreen player — corner clock follows Clock format setting
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1025](https://github.com/Psychotoxical/psysonic/pull/1025)**
|
||||
|
||||
* The wall clock in the fullscreen player now honours **Settings → System → Clock format** (24-hour vs 12-hour), matching the queue ETA and sleep-timer preview instead of always showing AM/PM.
|
||||
|
||||
|
||||
|
||||
### All Albums — Only compilations filter returns results
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1026](https://github.com/Psychotoxical/psysonic/pull/1026)**
|
||||
|
||||
* The **Only compilations** toggle on **All Albums** no longer returns an empty list when compilations are tagged via **Various Artists** as album artist or when genre is combined with other browse filters — local index SQL, track-grouped browse, and client-side detection now agree on the same compilation signals.
|
||||
|
||||
|
||||
|
||||
### Artist page — Top Tracks play button
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1031](https://github.com/Psychotoxical/psysonic/pull/1031)**
|
||||
|
||||
* Play on **Top Tracks** rows no longer silently does nothing when the artist page has top songs but no albums loaded (e.g. lossless artist view); playback starts from the clicked track and continues into the catalog when albums are available.
|
||||
|
||||
|
||||
|
||||
### PsyLab — tab bar no longer collapses on the Logs tab
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
|
||||
|
||||
* The PsyLab tab row keeps its height when the Logs flex layout fills the modal — tabs were previously squashed to a thin strip.
|
||||
|
||||
|
||||
|
||||
### Themes — consistent focus borders on inputs and dropdowns
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1052](https://github.com/Psychotoxical/psysonic/pull/1052)**
|
||||
|
||||
* Text fields no longer draw a double border when focused — they now show a single clean ring across every theme. The colour-blind-safe themes keep their stronger high-contrast focus ring on every field, including the header search.
|
||||
* Dropdown and popover borders now follow the active theme in all themes (a couple of themes previously rendered them with an unthemed colour).
|
||||
|
||||
|
||||
|
||||
### Home — Most Played no longer jumps the page when loading more
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#1053](https://github.com/Psychotoxical/psysonic/pull/1053)**
|
||||
|
||||
* Clicking the arrow to load more albums in the **Most Played** rail sometimes snapped the page up to an earlier section. Loading more now keeps the viewport in place.
|
||||
|
||||
|
||||
|
||||
## [1.47.0]
|
||||
|
||||
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
|
||||
|
||||
@@ -32,6 +32,9 @@ More translations are added over time.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
|
||||
|
||||
## What is Psysonic?
|
||||
|
||||
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
|
||||
|
||||
-82
@@ -6,91 +6,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Psysonic – Dein Navidrome Desktop Player" />
|
||||
<title>Psysonic</title>
|
||||
<script src="/startup-splash-preflight.js"></script>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--startup-splash-bg, #1e1e2e);
|
||||
}
|
||||
#app-startup-splash {
|
||||
--splash-bg: var(--startup-splash-bg, #1e1e2e);
|
||||
--splash-text: var(--startup-splash-text, #cdd6f4);
|
||||
--splash-muted: var(--startup-splash-muted, #a6adc8);
|
||||
--splash-accent: var(--startup-splash-accent, #cba6f7);
|
||||
--splash-track: var(--startup-splash-track, #313244);
|
||||
--splash-logo-start: var(--startup-splash-logo-start, var(--splash-accent));
|
||||
--splash-logo-end: var(--startup-splash-logo-end, var(--splash-accent));
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483646;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.75rem;
|
||||
background: var(--splash-bg);
|
||||
color: var(--splash-text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
opacity: 1;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
#app-startup-splash.app-startup-splash--hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__logo {
|
||||
height: 72px;
|
||||
width: auto;
|
||||
opacity: 0.95;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--splash-muted);
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar {
|
||||
width: min(240px, 72vw);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--splash-track);
|
||||
overflow: hidden;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar-fill {
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--splash-accent);
|
||||
animation: app-startup-splash-indeterminate 1.15s ease-in-out infinite;
|
||||
}
|
||||
@keyframes app-startup-splash-indeterminate {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-startup-splash" role="status" aria-live="polite" aria-label="Loading Psysonic">
|
||||
<svg class="app-startup-splash__logo" viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="startupSplashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="var(--splash-logo-start)" />
|
||||
<stop offset="100%" stop-color="var(--splash-logo-end)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(220.53237,27.789086)">
|
||||
<path fill="url(#startupSplashLogoGrad)" d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="app-startup-splash__bar" aria-hidden="true">
|
||||
<div class="app-startup-splash__bar-fill"></div>
|
||||
</div>
|
||||
<span class="app-startup-splash__label">Loading</span>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script src="/startup-splash-reveal.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Synchronous startup splash theme (before the Vite bundle loads).
|
||||
* Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`.
|
||||
*/
|
||||
(function startupSplashPreflight() {
|
||||
var THEME_KEY = 'psysonic_theme';
|
||||
var INSTALLED_KEY = 'psysonic_installed_themes';
|
||||
var DEFAULT = {
|
||||
bg: '#1e1e2e',
|
||||
text: '#cdd6f4',
|
||||
muted: '#a6adc8',
|
||||
accent: '#cba6f7',
|
||||
track: '#313244',
|
||||
logoStart: '#cba6f7',
|
||||
logoEnd: '#89b4fa',
|
||||
};
|
||||
var BUILTIN = {
|
||||
mocha: DEFAULT,
|
||||
latte: {
|
||||
bg: '#eff1f5',
|
||||
text: '#4c4f69',
|
||||
muted: '#6c6f85',
|
||||
accent: '#8839ef',
|
||||
track: '#ccd0da',
|
||||
logoStart: '#8839ef',
|
||||
logoEnd: '#1e66f5',
|
||||
},
|
||||
'kanagawa-wave': {
|
||||
bg: '#1F1F28',
|
||||
text: '#DCD7BA',
|
||||
muted: '#727169',
|
||||
accent: '#7E9CD8',
|
||||
track: '#2A2A37',
|
||||
logoStart: '#7E9CD8',
|
||||
logoEnd: '#957FB8',
|
||||
},
|
||||
'stark-hud': {
|
||||
bg: '#0b0f15',
|
||||
text: '#e0f7fa',
|
||||
muted: '#7da5aa',
|
||||
accent: '#00f2ff',
|
||||
track: '#141b24',
|
||||
logoStart: '#00f2ff',
|
||||
logoEnd: '#7df9ff',
|
||||
},
|
||||
'vision-dark': {
|
||||
bg: '#0d0b12',
|
||||
text: '#f2eef8',
|
||||
muted: '#a6a2b8',
|
||||
accent: '#ffd700',
|
||||
track: '#16131e',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
'vision-navy': {
|
||||
bg: '#0a1628',
|
||||
text: '#e8eef8',
|
||||
muted: '#9caac2',
|
||||
accent: '#ffd700',
|
||||
track: '#12213a',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
};
|
||||
|
||||
function readCssVar(css, name) {
|
||||
var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);'));
|
||||
var value = match && match[1] ? match[1].trim() : '';
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function resolveScheduledTheme(state) {
|
||||
if (!state.enableThemeScheduler) return state.theme;
|
||||
var now = new Date();
|
||||
var nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
var dayParts = state.timeDayStart.split(':').map(Number);
|
||||
var nightParts = state.timeNightStart.split(':').map(Number);
|
||||
var dayMins = dayParts[0] * 60 + dayParts[1];
|
||||
var nightMins = nightParts[0] * 60 + nightParts[1];
|
||||
var isDay = dayMins < nightMins
|
||||
? nowMins >= dayMins && nowMins < nightMins
|
||||
: nowMins >= dayMins || nowMins < nightMins;
|
||||
return isDay ? state.themeDay : state.themeNight;
|
||||
}
|
||||
|
||||
function readThemeState() {
|
||||
try {
|
||||
var raw = localStorage.getItem(THEME_KEY);
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
var s = parsed && parsed.state;
|
||||
if (!s) return null;
|
||||
return {
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
theme: String(s.theme || 'mocha'),
|
||||
themeDay: String(s.themeDay || 'latte'),
|
||||
themeNight: String(s.themeNight || 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart || '07:00'),
|
||||
timeNightStart: String(s.timeNightStart || '19:00'),
|
||||
};
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledThemes() {
|
||||
try {
|
||||
var raw = localStorage.getItem(INSTALLED_KEY);
|
||||
if (!raw) return [];
|
||||
var parsed = JSON.parse(raw);
|
||||
var themes = parsed && parsed.state && parsed.state.themes;
|
||||
return Array.isArray(themes) ? themes : [];
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function paletteForTheme(themeId, installedThemes) {
|
||||
if (BUILTIN[themeId]) return BUILTIN[themeId];
|
||||
for (var i = 0; i < installedThemes.length; i += 1) {
|
||||
var theme = installedThemes[i];
|
||||
if (!theme || theme.id !== themeId || !theme.css) continue;
|
||||
var bg = readCssVar(theme.css, '--bg-app');
|
||||
var accent = readCssVar(theme.css, '--accent');
|
||||
if (!bg || !accent) break;
|
||||
var logoStart = readCssVar(theme.css, '--logo-color-start') || accent;
|
||||
var logoEnd = readCssVar(theme.css, '--logo-color-end')
|
||||
|| readCssVar(theme.css, '--accent-2')
|
||||
|| accent;
|
||||
return {
|
||||
bg: bg,
|
||||
text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text,
|
||||
muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted,
|
||||
accent: accent,
|
||||
track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track,
|
||||
logoStart: logoStart,
|
||||
logoEnd: logoEnd,
|
||||
};
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
function applyPalette(themeId, palette) {
|
||||
var root = document.documentElement;
|
||||
root.setAttribute('data-theme', themeId);
|
||||
root.style.setProperty('--startup-splash-bg', palette.bg);
|
||||
root.style.setProperty('--startup-splash-text', palette.text);
|
||||
root.style.setProperty('--startup-splash-muted', palette.muted);
|
||||
root.style.setProperty('--startup-splash-accent', palette.accent);
|
||||
root.style.setProperty('--startup-splash-track', palette.track);
|
||||
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
|
||||
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
|
||||
root.style.background = palette.bg;
|
||||
if (document.body) document.body.style.background = palette.bg;
|
||||
}
|
||||
|
||||
var persisted = readThemeState();
|
||||
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
|
||||
var palette = paletteForTheme(themeId, readInstalledThemes());
|
||||
applyPalette(themeId, palette);
|
||||
})();
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Show the native window after the inline startup splash has painted.
|
||||
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
|
||||
*/
|
||||
(function startupSplashReveal() {
|
||||
var MAX_ATTEMPTS = 60;
|
||||
|
||||
function tryShowMainWindow() {
|
||||
var internals = window.__TAURI_INTERNALS__;
|
||||
if (!internals || typeof internals.invoke !== 'function') return false;
|
||||
internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function reveal(attempt) {
|
||||
if (tryShowMainWindow()) return;
|
||||
if (attempt >= MAX_ATTEMPTS) return;
|
||||
window.setTimeout(function () {
|
||||
reveal(attempt + 1);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
reveal(0);
|
||||
});
|
||||
});
|
||||
})();
|
||||
Generated
-1
@@ -4266,7 +4266,6 @@ dependencies = [
|
||||
"psysonic-analysis",
|
||||
"psysonic-audio",
|
||||
"psysonic-core",
|
||||
"psysonic-library",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -6,7 +6,6 @@ resolver = "2"
|
||||
version = "1.48.0-dev"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[workspace.dependencies]
|
||||
tempfile = "3"
|
||||
@@ -19,7 +18,7 @@ name = "psysonic"
|
||||
version.workspace = true
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license.workspace = true
|
||||
license = ""
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition.workspace = true
|
||||
|
||||
@@ -21,8 +21,6 @@ accepted = [
|
||||
"OpenSSL",
|
||||
"BSL-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"GPL-3.0-or-later",
|
||||
"bzip2-1.0.6",
|
||||
]
|
||||
|
||||
# Skip the build host's own platform-pinning; we want a list across all targets
|
||||
@@ -40,4 +38,5 @@ targets = [
|
||||
ignore-build-dependencies = false
|
||||
ignore-dev-dependencies = true
|
||||
ignore-transitive-dependencies = false
|
||||
filter-noassertion = false
|
||||
workarounds = ["ring"]
|
||||
|
||||
@@ -3,7 +3,6 @@ name = "psysonic-analysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -291,7 +291,7 @@ impl AnalysisBackfillQueueState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
|
||||
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
|
||||
#[derive(Default)]
|
||||
pub struct PlaybackPriorityHints {
|
||||
middle_track_ids: Mutex<HashSet<String>>,
|
||||
@@ -516,145 +516,6 @@ pub async fn enqueue_track_analysis_from_file(
|
||||
enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await
|
||||
}
|
||||
|
||||
/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key,
|
||||
/// plan enrichment under the library UUID, and skip work when both scopes are complete.
|
||||
pub async fn enqueue_offline_library_analysis_from_file(
|
||||
app: &tauri::AppHandle,
|
||||
server_index_key: &str,
|
||||
library_server_id: &str,
|
||||
track_id: &str,
|
||||
file_path: &std::path::Path,
|
||||
explicit_priority: Option<AnalysisBackfillPriority>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::track_analysis_plan::plan_track_analysis_offline_library;
|
||||
|
||||
let mut file = tokio::fs::File::open(file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut prefix = vec![0u8; 16384];
|
||||
let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?;
|
||||
prefix.truncate(n);
|
||||
if prefix.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let content_hash = analysis_cache::md5_first_16kb(&prefix);
|
||||
let plan = plan_track_analysis_offline_library(
|
||||
app,
|
||||
&[server_index_key, library_server_id],
|
||||
library_server_id,
|
||||
track_id,
|
||||
&content_hash,
|
||||
);
|
||||
if !plan.any() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] offline library seed skip (complete) track_id={} index={} library={}",
|
||||
track_id,
|
||||
server_index_key,
|
||||
library_server_id,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = tokio::fs::read(file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let format_hint = file_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.filter(|e| !e.is_empty());
|
||||
let priority = explicit_priority.unwrap_or_else(|| {
|
||||
analysis_backfill_resolve_priority(app, server_index_key, track_id, None)
|
||||
});
|
||||
enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue {
|
||||
app,
|
||||
cache_server_id: server_index_key,
|
||||
enrichment_server_id: library_server_id,
|
||||
track_id,
|
||||
bytes: &bytes,
|
||||
format_hint: format_hint.as_deref(),
|
||||
priority,
|
||||
plan,
|
||||
fetch_ms: 0,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct OfflineLibraryAnalysisEnqueue<'a> {
|
||||
app: &'a tauri::AppHandle,
|
||||
cache_server_id: &'a str,
|
||||
enrichment_server_id: &'a str,
|
||||
track_id: &'a str,
|
||||
bytes: &'a [u8],
|
||||
format_hint: Option<&'a str>,
|
||||
priority: AnalysisBackfillPriority,
|
||||
plan: psysonic_core::track_analysis::TrackAnalysisPlan,
|
||||
fetch_ms: u64,
|
||||
}
|
||||
|
||||
async fn enqueue_track_analysis_offline_library_with_plan(
|
||||
args: OfflineLibraryAnalysisEnqueue<'_>,
|
||||
) -> Result<EnqueueTrackAnalysisOutcome, String> {
|
||||
if args.bytes.is_empty() || !args.plan.any() {
|
||||
return Ok(EnqueueTrackAnalysisOutcome::Complete);
|
||||
}
|
||||
let content_hash = analysis_cache::md5_first_16kb(args.bytes);
|
||||
if args.plan.needs_full_cpu_seed() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
|
||||
args.track_id,
|
||||
content_hash,
|
||||
args.plan.need_waveform,
|
||||
args.plan.need_loudness,
|
||||
args.plan.enrichment.any()
|
||||
);
|
||||
submit_analysis_cpu_seed(
|
||||
args.app.clone(),
|
||||
args.cache_server_id.to_string(),
|
||||
args.track_id.to_string(),
|
||||
args.bytes.to_vec(),
|
||||
args.format_hint.map(str::to_string),
|
||||
args.priority,
|
||||
args.fetch_ms,
|
||||
)
|
||||
.await?;
|
||||
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
|
||||
}
|
||||
if args.plan.needs_enrichment_only() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] enrichment-only track_id={} hash={}",
|
||||
args.track_id,
|
||||
content_hash
|
||||
);
|
||||
let bpm_started = std::time::Instant::now();
|
||||
let outcome = run_track_enrichment_from_bytes(
|
||||
args.app,
|
||||
args.enrichment_server_id,
|
||||
args.track_id,
|
||||
args.bytes,
|
||||
analysis_emits_ui_events(args.priority),
|
||||
)
|
||||
.await;
|
||||
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
|
||||
if let Some(cache) = args.app.try_state::<analysis_cache::AnalysisCache>() {
|
||||
let key = analysis_cache::TrackKey {
|
||||
server_id: args.cache_server_id.to_string(),
|
||||
track_id: args.track_id.to_string(),
|
||||
md5_16kb: content_hash.clone(),
|
||||
};
|
||||
let _ = cache.touch_track_status(&key, "failed");
|
||||
}
|
||||
return Err("track enrichment failed".to_string());
|
||||
}
|
||||
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
|
||||
emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms);
|
||||
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
|
||||
}
|
||||
Ok(EnqueueTrackAnalysisOutcome::Complete)
|
||||
}
|
||||
|
||||
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
|
||||
pub async fn enqueue_analysis_seed(
|
||||
app: &tauri::AppHandle,
|
||||
|
||||
@@ -15,21 +15,8 @@ pub fn plan_track_analysis(
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
|
||||
}
|
||||
|
||||
/// Offline/library download: waveform cache and enrichment facts may live under the
|
||||
/// playback index key while library rows use the UUID — try every scope before seeding.
|
||||
pub fn plan_track_analysis_offline_library(
|
||||
app: &AppHandle,
|
||||
cache_server_ids: &[&str],
|
||||
_enrichment_server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
let (need_waveform, need_loudness) =
|
||||
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
|
||||
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
|
||||
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
|
||||
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
|
||||
TrackAnalysisPlan {
|
||||
need_waveform,
|
||||
need_loudness,
|
||||
@@ -116,32 +103,6 @@ fn cache_gaps(
|
||||
)
|
||||
}
|
||||
|
||||
fn cache_gaps_multi(
|
||||
app: &AppHandle,
|
||||
server_ids: &[&str],
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> (bool, bool) {
|
||||
let mut need_waveform = true;
|
||||
let mut need_loudness = true;
|
||||
for &server_id in server_ids {
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
|
||||
if !nw {
|
||||
need_waveform = false;
|
||||
}
|
||||
if !nl {
|
||||
need_loudness = false;
|
||||
}
|
||||
if !need_waveform && !need_loudness {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(need_waveform, need_loudness)
|
||||
}
|
||||
|
||||
fn enrichment_plan(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
@@ -156,45 +117,6 @@ fn enrichment_plan(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn enrichment_plan_multi(
|
||||
app: &AppHandle,
|
||||
server_ids: &[&str],
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
let mut need_bpm = true;
|
||||
let mut need_valence = true;
|
||||
let mut need_arousal = true;
|
||||
let mut need_moods = true;
|
||||
for &server_id in server_ids {
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let plan = enrichment_plan(app, server_id, track_id, content_hash);
|
||||
if !plan.need_bpm {
|
||||
need_bpm = false;
|
||||
}
|
||||
if !plan.need_valence {
|
||||
need_valence = false;
|
||||
}
|
||||
if !plan.need_arousal {
|
||||
need_arousal = false;
|
||||
}
|
||||
if !plan.need_moods {
|
||||
need_moods = false;
|
||||
}
|
||||
if !need_bpm && !need_valence && !need_arousal && !need_moods {
|
||||
break;
|
||||
}
|
||||
}
|
||||
psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
need_bpm,
|
||||
need_valence,
|
||||
need_arousal,
|
||||
need_moods,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_gaps_for_content(
|
||||
cache: Option<&AnalysisCache>,
|
||||
server_id: &str,
|
||||
@@ -272,14 +194,4 @@ mod tests {
|
||||
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
|
||||
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
|
||||
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
|
||||
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ name = "psysonic-audio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -166,24 +166,15 @@ impl SizedDecoder {
|
||||
inner: Cursor::new(data),
|
||||
len: data_len,
|
||||
};
|
||||
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
|
||||
// seekability during probe (same as `new_streaming`) so preview does not
|
||||
// read the entire in-memory file before the first sample.
|
||||
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
|
||||
.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||
Some(gate) => Box::new(ProbeSeekGate {
|
||||
inner: Box::new(source),
|
||||
seekable: gate.clone(),
|
||||
}),
|
||||
None => Box::new(source),
|
||||
};
|
||||
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
|
||||
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
|
||||
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
|
||||
// and compete with the playback thread at track start.
|
||||
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
|
||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
|
||||
let mss = MediaSourceStream::new(
|
||||
Box::new(source) as Box<dyn MediaSource>,
|
||||
MediaSourceStreamOptions { buffer_len: buf_len },
|
||||
);
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = format_hint {
|
||||
@@ -209,10 +200,6 @@ impl SizedDecoder {
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Some(gate) = &probe_seek_gate {
|
||||
gate.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
|
||||
@@ -140,6 +140,12 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let mut last_stall_recover_at: Option<Instant> = None;
|
||||
let mut last_poll_at = Instant::now();
|
||||
let mut watchdog_armed_until: Option<Instant> = None;
|
||||
// DIAGNOSTIC (issue #996, EXPERIMENT BUILD ONLY — revert before release):
|
||||
// counts watcher cycles so the heavy CoreAudio enumeration below runs only
|
||||
// ~every 60s (20 × 3s) instead of every cycle. Tests whether that periodic
|
||||
// enumeration is the source of the ~3s macOS playback stutter. The 3s loop,
|
||||
// watchdog and stall detector are left untouched on purpose.
|
||||
let mut diag_enum_skip: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
@@ -224,18 +230,20 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
// The full `output_devices()` + per-device `description()` scan is the
|
||||
// CoreAudio HAL call that contends with the audio render thread and
|
||||
// produces a brief dropout once per poll interval (issue #996: stutter
|
||||
// every ~3s, cadence tracking the poll exactly). It is only needed to
|
||||
// detect a *pinned* output device disappearing. With no pin — system
|
||||
// default, the common case — only the current default is needed, a
|
||||
// single cheap query, so the full enumeration is skipped entirely.
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
let need_full_enum = pinned.is_some();
|
||||
// DIAGNOSTIC (issue #996, EXPERIMENT BUILD ONLY — revert before release):
|
||||
// run the heavy CoreAudio enumeration + device-change detection only
|
||||
// ~every 60s. The stall detector above still runs every 3s, so playback
|
||||
// recovery is unaffected. If the stutter cadence shifts to ~60s (or
|
||||
// stops), the periodic enumeration is the culprit.
|
||||
diag_enum_skip += 1;
|
||||
if diag_enum_skip < 20 {
|
||||
continue;
|
||||
}
|
||||
diag_enum_skip = 0;
|
||||
|
||||
// Enumerate all available output devices and the current default.
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
@@ -253,28 +261,26 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let default = host
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
|
||||
let available: Vec<String> = if need_full_enum {
|
||||
host.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let available: Vec<String> = host
|
||||
.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(default, available)
|
||||
}).await.unwrap_or((None, vec![]));
|
||||
|
||||
// Empty list (only when we actually enumerated for a pinned device)
|
||||
// almost always means a transient enumeration failure, not that every
|
||||
// output device vanished. Treating it as "pinned missing" caused false
|
||||
// audio:device-reset (UI jumped back to system default) when switching
|
||||
// to external USB / class-compliant interfaces.
|
||||
if need_full_enum && available.is_empty() {
|
||||
// Empty list almost always means a transient enumeration failure, not
|
||||
// that every output device vanished. Treating it as "pinned missing"
|
||||
// caused false audio:device-reset (UI jumped back to system default)
|
||||
// when switching to external USB / class-compliant interfaces.
|
||||
if available.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if pinned.is_some() {
|
||||
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Short preview playback on a secondary sink (same output stream).
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
@@ -9,17 +9,8 @@ use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::SizedDecoder;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{
|
||||
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
|
||||
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
MASTER_HEADROOM,
|
||||
};
|
||||
use super::play_input::url_format_hint;
|
||||
use super::helpers::MASTER_HEADROOM;
|
||||
use super::sources::PriorityBoostSource;
|
||||
use super::stream::{
|
||||
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
|
||||
RangedHttpSource, RangedMp4ProbeGate,
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
|
||||
@@ -99,246 +90,26 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `format=` query param on Subsonic stream URLs (transcode targets).
|
||||
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
|
||||
/// a `format=flac` query param for `.opus` files (server transcodes); for
|
||||
/// everything else we guess from the URL's `format=` value or fall back to None.
|
||||
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
|
||||
url.split('?')
|
||||
.nth(1)?
|
||||
.split('&')
|
||||
.find_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
if k.eq_ignore_ascii_case("format") {
|
||||
Some(v.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
/// Symphonia container hint for preview downloads — mirrors main playback:
|
||||
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
|
||||
pub(crate) fn resolve_preview_format_hint(
|
||||
url: &str,
|
||||
content_type: Option<&str>,
|
||||
content_disposition: Option<&str>,
|
||||
stream_suffix: Option<&str>,
|
||||
bytes: &[u8],
|
||||
) -> Option<String> {
|
||||
let media_hint = content_type
|
||||
.and_then(content_type_to_hint)
|
||||
.or_else(|| {
|
||||
content_disposition.and_then(format_hint_from_content_disposition)
|
||||
});
|
||||
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
|
||||
resolve_playback_format_hint(
|
||||
url_hint.as_deref(),
|
||||
stream_suffix,
|
||||
media_hint.as_deref(),
|
||||
Some(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.use_rustls_tls()
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_else(|_| audio_http_client(state))
|
||||
}
|
||||
|
||||
/// Open a preview decoder — ranged HTTP when the server supports it (starts
|
||||
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
|
||||
async fn open_preview_decoder(
|
||||
url: &str,
|
||||
format_suffix: Option<&str>,
|
||||
gen: u64,
|
||||
state: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<SizedDecoder>, String> {
|
||||
let preview_http = preview_http_client(state);
|
||||
let response = preview_http
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?;
|
||||
|
||||
let mut stream_hint = content_type_to_hint(
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
)
|
||||
.or_else(|| {
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(format_hint_from_content_disposition)
|
||||
})
|
||||
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
|
||||
.or_else(|| preview_format_hint_from_url(url))
|
||||
.or_else(|| url_format_hint(url));
|
||||
|
||||
let supports_range = response
|
||||
.headers()
|
||||
.get(reqwest::header::ACCEPT_RANGES)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
||||
let total_size = response.content_length();
|
||||
|
||||
if stream_hint.is_none() && supports_range {
|
||||
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
|
||||
let last = total_u64
|
||||
.saturating_sub(1)
|
||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||
if let Ok(pr) = preview_http
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let stat = pr.status();
|
||||
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| stat == reqwest::StatusCode::OK;
|
||||
if ok {
|
||||
if let Ok(bytes) = pr.bytes().await {
|
||||
if !bytes.is_empty() {
|
||||
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
let total_usize = total as usize;
|
||||
crate::app_deprintln!(
|
||||
"[preview] ranged open — total={} KB, hint={:?}",
|
||||
total_usize / 1024,
|
||||
stream_hint
|
||||
);
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
|
||||
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let playback_armed = Arc::new(AtomicBool::new(false));
|
||||
let tail_ready = Arc::new(AtomicBool::new(false));
|
||||
let tail_filled_from = Arc::new(AtomicU64::new(0));
|
||||
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
|
||||
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
|
||||
tail_ready: tail_ready.clone(),
|
||||
buf: buf.clone(),
|
||||
downloaded_to: downloaded_to.clone(),
|
||||
gen_arc: state.preview_gen.clone(),
|
||||
gen,
|
||||
format_hint: stream_hint.clone(),
|
||||
});
|
||||
tokio::spawn(ranged_download_task(
|
||||
gen,
|
||||
state.preview_gen.clone(),
|
||||
preview_http,
|
||||
app.clone(),
|
||||
0.0,
|
||||
url.to_string(),
|
||||
response,
|
||||
buf.clone(),
|
||||
downloaded_to.clone(),
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.stream_completed_spill.clone(),
|
||||
state.normalization_engine.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
playback_armed,
|
||||
stream_hint.clone(),
|
||||
tail_ready.clone(),
|
||||
tail_filled_from.clone(),
|
||||
));
|
||||
if let Some(ref gate) = mp4_probe_gate {
|
||||
wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready,
|
||||
tail_filled_from,
|
||||
total_size: total,
|
||||
pos: 0,
|
||||
done,
|
||||
gen_arc: state.preview_gen.clone(),
|
||||
gen,
|
||||
};
|
||||
let hint = stream_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
return Ok(Some(decoder));
|
||||
}
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
|
||||
supports_range,
|
||||
total_size,
|
||||
stream_hint
|
||||
);
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let content_disposition = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("preview: read body: {e}"))?
|
||||
.to_vec();
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
let hint = resolve_preview_format_hint(
|
||||
url,
|
||||
content_type.as_deref(),
|
||||
content_disposition.as_deref(),
|
||||
format_suffix,
|
||||
&bytes,
|
||||
);
|
||||
let bytes_for_blocking = bytes;
|
||||
let hint_for_blocking = hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
Ok(Some(decoder))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
|
||||
pub async fn audio_preview_play(
|
||||
id: String,
|
||||
url: String,
|
||||
start_sec: f64,
|
||||
duration_sec: f64,
|
||||
volume: f32,
|
||||
format_suffix: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -363,24 +134,48 @@ pub async fn audio_preview_play(
|
||||
preview_pause_main(&state);
|
||||
}
|
||||
|
||||
// ── Open decoder (ranged stream when possible) ───────────────────────────
|
||||
let decoder = match open_preview_decoder(
|
||||
&url,
|
||||
format_suffix.as_deref(),
|
||||
gen,
|
||||
&state,
|
||||
&app,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return Ok(()),
|
||||
};
|
||||
// ── Download ─────────────────────────────────────────────────────────────
|
||||
// Dedicated client with a generous timeout. The shared `audio_http_client`
|
||||
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
|
||||
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
|
||||
// ~60–120 s on a typical home LAN. The watchdog (30 s wall-clock) still
|
||||
// bounds how long the preview plays once the bytes are in memory, so a
|
||||
// long download just means a longer "loading" spinner before audio starts.
|
||||
let preview_http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.use_rustls_tls()
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_else(|_| audio_http_client(&state));
|
||||
let bytes = preview_http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("preview: read body: {e}"))?
|
||||
.to_vec();
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
// A newer preview started while we were downloading — bail.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Decode ───────────────────────────────────────────────────────────────
|
||||
let hint = preview_format_hint_from_url(&url);
|
||||
let bytes_for_blocking = bytes;
|
||||
let hint_for_blocking = hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
// ── Build source pipeline ────────────────────────────────────────────────
|
||||
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
|
||||
// before the seek made take_duration's wall-clock counter tick from
|
||||
@@ -476,55 +271,6 @@ pub async fn audio_preview_play(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
b"fLaC\x00\x00\x00\x22",
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("flac"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
Some("audio/mpeg"),
|
||||
None,
|
||||
None,
|
||||
b"fLaC\x00\x00\x00\x22",
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("mp3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_uses_subsonic_suffix() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
None,
|
||||
None,
|
||||
Some("flac"),
|
||||
&[0x00, 0x01, 0x02, 0x03],
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("flac"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_format_hint_from_url_reads_format_query_param() {
|
||||
assert_eq!(
|
||||
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
|
||||
Some("opus".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
preview_stop_inner(&app, &state, true);
|
||||
|
||||
@@ -3,7 +3,6 @@ name = "psysonic-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -29,38 +29,17 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|
||||
|| id.starts_with("ra-")
|
||||
}
|
||||
|
||||
/// Windows reserved device names (case-insensitive) — invalid as path components.
|
||||
const WINDOWS_RESERVED_NAMES: &[&str] = &[
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
|
||||
/// Also used for media layout artist/album/title segments from server metadata.
|
||||
pub fn sanitize_path_segment(segment: &str) -> String {
|
||||
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
|
||||
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
|
||||
let trimmed = segment.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "_".to_string();
|
||||
}
|
||||
let cleaned: String = trimmed
|
||||
trimmed
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_control() || FORBIDDEN.contains(&c) {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
|
||||
return "_".to_string();
|
||||
}
|
||||
let upper = cleaned.to_ascii_uppercase();
|
||||
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
|
||||
return format!("_{cleaned}");
|
||||
}
|
||||
cleaned
|
||||
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Relative path under `{root}/{server_segment}/` — change format here only.
|
||||
@@ -277,13 +256,6 @@ mod tests {
|
||||
base
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_rejects_dot_dot_and_reserved_names() {
|
||||
assert_eq!(sanitize_path_segment(".."), "_");
|
||||
assert_eq!(sanitize_path_segment("CON"), "_CON");
|
||||
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_disk_usage_counts_canonical_only() {
|
||||
let server = test_server_dir("usage");
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||
|
||||
pub mod cover_cache_layout;
|
||||
pub mod log_sanitize;
|
||||
pub mod media_layout;
|
||||
pub mod logging;
|
||||
pub mod ports;
|
||||
pub mod track_analysis;
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
//! Redact secrets and partially mask remote server hostnames before log lines
|
||||
//! are stored or exported (PsyLab / Settings log export).
|
||||
|
||||
const SENSITIVE_QUERY_KEYS: &[&str] = &[
|
||||
"t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey",
|
||||
"access_token", "refresh_token", "auth",
|
||||
];
|
||||
|
||||
const SENSITIVE_KV_KEYS: &[&str] = &[
|
||||
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
|
||||
"refresh_token", "authorization", "auth",
|
||||
];
|
||||
|
||||
/// Sanitize one runtime log line for display and export.
|
||||
pub fn sanitize_log_line(line: &str) -> String {
|
||||
let mut out = redact_bearer_tokens(line);
|
||||
out = redact_sensitive_key_values(&out);
|
||||
out = redact_urls_in_text(&out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Never panic on the logging hot path — fall back to the raw line if needed.
|
||||
pub fn sanitize_log_line_infallible(line: &str) -> String {
|
||||
std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string())
|
||||
}
|
||||
|
||||
fn redact_bearer_tokens(line: &str) -> String {
|
||||
let marker = "Bearer ";
|
||||
let mut s = line.to_string();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = s[search_from..].find(marker) {
|
||||
let idx = search_from + rel;
|
||||
let start = idx + marker.len();
|
||||
let end = s[start..]
|
||||
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']')
|
||||
.map(|i| start + i)
|
||||
.unwrap_or(s.len());
|
||||
if end > start {
|
||||
s.replace_range(start..end, "REDACTED");
|
||||
}
|
||||
search_from = start + "REDACTED".len();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn redact_sensitive_key_values(line: &str) -> String {
|
||||
let mut out = line.to_string();
|
||||
for key in SENSITIVE_KV_KEYS {
|
||||
for sep in [':', '='] {
|
||||
let needle = format!("{key}{sep}");
|
||||
let lower = out.to_ascii_lowercase();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = lower[search_from..].find(&needle) {
|
||||
let idx = search_from + rel;
|
||||
let val_start = idx + needle.len();
|
||||
let slice = &out[val_start..];
|
||||
let trimmed = slice.trim_start();
|
||||
let ws = slice.len().saturating_sub(trimmed.len());
|
||||
let val_start = val_start + ws;
|
||||
let end = trimmed
|
||||
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
|
||||
.unwrap_or(trimmed.len());
|
||||
if end > 0 {
|
||||
out.replace_range(val_start..val_start + end, "REDACTED");
|
||||
}
|
||||
search_from = val_start + "REDACTED".len();
|
||||
if search_from >= out.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool {
|
||||
if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' {
|
||||
return true;
|
||||
}
|
||||
if ch == ')' || ch == ']' || ch == ',' {
|
||||
if let Some(next) = s[byte_off..].chars().nth(1) {
|
||||
return next.is_whitespace() || next == '"' || next == '\'';
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn redact_urls_in_text(line: &str) -> String {
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut cursor = 0;
|
||||
while cursor < line.len() {
|
||||
let slice = &line[cursor..];
|
||||
let rel = match (slice.find("http://"), slice.find("https://")) {
|
||||
(Some(h), Some(s)) => Some(h.min(s)),
|
||||
(Some(h), None) => Some(h),
|
||||
(None, Some(s)) => Some(s),
|
||||
(None, None) => None,
|
||||
};
|
||||
let Some(rel) = rel else {
|
||||
out.push_str(slice);
|
||||
break;
|
||||
};
|
||||
out.push_str(&slice[..rel]);
|
||||
let url_start = cursor + rel;
|
||||
let url_slice = &line[url_start..];
|
||||
let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 };
|
||||
let mut url_end = scheme_len;
|
||||
for (off, ch) in url_slice[scheme_len..].char_indices() {
|
||||
let abs = scheme_len + off;
|
||||
if url_char_ends_url(ch, url_slice, abs) {
|
||||
break;
|
||||
}
|
||||
url_end = abs + ch.len_utf8();
|
||||
}
|
||||
out.push_str(&redact_url(&line[url_start..url_start + url_end]));
|
||||
cursor = url_start + url_end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn redact_url(raw: &str) -> String {
|
||||
let (url, suffix) = split_trailing_punct(raw);
|
||||
let mut out = String::new();
|
||||
|
||||
let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
|
||||
out.push_str(&url[..scheme_end]);
|
||||
|
||||
let mut rest = &url[scheme_end..];
|
||||
if let Some(at) = rest.rfind('@') {
|
||||
// Drop userinfo entirely.
|
||||
out.push_str("***@");
|
||||
rest = &rest[at + 1..];
|
||||
}
|
||||
|
||||
let (hostport, path) = split_host_path(rest);
|
||||
let (host, port) = split_host_port(&hostport);
|
||||
let masked_host = mask_hostname(&host);
|
||||
out.push_str(&masked_host);
|
||||
if let Some(p) = port {
|
||||
out.push(':');
|
||||
out.push_str(&p);
|
||||
}
|
||||
if let Some((path_only, query)) = path.split_once('?') {
|
||||
out.push_str(path_only);
|
||||
out.push('?');
|
||||
out.push_str(&redact_query_string(query));
|
||||
} else {
|
||||
out.push_str(&path);
|
||||
}
|
||||
|
||||
format!("{out}{suffix}")
|
||||
}
|
||||
|
||||
fn split_trailing_punct(raw: &str) -> (&str, &str) {
|
||||
let mut end = raw.len();
|
||||
while end > 0 {
|
||||
let ch = raw.as_bytes()[end - 1] as char;
|
||||
if ch == ')' || ch == ']' || ch == ',' {
|
||||
end -= 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
(&raw[..end], &raw[end..])
|
||||
}
|
||||
|
||||
fn split_host_path(rest: &str) -> (String, String) {
|
||||
if rest.starts_with('[') {
|
||||
if let Some(end) = rest.find(']') {
|
||||
let hostport = &rest[..=end];
|
||||
return (hostport.to_string(), rest[end + 1..].to_string());
|
||||
}
|
||||
}
|
||||
if let Some(slash) = rest.find('/') {
|
||||
(rest[..slash].to_string(), rest[slash..].to_string())
|
||||
} else {
|
||||
(rest.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn split_host_port(hostport: &str) -> (String, Option<String>) {
|
||||
if hostport.starts_with('[') {
|
||||
if let Some(end) = hostport.find("]:") {
|
||||
return (
|
||||
hostport[..=end].to_string(),
|
||||
Some(hostport[end + 2..].to_string()),
|
||||
);
|
||||
}
|
||||
return (hostport.to_string(), None);
|
||||
}
|
||||
if let Some((h, p)) = hostport.rsplit_once(':') {
|
||||
if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
|
||||
return (h.to_string(), Some(p.to_string()));
|
||||
}
|
||||
}
|
||||
(hostport.to_string(), None)
|
||||
}
|
||||
|
||||
fn redact_query_string(query: &str) -> String {
|
||||
query
|
||||
.split('&')
|
||||
.map(|pair| {
|
||||
let (k, _v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
if is_sensitive_query_key(k) {
|
||||
format!("{k}=REDACTED")
|
||||
} else {
|
||||
pair.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
fn is_sensitive_query_key(key: &str) -> bool {
|
||||
let k = key.trim().to_ascii_lowercase();
|
||||
SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k)
|
||||
}
|
||||
|
||||
fn is_lan_ipv4(ip: &str) -> bool {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return false;
|
||||
}
|
||||
let Ok(a) = parts[0].parse::<u8>() else { return false };
|
||||
let Ok(b) = parts[1].parse::<u8>() else { return false };
|
||||
a == 127
|
||||
|| a == 10
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 168)
|
||||
}
|
||||
|
||||
fn is_lan_ipv6(host: &str) -> bool {
|
||||
let h = host.to_ascii_lowercase();
|
||||
if h == "::1" {
|
||||
return true;
|
||||
}
|
||||
if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") {
|
||||
return true;
|
||||
}
|
||||
if h.starts_with("fc") || h.starts_with("fd") {
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = h.strip_prefix("::ffff:") {
|
||||
if rest.contains('.') {
|
||||
return is_lan_ipv4(rest);
|
||||
}
|
||||
if let Some((a, b)) = rest.split_once(':') {
|
||||
if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) {
|
||||
let ip = format!(
|
||||
"{}.{}.{}.{}",
|
||||
(v1 >> 8) & 0xff,
|
||||
v1 & 0xff,
|
||||
(v2 >> 8) & 0xff,
|
||||
v2 & 0xff
|
||||
);
|
||||
return is_lan_ipv4(&ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_lan_host(host: &str) -> bool {
|
||||
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
|
||||
let lower = stripped.to_ascii_lowercase();
|
||||
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
|
||||
return true;
|
||||
}
|
||||
if stripped.contains(':') {
|
||||
return is_lan_ipv6(stripped);
|
||||
}
|
||||
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
|
||||
return is_lan_ipv4(stripped);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn mask_label_prefix(label: &str) -> String {
|
||||
let mut chars = label.chars();
|
||||
let c1 = chars.next();
|
||||
let c2 = chars.next();
|
||||
match (c1, c2) {
|
||||
(None, _) => "*".to_string(),
|
||||
(Some(a), None) => a.to_string(),
|
||||
(Some(a), Some(b)) => {
|
||||
let rest = label.chars().count().saturating_sub(2);
|
||||
let stars = rest.clamp(1, 4);
|
||||
format!("{a}{b}{}", "*".repeat(stars))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_public_ipv4(ip: &str) -> String {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return "***".to_string();
|
||||
}
|
||||
format!("{}.*.*.{}", parts[0], parts[3])
|
||||
}
|
||||
|
||||
fn mask_hostname(host: &str) -> String {
|
||||
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
|
||||
if is_lan_host(stripped) {
|
||||
return host.to_string();
|
||||
}
|
||||
|
||||
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
|
||||
return mask_public_ipv4(stripped);
|
||||
}
|
||||
|
||||
if stripped.contains(':') {
|
||||
return "[ipv6-redacted]".to_string();
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = stripped.split('.').collect();
|
||||
if parts.is_empty() {
|
||||
return "***".to_string();
|
||||
}
|
||||
|
||||
let masked_first = mask_label_prefix(parts[0]);
|
||||
|
||||
if parts.len() == 1 {
|
||||
masked_first
|
||||
} else {
|
||||
format!("{}.{}", masked_first, parts[1..].join("."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacts_subsonic_wire_auth_params() {
|
||||
let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("t=REDACTED"));
|
||||
assert!(out.contains("s=REDACTED"));
|
||||
assert!(out.contains("p=REDACTED"));
|
||||
assert!(!out.contains("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_remote_hostname_keeps_lan_ip() {
|
||||
let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping");
|
||||
assert!(remote.contains("my****.example.com"));
|
||||
assert!(!remote.contains("my-server.example.com"));
|
||||
|
||||
let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping");
|
||||
assert!(lan.contains("192.168.1.42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_bearer_and_password_kv() {
|
||||
let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("Bearer REDACTED"));
|
||||
assert!(!out.contains("eyJhbGci"));
|
||||
assert!(out.contains("password=REDACTED"));
|
||||
assert!(!out.contains("sekrit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_url_userinfo() {
|
||||
let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("***@10.0.0.5"));
|
||||
assert!(!out.contains("user:pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_log_with_em_dash_does_not_panic() {
|
||||
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains('—'));
|
||||
assert!(out.contains("RangedHttpSource"));
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,6 @@ pub fn should_log_debug() -> bool {
|
||||
}
|
||||
|
||||
pub fn append_log_line(line: String) {
|
||||
let line = crate::log_sanitize::sanitize_log_line_infallible(&line);
|
||||
let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
{
|
||||
let mut buf = log_buffer().lock().unwrap();
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
//! Local playback disk layout — artist/album/track paths from library-index fields.
|
||||
//!
|
||||
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
|
||||
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
|
||||
//! index key; artist/album/filename segments are derived from track metadata only.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::cover_cache_layout::sanitize_path_segment;
|
||||
|
||||
/// Max length for a single path component after sanitization (Windows budget).
|
||||
pub const MAX_SEGMENT_LEN: usize = 120;
|
||||
|
||||
/// Inputs required to build hierarchical media paths (library index row projection).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TrackPathInput {
|
||||
pub artist: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub album: String,
|
||||
pub title: String,
|
||||
pub track_number: Option<i64>,
|
||||
pub disc_number: Option<i64>,
|
||||
pub suffix: Option<String>,
|
||||
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
|
||||
pub raw_json: Option<String>,
|
||||
}
|
||||
|
||||
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalTier {
|
||||
Ephemeral,
|
||||
Library,
|
||||
/// Auto-synced starred favorites — separate from user-pinned `library/`.
|
||||
Favorites,
|
||||
}
|
||||
|
||||
impl LocalTier {
|
||||
pub fn subdir(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ephemeral => "cache",
|
||||
Self::Library => "library",
|
||||
Self::Favorites => "favorites",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"ephemeral" | "cache" => Some(Self::Ephemeral),
|
||||
"library" => Some(Self::Library),
|
||||
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
|
||||
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
|
||||
let artist_seg = artist_folder_segment(input);
|
||||
let album_seg = album_folder_segment(&input.album);
|
||||
let stem = track_filename_stem(input);
|
||||
let suffix = input
|
||||
.suffix
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("");
|
||||
let track_n = input.track_number.unwrap_or(0);
|
||||
let disc_n = input.disc_number.unwrap_or(0);
|
||||
format!(
|
||||
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
|
||||
input
|
||||
.album_artist
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or(""),
|
||||
input.title.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
|
||||
pub fn relative_path_for_track(
|
||||
server_index_key: &str,
|
||||
input: &TrackPathInput,
|
||||
suffix: &str,
|
||||
) -> PathBuf {
|
||||
let server_segment = sanitize_path_segment(server_index_key);
|
||||
let artist = artist_folder_segment(input);
|
||||
let album = album_folder_segment(&input.album);
|
||||
let stem = track_filename_stem(input);
|
||||
let ext = suffix.trim().trim_start_matches('.');
|
||||
let filename = if ext.is_empty() {
|
||||
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
|
||||
} else {
|
||||
format!(
|
||||
"{}.{}",
|
||||
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
|
||||
sanitize_path_segment(ext)
|
||||
)
|
||||
};
|
||||
PathBuf::from(server_segment)
|
||||
.join(artist)
|
||||
.join(album)
|
||||
.join(filename)
|
||||
}
|
||||
|
||||
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
|
||||
pub fn absolute_track_path(
|
||||
media_root: &Path,
|
||||
tier: LocalTier,
|
||||
server_index_key: &str,
|
||||
input: &TrackPathInput,
|
||||
suffix: &str,
|
||||
) -> PathBuf {
|
||||
media_root
|
||||
.join(tier.subdir())
|
||||
.join(relative_path_for_track(server_index_key, input, suffix))
|
||||
}
|
||||
|
||||
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
|
||||
pub fn ensure_track_path_within_tier(
|
||||
media_root: &Path,
|
||||
tier: LocalTier,
|
||||
absolute: &Path,
|
||||
) -> Result<(), String> {
|
||||
let tier_root = media_root.join(tier.subdir());
|
||||
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
|
||||
return Err(format!(
|
||||
"path `{}` escapes tier root `{}`",
|
||||
absolute.display(),
|
||||
tier_root.display()
|
||||
));
|
||||
};
|
||||
for comp in rel.components() {
|
||||
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
|
||||
return Err(format!(
|
||||
"path `{}` contains forbidden component `{comp:?}`",
|
||||
absolute.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn artist_folder_segment(input: &TrackPathInput) -> String {
|
||||
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
|
||||
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
|
||||
let chosen = if artist.is_empty() || track_is_compilation(input) {
|
||||
if !album_artist.is_empty() {
|
||||
album_artist
|
||||
} else {
|
||||
"Various Artists"
|
||||
}
|
||||
} else {
|
||||
artist
|
||||
};
|
||||
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
|
||||
}
|
||||
|
||||
fn album_folder_segment(album: &str) -> String {
|
||||
let trimmed = album.trim();
|
||||
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
|
||||
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
|
||||
}
|
||||
|
||||
fn track_filename_stem(input: &TrackPathInput) -> String {
|
||||
let title = input.title.trim();
|
||||
let title = if title.is_empty() { "Unknown Title" } else { title };
|
||||
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
|
||||
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
|
||||
if disc_n > 1 {
|
||||
format!("{disc_n:02}-{track_n:02} - {title}")
|
||||
} else {
|
||||
format!("{track_n:02} - {title}")
|
||||
}
|
||||
}
|
||||
|
||||
fn track_is_compilation(input: &TrackPathInput) -> bool {
|
||||
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
|
||||
return true;
|
||||
}
|
||||
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
raw_json_marks_compilation(raw)
|
||||
}
|
||||
|
||||
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
|
||||
fn raw_json_marks_compilation(raw: &str) -> bool {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
lower.contains("\"iscompilation\":true")
|
||||
|| lower.contains("\"iscompilation\": true")
|
||||
|| lower.contains("\"compilation\":true")
|
||||
|| lower.contains("\"compilation\": true")
|
||||
|| lower.contains("\"compilation\":1")
|
||||
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
|
||||
}
|
||||
|
||||
fn various_artists_label(s: &str) -> bool {
|
||||
let lower = s.trim().to_ascii_lowercase();
|
||||
lower.contains("various artists")
|
||||
}
|
||||
|
||||
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
|
||||
let sanitized = sanitize_path_segment(segment);
|
||||
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
|
||||
if sanitized.chars().count() <= max_len {
|
||||
return sanitized;
|
||||
}
|
||||
let hash = short_hash(segment);
|
||||
let keep = max_len.saturating_sub(1 + hash.len());
|
||||
let mut out = sanitized.chars().take(keep).collect::<String>();
|
||||
out.push('_');
|
||||
out.push_str(&hash);
|
||||
out
|
||||
}
|
||||
|
||||
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
|
||||
fn short_hash(s: &str) -> String {
|
||||
let mut h: u32 = 0;
|
||||
for unit in s.encode_utf16() {
|
||||
h = h.wrapping_mul(31).wrapping_add(unit as u32);
|
||||
}
|
||||
format!("{:08x}", h)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_input() -> TrackPathInput {
|
||||
TrackPathInput {
|
||||
artist: Some("Radiohead".to_string()),
|
||||
album_artist: None,
|
||||
album: "OK Computer".to_string(),
|
||||
title: "Paranoid Android".to_string(),
|
||||
track_number: Some(6),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_uses_library_segments() {
|
||||
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
|
||||
assert_eq!(
|
||||
rel,
|
||||
PathBuf::from("host_4533")
|
||||
.join("Radiohead")
|
||||
.join("OK Computer")
|
||||
.join("06 - Paranoid Android.mp3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_disc_adds_disc_prefix() {
|
||||
let mut input = sample_input();
|
||||
input.disc_number = Some(2);
|
||||
let rel = relative_path_for_track("srv", &input, "flac");
|
||||
assert!(rel
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_uses_album_artist_folder() {
|
||||
let input = TrackPathInput {
|
||||
artist: Some("Various Artists".to_string()),
|
||||
album_artist: Some("Original Soundtrack".to_string()),
|
||||
album: "Film Score".to_string(),
|
||||
title: "Main Theme".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let rel = relative_path_for_track("srv", &input, "mp3");
|
||||
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_artist_falls_back_to_various_artists() {
|
||||
let input = TrackPathInput {
|
||||
artist: None,
|
||||
album_artist: None,
|
||||
album: "Comp".to_string(),
|
||||
title: "Song".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let rel = relative_path_for_track("srv", &input, "mp3");
|
||||
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_fingerprint_is_stable() {
|
||||
let a = layout_fingerprint(&sample_input());
|
||||
let b = layout_fingerprint(&sample_input());
|
||||
assert_eq!(a, b);
|
||||
assert!(a.contains("Radiohead"));
|
||||
assert!(a.contains("OK Computer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_subdirs_are_fixed() {
|
||||
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
|
||||
assert_eq!(LocalTier::Library.subdir(), "library");
|
||||
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
|
||||
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
|
||||
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
|
||||
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_includes_tier() {
|
||||
let root = Path::new("/media");
|
||||
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
|
||||
assert!(path.starts_with(root.join("library")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_dot_metadata_does_not_escape_tier_root() {
|
||||
let input = TrackPathInput {
|
||||
artist: Some("..".to_string()),
|
||||
album_artist: None,
|
||||
album: "..".to_string(),
|
||||
title: "Song".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let root = Path::new("/media");
|
||||
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
|
||||
assert!(path.starts_with(root.join("library")));
|
||||
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_hash_matches_ts_imul31_utf16() {
|
||||
// "Radiohead" — same as mediaLayout.test parity anchor.
|
||||
assert_eq!(short_hash("Radiohead"), "3da68c3b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_and_truncate_uses_code_point_threshold() {
|
||||
let cyrillic_a = '\u{0430}';
|
||||
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
|
||||
assert!(hundred.len() > MAX_SEGMENT_LEN);
|
||||
assert_eq!(hundred.chars().count(), 100);
|
||||
assert_eq!(
|
||||
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
|
||||
hundred
|
||||
);
|
||||
|
||||
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
|
||||
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
|
||||
assert!(truncated.ends_with("_eef20600"));
|
||||
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ name = "psysonic-integration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -3,7 +3,6 @@ name = "psysonic-library"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -460,8 +460,8 @@ fn build_album_from_tracks(
|
||||
);
|
||||
|
||||
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
|
||||
MAX(t.album_artist), COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), \
|
||||
MAX(t.cover_art_id), MAX(t.starred_at), MAX(t.synced_at)";
|
||||
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
|
||||
MAX(t.starred_at), MAX(t.synced_at)";
|
||||
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
|
||||
"ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string()
|
||||
});
|
||||
@@ -868,10 +868,10 @@ fn resolve_clause(
|
||||
}));
|
||||
}
|
||||
("compilation", EntityKind::Album) => {
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Album);
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "a");
|
||||
}
|
||||
("compilation", EntityKind::Track) => {
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Track);
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "t");
|
||||
}
|
||||
("compilation", _) => return Ok(None),
|
||||
// `text` is handled by the entity builder (FTS / LIKE), never here.
|
||||
@@ -1156,21 +1156,19 @@ fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
|
||||
}
|
||||
|
||||
fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
|
||||
let track_artist: Option<String> = r.get(3)?;
|
||||
let album_artist: Option<String> = r.get(5)?;
|
||||
Ok(LibraryAlbumDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
artist: crate::album_compilation_filter::pick_album_group_artist(track_artist, album_artist),
|
||||
artist: r.get(3)?,
|
||||
artist_id: r.get(4)?,
|
||||
song_count: Some(r.get(6)?),
|
||||
duration_sec: Some(r.get(7)?),
|
||||
year: r.get(8)?,
|
||||
genre: r.get(9)?,
|
||||
cover_art_id: r.get(10)?,
|
||||
starred_at: r.get(11)?,
|
||||
synced_at: r.get(12)?,
|
||||
song_count: Some(r.get(5)?),
|
||||
duration_sec: Some(r.get(6)?),
|
||||
year: r.get(7)?,
|
||||
genre: r.get(8)?,
|
||||
cover_art_id: r.get(9)?,
|
||||
starred_at: r.get(10)?,
|
||||
synced_at: r.get(11)?,
|
||||
raw_json: Value::Null,
|
||||
})
|
||||
}
|
||||
@@ -1266,21 +1264,9 @@ fn compilation_filter_fragment(
|
||||
field: &str,
|
||||
op: FilterOp,
|
||||
value: Option<&Value>,
|
||||
kind: EntityKind,
|
||||
table_alias: &str,
|
||||
) -> Result<Option<SqlFragment>, String> {
|
||||
let comp_sql = match kind {
|
||||
EntityKind::Album => crate::album_compilation_filter::compilation_predicate_sql(
|
||||
"a",
|
||||
Some("a.artist"),
|
||||
None,
|
||||
),
|
||||
EntityKind::Track => crate::album_compilation_filter::compilation_predicate_sql(
|
||||
"t",
|
||||
Some("t.artist"),
|
||||
Some("t.album_artist"),
|
||||
),
|
||||
_ => crate::album_compilation_filter::compilation_raw_json_sql("t"),
|
||||
};
|
||||
let comp_sql = crate::album_compilation_filter::compilation_raw_json_sql(table_alias);
|
||||
match op {
|
||||
FilterOp::IsTrue => Ok(Some(SqlFragment {
|
||||
sql: comp_sql,
|
||||
@@ -2046,27 +2032,6 @@ mod tests {
|
||||
assert_eq!(resp.albums[0].id, "al_comp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_filter_matches_va_album_artist_on_track_groups() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut comp = track("s1", "t_comp", "Hit", "Alice", "Comp Album");
|
||||
comp.album_id = Some("al_comp".into());
|
||||
comp.album_artist = Some("Various Artists".into());
|
||||
comp.raw_json = "{}".into();
|
||||
let mut reg = track("s1", "t_reg", "Song", "Band", "Studio");
|
||||
reg.album_id = Some("al_reg".into());
|
||||
reg.raw_json = "{}".into();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[comp, reg])
|
||||
.unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Album]);
|
||||
r.filters = vec![clause("compilation", FilterOp::IsTrue, None, None)];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "al_comp");
|
||||
assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_filter_on_track_grouped_album_browse() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! OpenSubsonic compilation flag in entity `raw_json` (Navidrome: `compilation`,
|
||||
//! `isCompilation`, or `releaseTypes` containing `Compilation`), plus the same
|
||||
//! "Various Artists" heuristics the web UI uses when structured flags are absent.
|
||||
//! `isCompilation`, or `releaseTypes` containing `Compilation`).
|
||||
|
||||
/// SQL predicate on any row with a `raw_json` column (album or track).
|
||||
pub fn compilation_raw_json_sql(table_alias: &str) -> String {
|
||||
@@ -18,49 +17,6 @@ pub fn compilation_raw_json_sql(table_alias: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn various_artists_like_sql(column: &str) -> String {
|
||||
format!(
|
||||
"lower(trim(coalesce({column}, ''))) LIKE '%various artists%'",
|
||||
column = column
|
||||
)
|
||||
}
|
||||
|
||||
/// Full compilation predicate for browse filters — JSON flags plus VA artist labels.
|
||||
pub fn compilation_predicate_sql(
|
||||
table_alias: &str,
|
||||
artist_column: Option<&str>,
|
||||
album_artist_column: Option<&str>,
|
||||
) -> String {
|
||||
let mut parts = vec![compilation_raw_json_sql(table_alias)];
|
||||
parts.push(format!(
|
||||
"lower(trim(coalesce(json_extract({a}.raw_json, '$.displayArtist'), ''))) LIKE '%various artists%'",
|
||||
a = table_alias
|
||||
));
|
||||
if let Some(col) = artist_column {
|
||||
parts.push(various_artists_like_sql(col));
|
||||
}
|
||||
if let Some(col) = album_artist_column {
|
||||
parts.push(various_artists_like_sql(col));
|
||||
}
|
||||
format!("({})", parts.join(" OR "))
|
||||
}
|
||||
|
||||
pub fn various_artists_label(s: &str) -> bool {
|
||||
s.trim().to_ascii_lowercase().contains("various artists")
|
||||
}
|
||||
|
||||
/// Track-grouped album rows: prefer album artist when it marks a VA compilation.
|
||||
pub fn pick_album_group_artist(
|
||||
track_artist: Option<String>,
|
||||
album_artist: Option<String>,
|
||||
) -> Option<String> {
|
||||
let aa = album_artist.as_deref().unwrap_or("").trim();
|
||||
if various_artists_label(aa) {
|
||||
return Some(aa.to_string());
|
||||
}
|
||||
track_artist
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -71,24 +27,4 @@ mod tests {
|
||||
assert!(sql.contains("$.compilation"));
|
||||
assert!(sql.contains("$.releaseTypes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicate_includes_artist_columns() {
|
||||
let sql = compilation_predicate_sql("t", Some("t.artist"), Some("t.album_artist"));
|
||||
assert!(sql.contains("t.artist"));
|
||||
assert!(sql.contains("t.album_artist"));
|
||||
assert!(sql.contains("$.displayArtist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_album_group_artist_prefers_va_album_artist() {
|
||||
assert_eq!(
|
||||
pick_album_group_artist(Some("Alice".into()), Some("Various Artists".into())),
|
||||
Some("Various Artists".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
pick_album_group_artist(Some("Alice".into()), Some("Bob".into())),
|
||||
Some("Alice".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(crate) fn reconcile_album_stars(
|
||||
) -> Result<(), String> {
|
||||
runtime
|
||||
.store
|
||||
.with_conn("browse.reconcile_album_stars", |conn| {
|
||||
.with_conn("misc", |conn| {
|
||||
if starred.is_empty() {
|
||||
conn.execute(
|
||||
"UPDATE album SET starred_at = NULL \
|
||||
|
||||
@@ -81,7 +81,7 @@ pub fn link_all_tracks_for_server(
|
||||
server_id: &str,
|
||||
now: i64,
|
||||
) -> Result<u32, String> {
|
||||
store.with_conn_mut("canonical.link_all_tracks", |conn| {
|
||||
store.with_conn_mut("misc", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT id, isrc, mbid_recording FROM track \
|
||||
|
||||
@@ -396,40 +396,6 @@ pub async fn library_get_tracks_by_album(
|
||||
Ok(rows.iter().map(LibraryTrackDto::from_row).collect())
|
||||
}
|
||||
|
||||
/// Upsert Subsonic API song payloads into the library index so pin/download can
|
||||
/// build `media/library/…` paths before a full sync has ingested the rows.
|
||||
#[tauri::command]
|
||||
pub fn library_upsert_songs_from_api(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
songs: Vec<serde_json::Value>,
|
||||
) -> Result<u32, String> {
|
||||
use crate::sync::subsonic_song_to_track_row;
|
||||
use psysonic_integration::subsonic::Song;
|
||||
|
||||
if songs.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let synced_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_secs() as i64;
|
||||
let repo = TrackRepository::new(&runtime.store);
|
||||
let mut rows = Vec::with_capacity(songs.len());
|
||||
for raw in songs {
|
||||
let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
|
||||
rows.push(subsonic_song_to_track_row(
|
||||
&server_id,
|
||||
&song,
|
||||
&raw,
|
||||
synced_at,
|
||||
None,
|
||||
));
|
||||
}
|
||||
repo.upsert_batch(&rows)?;
|
||||
Ok(rows.len() as u32)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_get_artifact(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -476,7 +442,7 @@ pub async fn library_get_offline_path(
|
||||
) -> Result<OfflinePathDto, String> {
|
||||
let path = runtime
|
||||
.store
|
||||
.with_conn("cmd.get_offline_path", |conn| {
|
||||
.with_conn("misc", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT local_path FROM track_offline \
|
||||
WHERE server_id = ?1 AND track_id = ?2",
|
||||
@@ -1028,7 +994,7 @@ pub fn patch_content_hash(
|
||||
}
|
||||
runtime
|
||||
.store
|
||||
.with_conn("cmd.patch_content_hash", |conn| {
|
||||
.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"UPDATE track SET content_hash = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
@@ -1075,7 +1041,7 @@ pub(crate) fn apply_track_patch(
|
||||
|
||||
runtime
|
||||
.store
|
||||
.with_conn("cmd.patch_track", |conn| {
|
||||
.with_conn("misc", |conn| {
|
||||
// One UPDATE per field present — keeps SQL simple and
|
||||
// matches the spec's per-field patch semantics.
|
||||
if let Some(v) = starred_at {
|
||||
@@ -1211,7 +1177,7 @@ pub fn library_purge_server(
|
||||
let mut report = PurgeReportDto::default();
|
||||
runtime
|
||||
.store
|
||||
.with_conn_mut("cmd.purge_server", |conn| {
|
||||
.with_conn_mut("misc", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let track_count: i64 =
|
||||
tx.query_row("SELECT COUNT(*) FROM track WHERE server_id = ?1", params![server_id], |r| r.get(0))?;
|
||||
|
||||
@@ -29,7 +29,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
/// if none exists. All non-PK columns fall back to their schema DEFAULTs
|
||||
/// (`sync_phase='idle'`, `initial_sync_cursor_json='{}'`, …).
|
||||
pub fn ensure(&self, server_id: &str, library_scope: &str) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.ensure", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sync_state (server_id, library_scope) VALUES (?1, ?2)",
|
||||
params![server_id, library_scope],
|
||||
@@ -73,7 +73,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
cursor: &Value,
|
||||
) -> Result<(), String> {
|
||||
let json = serde_json::to_string(cursor).map_err(|e| e.to_string())?;
|
||||
self.store.with_conn("sync_state.set_initial_sync_cursor", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -134,7 +134,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
flags: u32,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_capability_flags", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, capability_flags) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -192,7 +192,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
phase: &str,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_sync_phase", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, sync_phase) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -212,7 +212,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_scan_iso: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_server_last_scan_iso", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, server_last_scan_iso) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -232,7 +232,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_modified_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_indexes_last_modified_ms", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, indexes_last_modified_ms) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -330,7 +330,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_next_poll_at", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, next_poll_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -375,7 +375,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
stats: &Value,
|
||||
) -> Result<(), String> {
|
||||
let json = serde_json::to_string(stats).map_err(|e| e.to_string())?;
|
||||
self.store.with_conn("sync_state.set_poll_stats_json", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, poll_stats_json) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -412,7 +412,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
count: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_local_track_count", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, local_track_count) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -447,7 +447,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
count: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_server_track_count", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, server_track_count) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -488,7 +488,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
unreliable: bool,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_n1_bulk_unreliable", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, n1_bulk_unreliable) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -508,7 +508,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_last_full_sync_at", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_full_sync_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -528,7 +528,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_last_delta_sync_at", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_delta_sync_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -549,7 +549,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_modified_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_artists_last_modified_ms", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, artists_last_modified_ms) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -570,7 +570,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
tier: &str,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_library_tier", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, library_tier) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
|
||||
@@ -191,7 +191,7 @@ impl<'a> TrackRepository<'a> {
|
||||
|
||||
/// Next generation stamp for a full-resync orphan sweep on this server.
|
||||
pub fn next_resync_gen(&self, server_id: &str) -> Result<i64, String> {
|
||||
self.store.with_conn("track.next_resync_gen", |c| {
|
||||
self.store.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT COALESCE(MAX(resync_gen), 0) + 1 FROM track WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
@@ -203,7 +203,7 @@ impl<'a> TrackRepository<'a> {
|
||||
/// IS-7 — soft-delete live rows not re-stamped during the active resync.
|
||||
pub fn sweep_resync_orphans(&self, server_id: &str, resync_gen: i64) -> Result<u32, String> {
|
||||
let now = now_unix_ms();
|
||||
let changed = self.store.with_conn_mut("track.sweep_resync_orphans", |c| {
|
||||
let changed = self.store.with_conn_mut("misc", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET deleted = 1, synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2",
|
||||
@@ -228,18 +228,6 @@ impl<'a> TrackRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// All live rows for a Subsonic track id (any server). Used when legacy offline
|
||||
/// folders name the server by URL index key rather than profile UUID.
|
||||
pub fn find_live_by_id(&self, track_id: &str) -> Result<Vec<TrackRow>, String> {
|
||||
self.store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?;
|
||||
let rows = stmt
|
||||
.query_map(params![track_id], row_to_track_row)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
})
|
||||
}
|
||||
|
||||
/// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs
|
||||
/// preserve their order in the result; unknown / deleted refs
|
||||
/// are silently dropped (frontend reads `tracks.length` against
|
||||
@@ -304,26 +292,6 @@ impl<'a> TrackRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy offline rows keyed by library `server_id` (index key scope).
|
||||
pub fn list_offline_local_paths(
|
||||
&self,
|
||||
server_id: &str,
|
||||
) -> Result<Vec<(String, String, Option<String>)>, String> {
|
||||
self.store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![server_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
}
|
||||
|
||||
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
|
||||
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
|
||||
pub fn list_analysis_hash_bpm_ids_after(
|
||||
@@ -418,7 +386,7 @@ impl<'a> TrackRepository<'a> {
|
||||
if rows.is_empty() {
|
||||
return Ok(RemapStats::default());
|
||||
}
|
||||
self.store.with_conn_mut("track.upsert_batch_remap", |conn| {
|
||||
self.store.with_conn_mut("misc", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut remapped: Vec<RemapEntry> = Vec::new();
|
||||
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
|
||||
@@ -615,13 +583,6 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
|
||||
|
||||
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE id = ?1 AND deleted = 0";
|
||||
|
||||
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
|
||||
@@ -26,7 +26,7 @@ impl<'a> TrackIdHistoryRepository<'a> {
|
||||
server_id: &str,
|
||||
old_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
self.store.with_conn("track_id_history.lookup", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT new_id FROM track_id_history \
|
||||
WHERE server_id = ?1 AND old_id = ?2",
|
||||
@@ -40,7 +40,7 @@ impl<'a> TrackIdHistoryRepository<'a> {
|
||||
/// Count the rows recorded for this server — used by tests and by
|
||||
/// post-sync diagnostics (Settings „Library index" panel later).
|
||||
pub fn count_for_server(&self, server_id: &str) -> Result<i64, String> {
|
||||
self.store.with_conn("track_id_history.count", |conn| {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM track_id_history WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
|
||||
@@ -103,11 +103,6 @@ impl LibraryStore {
|
||||
}
|
||||
|
||||
/// Writer connection — sync ingest, migrations, mutations.
|
||||
///
|
||||
/// `op` is logged on slow writes (`[library-db] SLOW write op=…`) — use a
|
||||
/// stable `module.action` label (e.g. `sync_state.set_sync_phase`,
|
||||
/// `track.upsert_batch_remap`), not the generic `"misc"`, so production
|
||||
/// stalls can be attributed to a specific call site.
|
||||
pub(crate) fn with_conn<R>(
|
||||
&self,
|
||||
op: &'static str,
|
||||
|
||||
@@ -284,7 +284,7 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
|
||||
fn local_track_updated_watermark(&self) -> Result<Option<i64>, SyncError> {
|
||||
self.store
|
||||
.with_conn("delta.local_track_watermark", |c| {
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT MAX(server_updated_at) FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0",
|
||||
@@ -297,7 +297,7 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
|
||||
fn local_album_ids(&self) -> Result<HashSet<String>, SyncError> {
|
||||
self.store
|
||||
.with_conn("delta.local_album_ids", |c| {
|
||||
.with_conn("misc", |c| {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT DISTINCT album_id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND album_id IS NOT NULL",
|
||||
|
||||
@@ -115,7 +115,7 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
|
||||
fn next_candidates(&self, budget: u32) -> Result<Vec<String>, SyncError> {
|
||||
self.store
|
||||
.with_conn("tombstone.next_candidates", |c| {
|
||||
.with_conn("misc", |c| {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 \
|
||||
@@ -133,7 +133,7 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
|
||||
fn mark_deleted(&self, id: &str) -> Result<(), SyncError> {
|
||||
self.store
|
||||
.with_conn("tombstone.mark_deleted", |c| {
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET deleted = 1, synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
@@ -146,7 +146,7 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
|
||||
fn mark_synced(&self, id: &str) -> Result<(), SyncError> {
|
||||
self.store
|
||||
.with_conn("tombstone.mark_synced", |c| {
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
|
||||
@@ -3,12 +3,10 @@ name = "psysonic-syncfs"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
psysonic-library = { path = "../psysonic-library" }
|
||||
psysonic-analysis = { path = "../psysonic-analysis" }
|
||||
psysonic-audio = { path = "../psysonic-audio" }
|
||||
|
||||
|
||||
+2
-100
@@ -1,4 +1,4 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
/// Recursively sums the size of all files under `root`.
|
||||
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
|
||||
@@ -25,47 +25,6 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
|
||||
total
|
||||
}
|
||||
|
||||
/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list.
|
||||
pub fn collect_regular_files_under(root: &Path) -> Vec<std::path::PathBuf> {
|
||||
if !root.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let rd = match std::fs::read_dir(&dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.is_file() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
fn normalize_path_for_prefix(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path.
|
||||
pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
|
||||
let mut current = path.parent()?;
|
||||
loop {
|
||||
match current.file_name().and_then(|s| s.to_str()) {
|
||||
Some("cache") | Some("library") | Some("favorites") => {
|
||||
return Some(current.to_path_buf());
|
||||
}
|
||||
_ => current = current.parent()?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks upward from `start_dir`, removing each empty directory using `remove_dir`
|
||||
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
|
||||
/// boundary is reached, or removal fails.
|
||||
@@ -73,11 +32,9 @@ pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
|
||||
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
|
||||
/// not under `boundary`, the function is a no-op.
|
||||
pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
|
||||
let boundary_norm = normalize_path_for_prefix(boundary);
|
||||
let mut current = Some(start_dir.to_path_buf());
|
||||
while let Some(dir) = current {
|
||||
let dir_norm = normalize_path_for_prefix(&dir);
|
||||
if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) {
|
||||
if dir == boundary || !dir.starts_with(boundary) {
|
||||
break;
|
||||
}
|
||||
match std::fs::read_dir(&dir) {
|
||||
@@ -95,29 +52,6 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-order sweep: removes empty child directories under `root` (never `root` itself).
|
||||
pub fn prune_empty_subdirs_under(root: &Path) {
|
||||
if !root.is_dir() {
|
||||
return;
|
||||
}
|
||||
let children: Vec<PathBuf> = std::fs::read_dir(root)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
for child in children {
|
||||
prune_empty_subdirs_under(&child);
|
||||
let is_empty = std::fs::read_dir(&child)
|
||||
.map(|mut rd| rd.next().is_none())
|
||||
.unwrap_or(false);
|
||||
if is_empty {
|
||||
let _ = std::fs::remove_dir(&child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -153,17 +87,6 @@ mod tests {
|
||||
assert!(path.exists(), "boundary dir must never be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_regular_files_under_lists_nested_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.mp3"), b"x").unwrap();
|
||||
let sub = dir.path().join("Artist/Album");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
std::fs::write(sub.join("b.flac"), b"yy").unwrap();
|
||||
let files = collect_regular_files_under(dir.path());
|
||||
assert_eq!(files.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -175,25 +98,4 @@ mod tests {
|
||||
assert!(!child.exists(), "empty leaf should be pruned");
|
||||
assert!(parent.exists(), "non-empty parent must stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tier_boundary_from_path_finds_cache_root() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let track = root
|
||||
.path()
|
||||
.join("vol/media/cache/my.server/Artist/Album/track.flac");
|
||||
std::fs::create_dir_all(track.parent().unwrap()).unwrap();
|
||||
let boundary = local_tier_boundary_from_path(&track).unwrap();
|
||||
assert_eq!(boundary, root.path().join("vol/media/cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_empty_subdirs_under_removes_nested_empty_tree() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cache = root.path().join("cache").join("srv").join("Artist").join("Album");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
prune_empty_subdirs_under(&root.path().join("cache"));
|
||||
assert!(!cache.exists());
|
||||
assert!(root.path().join("cache").exists(), "tier root preserved");
|
||||
}
|
||||
}
|
||||
|
||||
-1422
File diff suppressed because it is too large
Load Diff
@@ -2,4 +2,3 @@ mod fs_utils;
|
||||
pub mod offline;
|
||||
pub mod downloads;
|
||||
pub mod hot;
|
||||
pub mod local;
|
||||
|
||||
@@ -5,8 +5,6 @@ pub mod cli;
|
||||
mod cover_cache;
|
||||
mod library_analysis_backfill;
|
||||
mod lib_commands;
|
||||
mod theme_import;
|
||||
pub mod theme_animation;
|
||||
|
||||
pub use psysonic_integration::discord;
|
||||
|
||||
@@ -97,10 +95,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
& !tauri_plugin_window_state::StateFlags::VISIBLE,
|
||||
)
|
||||
.with_denylist(&["mini"])
|
||||
.build()
|
||||
)
|
||||
@@ -120,38 +114,6 @@ pub fn run() {
|
||||
let _ = window.set_title("Psysonic (Dev)");
|
||||
}
|
||||
|
||||
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
|
||||
// Poll a local theme.css and push it into the running app on save,
|
||||
// so theme authors get a live loop without re-importing a zip. The
|
||||
// frontend (dev only) installs it under the id in its
|
||||
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
|
||||
match args.get(i + 1).cloned() {
|
||||
Some(path) => {
|
||||
eprintln!("[theme-watch] watching {path}");
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
let p = std::path::PathBuf::from(&path);
|
||||
let mut last_css = String::new();
|
||||
loop {
|
||||
if let Ok(css) = std::fs::read_to_string(&p) {
|
||||
if css != last_css {
|
||||
last_css = css.clone();
|
||||
let _ = handle.emit("theme-watch:css", css);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
}
|
||||
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Analysis cache (SQLite) ───────────────────────────────────
|
||||
{
|
||||
let cache = analysis_cache::AnalysisCache::init(app.handle())
|
||||
@@ -629,27 +591,8 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_page_load(|webview, payload| {
|
||||
if webview.label() != "main" {
|
||||
return;
|
||||
}
|
||||
|
||||
match payload.event() {
|
||||
tauri::webview::PageLoadEvent::Started => {
|
||||
let window = webview.window().clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(48));
|
||||
let _ = window.show();
|
||||
});
|
||||
}
|
||||
tauri::webview::PageLoadEvent::Finished => {
|
||||
let _ = webview.window().show();
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
theme_import::import_theme_zip,
|
||||
backup_export_library_db,
|
||||
backup_import_library_db,
|
||||
backup_export_full,
|
||||
@@ -676,7 +619,6 @@ pub fn run() {
|
||||
performance_cpu_snapshot,
|
||||
set_subsonic_wire_user_agent,
|
||||
no_compositing_mode,
|
||||
theme_animation_risk,
|
||||
linux_xdg_session_type,
|
||||
is_tiling_wm_cmd,
|
||||
open_mini_player,
|
||||
@@ -773,7 +715,6 @@ pub fn run() {
|
||||
psysonic_library::commands::library_get_track,
|
||||
psysonic_library::commands::library_get_tracks_batch,
|
||||
psysonic_library::commands::library_get_tracks_by_album,
|
||||
psysonic_library::commands::library_upsert_songs_from_api,
|
||||
psysonic_library::commands::library_get_artifact,
|
||||
psysonic_library::commands::library_get_facts,
|
||||
psysonic_library::commands::library_get_offline_path,
|
||||
@@ -834,19 +775,6 @@ pub fn run() {
|
||||
psysonic_syncfs::cache::offline::clear_offline_cancel,
|
||||
psysonic_syncfs::cache::offline::delete_offline_track,
|
||||
psysonic_syncfs::cache::offline::get_offline_cache_size,
|
||||
psysonic_syncfs::cache::local::download_track_local,
|
||||
psysonic_syncfs::cache::local::probe_library_track_local,
|
||||
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
|
||||
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
|
||||
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
|
||||
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
|
||||
psysonic_syncfs::cache::local::probe_media_files,
|
||||
psysonic_syncfs::cache::local::get_media_tier_size,
|
||||
psysonic_syncfs::cache::local::purge_media_tier,
|
||||
psysonic_syncfs::cache::local::delete_media_file,
|
||||
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
|
||||
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
|
||||
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
|
||||
psysonic_syncfs::cache::hot::download_track_hot_cache,
|
||||
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
|
||||
psysonic_syncfs::cache::hot::get_hot_cache_size,
|
||||
|
||||
@@ -49,8 +49,8 @@ pub(crate) struct LogTailDto {
|
||||
pub dropped: bool,
|
||||
}
|
||||
|
||||
/// Incremental tail of the in-memory runtime log buffer for the PsyLab Logs tab.
|
||||
/// `after_seq` is the highest seq the UI already has (omit for
|
||||
/// Incremental tail of the in-memory runtime log buffer for the Performance
|
||||
/// Probe Logs tab. `after_seq` is the highest seq the UI already has (omit for
|
||||
/// the initial fetch of the most recent `max` lines).
|
||||
#[tauri::command]
|
||||
pub(crate) fn tail_runtime_logs(after_seq: Option<u64>, max: Option<usize>) -> LogTailDto {
|
||||
|
||||
@@ -23,7 +23,6 @@ pub(crate) use perf::performance_cpu_snapshot;
|
||||
pub(crate) use platform::{
|
||||
linux_wayland_gpu_font_tuning_active, linux_wayland_text_render_settings_available,
|
||||
set_linux_wayland_text_render_profile, set_linux_webkit_smooth_scrolling, set_window_decorations,
|
||||
theme_animation_risk,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use platform::{
|
||||
|
||||
@@ -137,29 +137,6 @@ pub(crate) fn linux_webkit_apply_wayland_gpu_font_tuning(win: &tauri::WebviewWin
|
||||
}
|
||||
|
||||
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
|
||||
/// Tauri command: true when theme animations may be costly on this setup —
|
||||
/// Linux with the Nvidia WebKit quirk active (recorded once at startup) or
|
||||
/// compositing forced off. The frontend warns on animated themes when true.
|
||||
/// Always false off Linux.
|
||||
#[tauri::command]
|
||||
pub(crate) fn theme_animation_risk() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Compositing forced off → GPU-accelerated effects/animation are costly.
|
||||
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
crate::theme_animation::nvidia_quirk_active()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
|
||||
if let Some(win) = app_handle.get_webview_window("main") {
|
||||
|
||||
@@ -17,16 +17,11 @@ fn apply_linux_webkit_nvidia_quirk() {
|
||||
// may still be `XDG_SESSION_TYPE=wayland`. The quirk maps that to `__NV_DISABLE_EXPLICIT_SYNC`,
|
||||
// which mismatches a real X11 EGL stack and can leave the webview gray — mirror the native-X11
|
||||
// branch (`WEBKIT_DISABLE_DMABUF_RENDERER` only) whenever GDK is pinned to x11 first in the list.
|
||||
// Detect once and record it for the UI's animated-theme CPU-load warning,
|
||||
// so the theme_animation_risk command never has to re-probe the GPU.
|
||||
let kind = needs_workaround();
|
||||
psysonic_lib::theme_animation::set_nvidia_quirk_active(!matches!(kind, WorkaroundKind::None));
|
||||
|
||||
let forced_x11_gdk = std::env::var("GDK_BACKEND").ok().is_some_and(|s| {
|
||||
matches!(s.split(',').next().map(str::trim), Some("x11"))
|
||||
});
|
||||
if forced_x11_gdk {
|
||||
match kind {
|
||||
match needs_workaround() {
|
||||
WorkaroundKind::None => {}
|
||||
WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => {
|
||||
set_webkit_disable_dmabuf_renderer();
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//! Startup-recorded display hint for the theme system.
|
||||
//!
|
||||
//! The Nvidia/WebKit GPU quirk is detected once at process start (in `main()`,
|
||||
//! before the webview/GTK init). We record whether it was needed so the UI can
|
||||
//! warn that animated themes may raise CPU load on this setup — without
|
||||
//! re-probing the GPU later. Read via the `theme_animation_risk` command
|
||||
//! (`lib_commands::app_api::platform`).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static NVIDIA_QUIRK_ACTIVE: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Record the startup Nvidia-WebKit-quirk detection. Called once from `main()`.
|
||||
pub fn set_nvidia_quirk_active(active: bool) {
|
||||
let _ = NVIDIA_QUIRK_ACTIVE.set(active);
|
||||
}
|
||||
|
||||
/// Whether the Nvidia WebKit quirk was needed at startup. Linux-only: read
|
||||
/// solely by the Linux branch of `theme_animation_risk`. False when unrecorded
|
||||
/// (GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`). Gated so it is
|
||||
/// not dead code on Windows/macOS, where the quirk never applies.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn nvidia_quirk_active() -> bool {
|
||||
NVIDIA_QUIRK_ACTIVE.get().copied().unwrap_or(false)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
//! Local theme-package import.
|
||||
//!
|
||||
//! Reads a user-picked `.zip` and returns its `manifest.json` + `theme.css`
|
||||
//! to the frontend, which runs the full theme-store contract validation
|
||||
//! (`src/utils/themes/validateThemePackage.ts`) before installing.
|
||||
//!
|
||||
//! Only those two entries are pulled out — the thumbnail is not needed (the UI
|
||||
//! derives a swatch from the CSS). Parsing the untrusted archive happens here
|
||||
//! in Rust, outside the webview, and every read is size-capped so a malformed
|
||||
//! or hostile archive (lying header, zip-bomb, path traversal) cannot exhaust
|
||||
//! memory or escape the archive.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// On-disk archive cap. A real token-only theme zip is a few KB.
|
||||
const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// Per-entry uncompressed caps — mirror the frontend/CI limits
|
||||
/// (`validateThemeCss` caps CSS at 64 KB; the manifest is tiny).
|
||||
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||||
const MAX_CSS_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ImportedThemeFiles {
|
||||
pub manifest: String,
|
||||
pub css: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_theme_zip(path: String) -> Result<ImportedThemeFiles, String> {
|
||||
let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?;
|
||||
let len = file
|
||||
.metadata()
|
||||
.map_err(|e| format!("cannot read file info: {e}"))?
|
||||
.len();
|
||||
if len > MAX_ARCHIVE_BYTES {
|
||||
return Err(format!(
|
||||
"archive is too large (> {} KB)",
|
||||
MAX_ARCHIVE_BYTES / 1024
|
||||
));
|
||||
}
|
||||
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(file).map_err(|_| "not a valid .zip archive".to_string())?;
|
||||
|
||||
let manifest = read_capped_entry(&mut archive, "manifest.json", MAX_MANIFEST_BYTES)?
|
||||
.ok_or_else(|| "manifest.json was not found in the archive".to_string())?;
|
||||
let css = read_capped_entry(&mut archive, "theme.css", MAX_CSS_BYTES)?
|
||||
.ok_or_else(|| "theme.css was not found in the archive".to_string())?;
|
||||
|
||||
Ok(ImportedThemeFiles { manifest, css })
|
||||
}
|
||||
|
||||
/// Find the first non-directory entry whose file name equals `wanted` (at the
|
||||
/// archive root or under a single wrapping folder), reject path traversal, and
|
||||
/// read it as UTF-8 text under `cap` bytes. Both the declared size and the
|
||||
/// actual read are bounded, so a lying header cannot allocate past the cap.
|
||||
fn read_capped_entry<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
wanted: &str,
|
||||
cap: usize,
|
||||
) -> Result<Option<String>, String> {
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("corrupt archive entry: {e}"))?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
// `enclosed_name()` is `None` for absolute paths or `..` traversal.
|
||||
let base = match entry.enclosed_name() {
|
||||
Some(p) => match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => continue,
|
||||
},
|
||||
None => return Err("archive contains an unsafe path".to_string()),
|
||||
};
|
||||
if base != wanted {
|
||||
continue;
|
||||
}
|
||||
if entry.size() > cap as u64 {
|
||||
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
entry
|
||||
.by_ref()
|
||||
.take(cap as u64 + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("cannot read {wanted}: {e}"))?;
|
||||
if buf.len() > cap {
|
||||
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
|
||||
}
|
||||
return String::from_utf8(buf)
|
||||
.map(Some)
|
||||
.map_err(|_| format!("{wanted} is not valid UTF-8"));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
@@ -22,8 +22,7 @@
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"visible": false,
|
||||
"backgroundColor": "#1e1e2e",
|
||||
"visible": true,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
|
||||
-56
@@ -1,10 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { useLyricsStore } from './store/lyricsStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useInstalledThemesStore } from './store/installedThemesStore';
|
||||
import { syncInjectedThemes } from './utils/themes/themeInjection';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { getWindowKind } from './app/windowKind';
|
||||
@@ -17,66 +13,14 @@ export default function App() {
|
||||
useThemeStore(s => s.theme);
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const installedThemes = useInstalledThemesStore(s => s.themes);
|
||||
|
||||
// Document-attribute hooks are shared between both window kinds — each
|
||||
// webview has its own `document`, and theme / font / track-preview tokens
|
||||
// are read by CSS in both trees.
|
||||
|
||||
// Installed community themes have no build-time CSS — inject their
|
||||
// `[data-theme='<id>']` blocks into <head> from the persisted (localStorage)
|
||||
// store. Runs before the data-theme effect below so the matching style exists
|
||||
// when the attribute is applied. The store hydrates synchronously, so an
|
||||
// active community theme is painted without a network round-trip.
|
||||
useEffect(() => {
|
||||
syncInjectedThemes(installedThemes);
|
||||
}, [installedThemes]);
|
||||
|
||||
// Dev only: `--theme-watch <theme.css>` (debug builds) pushes a local theme's
|
||||
// CSS in on every save. Install it under the id in its `[data-theme='<id>']`
|
||||
// selector and apply it — the syncInjectedThemes effect above re-injects, so
|
||||
// authoring is live without re-importing a zip. Never wired in production.
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
void import('@tauri-apps/api/event').then(({ listen }) => {
|
||||
const sub = listen<string>('theme-watch:css', ({ payload }) => {
|
||||
const id = payload.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
|
||||
if (!id) return;
|
||||
useInstalledThemesStore.getState().install({
|
||||
id, name: id, author: 'dev', version: '0.0.0', description: '', mode: 'dark', css: payload, installedAt: Date.now(),
|
||||
});
|
||||
useThemeStore.getState().setTheme(id);
|
||||
});
|
||||
// Guard the mocked-in-tests case where listen() isn't a promise.
|
||||
if (sub && typeof sub.then === 'function') sub.then(u => { unlisten = u; });
|
||||
}).catch(() => {});
|
||||
return () => unlisten?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
// Expose app state on the theme root so themes can react to it with a
|
||||
// same-element compound selector, e.g. `[data-theme='x'][data-playing='true']`.
|
||||
// The set of allowed state attributes is the contract's `stateSelectors`.
|
||||
// (Sidebar-collapsed is set in AppShell, where that state lives.)
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-playing', isPlaying ? 'true' : 'false');
|
||||
}, [isPlaying]);
|
||||
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-fullscreen', isFullscreenOpen ? 'true' : 'false');
|
||||
}, [isFullscreenOpen]);
|
||||
|
||||
const lyricsOpen = useLyricsStore(s => s.activeTab === 'lyrics');
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-lyrics-open', lyricsOpen ? 'true' : 'false');
|
||||
}, [lyricsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
@@ -473,18 +473,6 @@ export function libraryGetTrack(
|
||||
.then(track => (track ? { ...track, serverId } : track));
|
||||
}
|
||||
|
||||
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
|
||||
export function libraryUpsertSongsFromApi(
|
||||
serverId: string,
|
||||
songs: unknown[],
|
||||
): Promise<number> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
|
||||
}
|
||||
|
||||
/** `library_get_tracks_batch` cap (spec §7.1). */
|
||||
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
|
||||
|
||||
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||
const indexKeyMap = new Map<string, string>();
|
||||
const remapped = refs.map(ref => {
|
||||
@@ -499,18 +487,6 @@ export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrack
|
||||
})));
|
||||
}
|
||||
|
||||
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
|
||||
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||
if (refs.length === 0) return [];
|
||||
const out: LibraryTrackDto[] = [];
|
||||
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
|
||||
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
|
||||
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
|
||||
out.push(...batch);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function libraryGetTracksByAlbum(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
|
||||
@@ -20,11 +20,6 @@ vi.mock('axios', () => ({
|
||||
default: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForActiveServer: () => true,
|
||||
shouldAttemptSubsonicForServer: () => true,
|
||||
}));
|
||||
|
||||
import axios from 'axios';
|
||||
import { pingWithCredentials, ping } from './subsonic';
|
||||
import { getAlbumInfo2 } from './subsonicAlbumInfo';
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./subsonicOpenSubsonic', () => ({
|
||||
fetchOpenSubsonicExtensionsWithCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic';
|
||||
import { scheduleInstantMixProbeForServer } from './subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { SubsonicServerIdentity } from '../utils/server/subsonicServerIdentity';
|
||||
|
||||
const fetchMock = vi.mocked(fetchOpenSubsonicExtensionsWithCredentials);
|
||||
const SID = 'srv-probe';
|
||||
const id062: SubsonicServerIdentity = { type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true };
|
||||
|
||||
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
function reset() {
|
||||
useAuthStore.setState({
|
||||
subsonicServerIdentityByServer: {},
|
||||
audiomusePluginProbeByServer: {},
|
||||
instantMixProbeByServer: {},
|
||||
audiomuseNavidromeByServer: {},
|
||||
audiomuseNavidromeIssueByServer: {},
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe('scheduleInstantMixProbeForServer (idempotency)', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(['sonicSimilarity']);
|
||||
reset();
|
||||
});
|
||||
|
||||
it('probes once, caches the result, then skips on the next poll', async () => {
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await flush();
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
|
||||
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-probes when forced (user-initiated refresh)', async () => {
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
await flush();
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062, true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-probes after a prior error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(null);
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
await flush();
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('error');
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSubsonicServerIdentity (version-change cache invalidation)', () => {
|
||||
beforeEach(reset);
|
||||
|
||||
it('clears cached probes on a version change but keeps the opt-in', () => {
|
||||
useAuthStore.setState({
|
||||
subsonicServerIdentityByServer: { [SID]: id062 },
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
audiomuseNavidromeByServer: { [SID]: true },
|
||||
} as never);
|
||||
|
||||
useAuthStore.getState().setSubsonicServerIdentity(SID, { type: 'navidrome', serverVersion: '0.63.0', openSubsonic: true });
|
||||
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.audiomusePluginProbeByServer[SID]).toBeUndefined();
|
||||
expect(s.audiomuseNavidromeByServer[SID]).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps cached probes when the identity is unchanged', () => {
|
||||
useAuthStore.setState({
|
||||
subsonicServerIdentityByServer: { [SID]: id062 },
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
} as never);
|
||||
|
||||
useAuthStore.getState().setSubsonicServerIdentity(SID, { ...id062 });
|
||||
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
|
||||
});
|
||||
});
|
||||
+11
-78
@@ -2,34 +2,17 @@ import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
isNavidromeAudiomuseSoftwareEligible,
|
||||
type InstantMixProbeResult,
|
||||
type SubsonicServerIdentity,
|
||||
} from '../utils/server/subsonicServerIdentity';
|
||||
import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic';
|
||||
import { buildCapabilityContext } from '../serverCapabilities/context';
|
||||
import {
|
||||
PROBE_LEGACY_INSTANT_MIX,
|
||||
PROBE_OPENSUBSONIC_EXTENSIONS,
|
||||
SERVER_CAPABILITY_CATALOG,
|
||||
SONIC_SIMILARITY_EXTENSION,
|
||||
} from '../serverCapabilities/catalog';
|
||||
import { neededProbeIds } from '../serverCapabilities/resolve';
|
||||
import {
|
||||
SUBSONIC_CLIENT,
|
||||
SUBSONIC_API_VERSION,
|
||||
api,
|
||||
apiWithCredentials,
|
||||
secureRandomSalt,
|
||||
} from './subsonicClient';
|
||||
import type { PingFailure, PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
/** Map a Subsonic error code to a coarse failure category for the UI. */
|
||||
function classifyPingError(code: number | undefined, message: string | undefined): PingFailure {
|
||||
let reason: PingFailure['reason'] = 'server';
|
||||
if (code === 40 || code === 41 || code === 50) reason = 'auth';
|
||||
else if (code === 20 || code === 30) reason = 'version';
|
||||
return { reason, code, message };
|
||||
}
|
||||
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function ping(): Promise<boolean> {
|
||||
try {
|
||||
@@ -52,33 +35,21 @@ export async function pingWithCredentials(
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
const resp = await axios.get(`${base}/rest/ping.view`, {
|
||||
params: { u: username, t: token, s: salt, v: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json' },
|
||||
params: { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' },
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout: 15000,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
const ok = data?.status === 'ok';
|
||||
const identity = {
|
||||
return {
|
||||
ok,
|
||||
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
};
|
||||
if (ok) return { ok: true, ...identity };
|
||||
// Reachable server that rejected the ping — keep the Subsonic reason so the
|
||||
// UI can show a specific message (code 30 = protocol too high, 40 = bad
|
||||
// credentials, …) instead of an opaque failure.
|
||||
const code = typeof data?.error?.code === 'number' ? data.error.code : undefined;
|
||||
const message = typeof data?.error?.message === 'string' ? data.error.message : undefined;
|
||||
console.warn('[psysonic] ping rejected by server:', serverUrl, 'sentVersion=', SUBSONIC_API_VERSION, data?.error ?? data);
|
||||
return { ok: false, failure: classifyPingError(code, message), ...identity };
|
||||
} catch (err) {
|
||||
// Never reached the server (DNS, refused, timeout, TLS-cert not trusted, or
|
||||
// a blocked cross-origin request). The WebView hides the exact cause, so
|
||||
// pass the raw detail through for the toast + log.
|
||||
const detail =
|
||||
(err as { message?: string })?.message || (err as { code?: string })?.code || 'network error';
|
||||
console.warn('[psysonic] pingWithCredentials failed:', serverUrl, err);
|
||||
return { ok: false, failure: { reason: 'network', message: String(detail) } };
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,54 +104,16 @@ export async function probeInstantMixWithCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful ping, run the server-capability probes needed by the catalog
|
||||
* (`serverCapabilities/`). Which probes run is decided by the strategies eligible
|
||||
* for this server generation — not by inline version checks here.
|
||||
*
|
||||
* Currently: Navidrome ≥ 0.62 → `getOpenSubsonicExtensions` (sonicSimilarity);
|
||||
* Navidrome 0.60–0.61 → legacy `getSimilarSongs` Instant Mix probe.
|
||||
*
|
||||
* Idempotent: a server's advertised capabilities are static within a session, so
|
||||
* once a definitive result is cached the probe is skipped. This is called on every
|
||||
* 120 s connection poll, so re-fetching each time would be wasteful and would flip
|
||||
* the resolved status (and the routed endpoint) through a `probing` window. Pass
|
||||
* `force` for user-initiated refreshes (add/edit/test server); a server version or
|
||||
* type change clears the cache (see `setSubsonicServerIdentity`), forcing a re-probe.
|
||||
*/
|
||||
/** After a successful ping, probe Instant Mix in the background (Navidrome ≥ 0.60 only). */
|
||||
export function scheduleInstantMixProbeForServer(
|
||||
serverId: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
identity: SubsonicServerIdentity,
|
||||
force = false,
|
||||
): void {
|
||||
const ctx = buildCapabilityContext(identity);
|
||||
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
|
||||
const store = useAuthStore.getState();
|
||||
|
||||
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
|
||||
const cached = store.audiomusePluginProbeByServer[serverId];
|
||||
// Re-probe only without a definitive cached result (or on force / prior error).
|
||||
// `probing` means an in-flight fetch — skip to avoid a duplicate request.
|
||||
if (force || cached === undefined || cached === 'error') {
|
||||
store.setAudiomusePluginProbe(serverId, 'probing');
|
||||
void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => {
|
||||
const result = extensions === null
|
||||
? 'error'
|
||||
: extensions.includes(SONIC_SIMILARITY_EXTENSION) ? 'present' : 'absent';
|
||||
useAuthStore.getState().setAudiomusePluginProbe(serverId, result);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
|
||||
const cached = store.instantMixProbeByServer[serverId];
|
||||
if (force || cached === undefined || cached === 'error') {
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
|
||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return;
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
|
||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./subsonicClient', () => ({
|
||||
api: vi.fn(),
|
||||
apiForServer: vi.fn(),
|
||||
libraryFilterParams: () => ({}),
|
||||
libraryFilterParamsForServer: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock('./subsonicLibrary', () => ({
|
||||
filterSongsToActiveLibrary: async (songs: unknown[]) => songs,
|
||||
filterSongsToServerLibrary: async (songs: unknown[]) => songs,
|
||||
similarSongsRequestCount: (count: number) => count,
|
||||
}));
|
||||
|
||||
import { api } from './subsonicClient';
|
||||
import { fetchSimilarTracksRouted } from './subsonicArtists';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const SID = 'srv-router';
|
||||
const apiMock = vi.mocked(api);
|
||||
|
||||
function seedServer(identity: Record<string, unknown>, probes: Record<string, unknown>) {
|
||||
useAuthStore.setState({
|
||||
activeServerId: SID,
|
||||
subsonicServerIdentityByServer: { [SID]: identity as never },
|
||||
audiomusePluginProbeByServer: {},
|
||||
instantMixProbeByServer: {},
|
||||
audiomuseNavidromeByServer: {},
|
||||
...probes,
|
||||
} as never);
|
||||
}
|
||||
|
||||
const SONIC_RESPONSE = { sonicMatch: [{ entry: { id: 'sonic-1', title: 'Sonic' } }] };
|
||||
const SIMILAR_RESPONSE = { similarSongs: { song: [{ id: 'legacy-1', title: 'Legacy' }] } };
|
||||
|
||||
describe('fetchSimilarTracksRouted', () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
it('prefers sonicSimilarity on Navidrome 0.62 with plugin', async () => {
|
||||
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
});
|
||||
apiMock.mockImplementation(async (endpoint: string) =>
|
||||
(endpoint === 'getSonicSimilarTracks.view' ? SONIC_RESPONSE : SIMILAR_RESPONSE) as never);
|
||||
|
||||
const result = await fetchSimilarTracksRouted('seed', 10);
|
||||
expect(result.map(s => s.id)).toEqual(['sonic-1']);
|
||||
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
|
||||
expect(apiMock).not.toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
|
||||
});
|
||||
|
||||
it('falls back to legacy when sonic returns empty', async () => {
|
||||
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
});
|
||||
apiMock.mockImplementation(async (endpoint: string) =>
|
||||
(endpoint === 'getSonicSimilarTracks.view' ? { sonicMatch: [] } : SIMILAR_RESPONSE) as never);
|
||||
|
||||
const result = await fetchSimilarTracksRouted('seed', 10);
|
||||
expect(result.map(s => s.id)).toEqual(['legacy-1']);
|
||||
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
|
||||
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
|
||||
});
|
||||
|
||||
it('uses legacy only on Navidrome 0.62 without plugin', async () => {
|
||||
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
|
||||
audiomusePluginProbeByServer: { [SID]: 'absent' },
|
||||
});
|
||||
apiMock.mockImplementation(async () => SIMILAR_RESPONSE as never);
|
||||
|
||||
const result = await fetchSimilarTracksRouted('seed', 10);
|
||||
expect(result.map(s => s.id)).toEqual(['legacy-1']);
|
||||
expect(apiMock).not.toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
|
||||
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,6 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||
import { filterSongsToServerLibrary } from './subsonicLibrary';
|
||||
import { filterSongsToActiveLibrary, similarSongsRequestCount } from './subsonicLibrary';
|
||||
import {
|
||||
FEATURE_AUDIOMUSE_SIMILAR_TRACKS,
|
||||
OP_SIMILAR_TRACKS,
|
||||
} from '../serverCapabilities/catalog';
|
||||
import { resolveCallRoutesForServer } from '../serverCapabilities/storeView';
|
||||
import type {
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
@@ -114,45 +109,3 @@ export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicS
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonic (audio-analysis) similar tracks via the OpenSubsonic `sonicSimilarity`
|
||||
* extension (Navidrome ≥ 0.62 + AudioMuse plugin). Returns `[]` when the server
|
||||
* has no provider (HTTP 404) so callers can fall back.
|
||||
*/
|
||||
export async function getSonicSimilarTracks(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ sonicMatch: Array<{ entry?: SubsonicSong }> | { entry?: SubsonicSong } }>(
|
||||
'getSonicSimilarTracks.view',
|
||||
{ id, count: requestCount, ...libraryFilterParams() },
|
||||
);
|
||||
const raw = data.sonicMatch;
|
||||
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
const songs = list.map(m => m.entry).filter((e): e is SubsonicSong => !!e);
|
||||
if (songs.length === 0) return [];
|
||||
const filtered = await filterSongsToActiveLibrary(songs);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability-routed similar tracks for the active server. Prefers the sonic
|
||||
* similarity endpoint when the AudioMuse plugin is detected (Navidrome ≥ 0.62),
|
||||
* falling back to legacy `getSimilarSongs` on empty/unavailable.
|
||||
*/
|
||||
export async function fetchSimilarTracksRouted(songId: string, count = 50): Promise<SubsonicSong[]> {
|
||||
const { activeServerId } = useAuthStore.getState();
|
||||
if (!activeServerId) return getSimilarSongs(songId, count);
|
||||
const routes = resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS);
|
||||
if (routes.length === 0) return getSimilarSongs(songId, count);
|
||||
for (const route of routes) {
|
||||
const songs = route.transport === 'opensubsonic'
|
||||
? await getSonicSimilarTracks(songId, count)
|
||||
: await getSimilarSongs(songId, count);
|
||||
if (songs.length > 0) return songs;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -8,15 +8,6 @@ import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/s
|
||||
|
||||
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
||||
|
||||
/**
|
||||
* Subsonic REST protocol version sent on every request. A server returns
|
||||
* error 30 ("Incompatible REST protocol version") if the client asks for a
|
||||
* HIGHER version than it supports — surfaced to the user via the connection
|
||||
* error toast. OpenSubsonic extensions are negotiated via the `openSubsonic`
|
||||
* response flag, not this number.
|
||||
*/
|
||||
export const SUBSONIC_API_VERSION = '1.16.1';
|
||||
|
||||
export function secureRandomSalt(): string {
|
||||
const buf = new Uint8Array(8);
|
||||
crypto.getRandomValues(buf);
|
||||
@@ -26,7 +17,7 @@ export function secureRandomSalt(): string {
|
||||
export function getAuthParams(username: string, password: string) {
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
return { u: username, t: token, s: salt, v: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json' };
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' };
|
||||
}
|
||||
|
||||
export function restBaseFromUrl(serverUrl: string): string {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
shouldAttemptSubsonicForActiveServer,
|
||||
shouldAttemptSubsonicForServer,
|
||||
} from '../utils/network/subsonicNetworkGuard';
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||
import type {
|
||||
RandomSongsFilters,
|
||||
@@ -61,7 +57,6 @@ export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
|
||||
}
|
||||
|
||||
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return [];
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type: 'random',
|
||||
size,
|
||||
@@ -76,7 +71,6 @@ export async function getAlbumList(
|
||||
offset = 0,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return [];
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type,
|
||||
size,
|
||||
@@ -218,7 +212,6 @@ export async function getAlbumListForServer(
|
||||
offset = 0,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) return [];
|
||||
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
|
||||
type,
|
||||
size,
|
||||
@@ -231,7 +224,6 @@ export async function getAlbumListForServer(
|
||||
}
|
||||
|
||||
export async function getSong(id: string): Promise<SubsonicSong | null> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return null;
|
||||
try {
|
||||
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
|
||||
return data.song ?? null;
|
||||
@@ -241,7 +233,6 @@ export async function getSong(id: string): Promise<SubsonicSong | null> {
|
||||
}
|
||||
|
||||
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId, id)) return null;
|
||||
try {
|
||||
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
|
||||
return data.song ?? null;
|
||||
@@ -251,9 +242,6 @@ export async function getSongForServer(serverId: string, id: string): Promise<Su
|
||||
}
|
||||
|
||||
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) {
|
||||
throw new Error('Subsonic unavailable');
|
||||
}
|
||||
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
@@ -263,9 +251,6 @@ export async function getAlbumForServer(
|
||||
serverId: string,
|
||||
id: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) {
|
||||
throw new Error('Subsonic unavailable');
|
||||
}
|
||||
const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id });
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
fetchOpenSubsonicExtensionsWithCredentials,
|
||||
hasOpenSubsonicExtension,
|
||||
parseOpenSubsonicExtensions,
|
||||
} from './subsonicOpenSubsonic';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
function okExtensions(extensions: unknown[]) {
|
||||
return {
|
||||
data: {
|
||||
'subsonic-response': {
|
||||
status: 'ok',
|
||||
openSubsonic: true,
|
||||
openSubsonicExtensions: extensions,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseOpenSubsonicExtensions', () => {
|
||||
it('parses extension names and versions', () => {
|
||||
const parsed = parseOpenSubsonicExtensions([
|
||||
{ name: 'sonicSimilarity', versions: [1] },
|
||||
{ name: 'playbackReport', versions: [1, 2] },
|
||||
{ bad: true },
|
||||
]);
|
||||
expect(parsed).toEqual([
|
||||
{ name: 'sonicSimilarity', versions: [1] },
|
||||
{ name: 'playbackReport', versions: [1, 2] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasOpenSubsonicExtension', () => {
|
||||
it('detects sonicSimilarity', () => {
|
||||
const extensions = parseOpenSubsonicExtensions([{ name: 'sonicSimilarity', versions: [1] }]);
|
||||
expect(hasOpenSubsonicExtension(extensions, 'sonicSimilarity')).toBe(true);
|
||||
expect(hasOpenSubsonicExtension(extensions, 'other')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the advertised extension names', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okExtensions([{ name: 'sonicSimilarity', versions: [1] }, { name: 'playbackReport', versions: [1] }]),
|
||||
);
|
||||
await expect(
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toEqual(['sonicSimilarity', 'playbackReport']);
|
||||
});
|
||||
|
||||
it('returns an empty list when none are advertised', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
|
||||
await expect(
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null on request failure', async () => {
|
||||
vi.mocked(axios.get).mockRejectedValue(new Error('boom'));
|
||||
await expect(
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { apiWithCredentials } from './subsonicClient';
|
||||
|
||||
export interface OpenSubsonicExtension {
|
||||
name: string;
|
||||
versions: number[];
|
||||
}
|
||||
|
||||
export function parseOpenSubsonicExtensions(raw: unknown): OpenSubsonicExtension[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object' && !Array.isArray(entry))
|
||||
.map(entry => ({
|
||||
name: typeof entry.name === 'string' ? entry.name : '',
|
||||
versions: Array.isArray(entry.versions)
|
||||
? entry.versions.filter((v): v is number => typeof v === 'number')
|
||||
: [],
|
||||
}))
|
||||
.filter(entry => entry.name.length > 0);
|
||||
}
|
||||
|
||||
export function hasOpenSubsonicExtension(extensions: readonly OpenSubsonicExtension[], name: string): boolean {
|
||||
return extensions.some(ext => ext.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of OpenSubsonic extension names advertised by the server, or
|
||||
* `null` when the request fails. Shared probe for any extension-gated feature.
|
||||
*/
|
||||
export async function fetchOpenSubsonicExtensionsWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<string[] | null> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getOpenSubsonicExtensions.view',
|
||||
{},
|
||||
12000,
|
||||
);
|
||||
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import { api } from './subsonicClient';
|
||||
import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
|
||||
@@ -20,22 +19,6 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
|
||||
return { playlist, songs: entry ?? [] };
|
||||
}
|
||||
|
||||
export async function getPlaylistForServer(
|
||||
serverId: string,
|
||||
id: string,
|
||||
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) {
|
||||
throw new Error('Subsonic unavailable');
|
||||
}
|
||||
const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>(
|
||||
serverId,
|
||||
'getPlaylist.view',
|
||||
{ id },
|
||||
);
|
||||
const { entry, ...playlist } = data.playlist;
|
||||
return { playlist, songs: entry ?? [] };
|
||||
}
|
||||
|
||||
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
|
||||
const params: Record<string, unknown> = { name };
|
||||
if (songIds && songIds.length > 0) {
|
||||
@@ -57,9 +40,6 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
|
||||
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
|
||||
});
|
||||
}
|
||||
void import('../utils/offline/pinnedOfflineSync')
|
||||
.then(m => m.schedulePinnedPlaylistSync(id))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export async function updatePlaylistMeta(
|
||||
|
||||
@@ -2,9 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./subsonicArtists', () => ({ getArtist: vi.fn() }));
|
||||
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForActiveServer: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { getArtist } from './subsonicArtists';
|
||||
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getArtist } from './subsonicArtists';
|
||||
import { getAlbum } from './subsonicLibrary';
|
||||
import { shouldAttemptSubsonicForActiveServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
||||
@@ -56,7 +55,6 @@ export async function prefetchArtistUserRatings(
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
@@ -95,7 +93,6 @@ export async function prefetchAlbumUserRatings(
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
|
||||
@@ -11,9 +11,6 @@ vi.mock('./subsonicClient', () => ({
|
||||
api: vi.fn(),
|
||||
apiForServer: apiForServerMock,
|
||||
}));
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForServer: () => true,
|
||||
}));
|
||||
|
||||
describe('subsonicScrobble', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import type { SubsonicNowPlaying } from './subsonicTypes';
|
||||
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
async function scrobbleOnServer(
|
||||
serverId: string,
|
||||
@@ -9,7 +8,6 @@ async function scrobbleOnServer(
|
||||
submission: boolean,
|
||||
time?: number,
|
||||
): Promise<void> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId, id)) return;
|
||||
const params: Record<string, unknown> = { id, submission };
|
||||
if (time !== undefined) params.time = time;
|
||||
await apiForServer(serverId, 'scrobble.view', params);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||
import { api, libraryFilterParams } from './subsonicClient';
|
||||
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
|
||||
@@ -15,17 +15,6 @@ import type {
|
||||
SubsonicSong,
|
||||
} from './subsonicTypes';
|
||||
|
||||
function parseStarred2Response(data: {
|
||||
starred2?: {
|
||||
artist?: SubsonicArtist[];
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
};
|
||||
}): StarredResults {
|
||||
const r = data.starred2 ?? {};
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
}
|
||||
|
||||
export async function getStarred(): Promise<StarredResults> {
|
||||
const data = await api<{
|
||||
starred2: {
|
||||
@@ -34,50 +23,21 @@ export async function getStarred(): Promise<StarredResults> {
|
||||
song?: SubsonicSong[];
|
||||
}
|
||||
}>('getStarred2.view', { ...libraryFilterParams() });
|
||||
return parseStarred2Response(data);
|
||||
}
|
||||
|
||||
/** Starred entities for an explicit saved server (not necessarily the active one). */
|
||||
export async function getStarredForServer(serverId: string): Promise<StarredResults> {
|
||||
const data = await apiForServer<{
|
||||
starred2: {
|
||||
artist?: SubsonicArtist[];
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
};
|
||||
}>(serverId, 'getStarred2.view', { ...libraryFilterParamsForServer(serverId) });
|
||||
return parseStarred2Response(data);
|
||||
}
|
||||
|
||||
function resolveStarServerId(meta?: StarPatchMeta): string | null {
|
||||
return meta?.serverId ?? useAuthStore.getState().activeServerId;
|
||||
}
|
||||
|
||||
async function starApi(
|
||||
serverId: string | null | undefined,
|
||||
endpoint: string,
|
||||
params: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!sid) throw new Error('No server for star API');
|
||||
if (sid === useAuthStore.getState().activeServerId) {
|
||||
await api(endpoint, params);
|
||||
} else {
|
||||
await apiForServer(sid, endpoint, params);
|
||||
}
|
||||
const r = data.starred2 ?? {};
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
}
|
||||
|
||||
export async function star(
|
||||
id: string,
|
||||
type: 'song' | 'album' | 'artist' = 'album',
|
||||
meta?: StarPatchMeta,
|
||||
_meta?: StarPatchMeta,
|
||||
): Promise<void> {
|
||||
const params: Record<string, string> = {};
|
||||
if (type === 'song') params.id = id;
|
||||
if (type === 'album') params.albumId = id;
|
||||
if (type === 'artist') params.artistId = id;
|
||||
const serverId = resolveStarServerId(meta);
|
||||
await starApi(serverId, 'star.view', params);
|
||||
await api('star.view', params);
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
if (type === 'song') {
|
||||
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
|
||||
} else if (type === 'album' && serverId) {
|
||||
@@ -85,22 +45,19 @@ export async function star(
|
||||
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
||||
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
|
||||
}
|
||||
void import('../utils/offline/favoritesOfflineSync')
|
||||
.then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export async function unstar(
|
||||
id: string,
|
||||
type: 'song' | 'album' | 'artist' = 'album',
|
||||
meta?: StarPatchMeta,
|
||||
_meta?: StarPatchMeta,
|
||||
): Promise<void> {
|
||||
const params: Record<string, string> = {};
|
||||
if (type === 'song') params.id = id;
|
||||
if (type === 'album') params.albumId = id;
|
||||
if (type === 'artist') params.artistId = id;
|
||||
const serverId = resolveStarServerId(meta);
|
||||
await starApi(serverId, 'unstar.view', params);
|
||||
await api('unstar.view', params);
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
if (type === 'song') {
|
||||
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
|
||||
} else if (type === 'album' && serverId) {
|
||||
@@ -108,9 +65,6 @@ export async function unstar(
|
||||
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
||||
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
|
||||
}
|
||||
void import('../utils/offline/favoritesOfflineSync')
|
||||
.then(m => m.onFavoritesOfflineStarChange(id, type, false, serverId ?? undefined))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export async function setRating(id: string, rating: number): Promise<void> {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CoverArtTier } from '../cover/types';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
||||
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
||||
import { restBaseFromUrl, SUBSONIC_CLIENT, SUBSONIC_API_VERSION, secureRandomSalt } from './subsonicClient';
|
||||
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
|
||||
|
||||
function coverArtQueryParams(username: string, password: string, id: string, size: number): URLSearchParams {
|
||||
const salt = secureRandomSalt();
|
||||
@@ -16,7 +16,7 @@ function coverArtQueryParams(username: string, password: string, id: string, siz
|
||||
u: username,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: SUBSONIC_API_VERSION,
|
||||
v: '1.16.1',
|
||||
c: SUBSONIC_CLIENT,
|
||||
f: 'json',
|
||||
});
|
||||
@@ -36,7 +36,7 @@ function streamUrlFromProfile(
|
||||
u: username,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: SUBSONIC_API_VERSION,
|
||||
v: '1.16.1',
|
||||
c: SUBSONIC_CLIENT,
|
||||
f: 'json',
|
||||
});
|
||||
@@ -116,7 +116,7 @@ export function buildDownloadUrl(id: string): string {
|
||||
const p = new URLSearchParams({
|
||||
id,
|
||||
u: server?.username ?? '',
|
||||
t: token, s: salt, v: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json',
|
||||
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
|
||||
});
|
||||
return `${baseUrl}/rest/download.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ export interface SubsonicAlbum {
|
||||
displayArtist?: string;
|
||||
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
|
||||
discTitles?: SubsonicDiscTitle[];
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicDiscTitle {
|
||||
@@ -92,8 +90,6 @@ export interface SubsonicSong {
|
||||
trackPeak?: number;
|
||||
albumPeak?: number;
|
||||
};
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
/** OpenSubsonic: structured composer credit (string for back-compat). */
|
||||
displayComposer?: string;
|
||||
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
|
||||
@@ -148,8 +144,6 @@ export interface SubsonicArtist {
|
||||
starred?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
|
||||
userRating?: number;
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicGenre {
|
||||
@@ -201,20 +195,7 @@ export interface SubsonicDirectory {
|
||||
child: SubsonicDirectoryEntry[];
|
||||
}
|
||||
|
||||
export interface PingFailure {
|
||||
/** Coarse category that drives the user-facing message. */
|
||||
reason: 'auth' | 'version' | 'network' | 'server';
|
||||
/** Subsonic error code, when the server returned a structured error. */
|
||||
code?: number;
|
||||
/** Server's error message, or the network/TLS error detail. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type PingWithCredentialsResult = SubsonicServerIdentity & {
|
||||
ok: boolean;
|
||||
/** Populated when `ok` is false — why the ping was rejected. */
|
||||
failure?: PingFailure;
|
||||
};
|
||||
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
|
||||
|
||||
export interface RandomSongsFilters {
|
||||
size?: number;
|
||||
|
||||
+6
-21
@@ -11,7 +11,6 @@ import PlayerBar from '../components/PlayerBar';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import LiveSearch from '../components/LiveSearch';
|
||||
import DevNetworkModeToggle from '../components/DevNetworkModeToggle';
|
||||
import NowPlayingDropdown from '../components/NowPlayingDropdown';
|
||||
import QueuePanel from '../components/QueuePanel';
|
||||
import AppRoutes from './AppRoutes';
|
||||
@@ -20,7 +19,6 @@ import ContextMenu from '../components/ContextMenu';
|
||||
import SongInfoModal from '../components/SongInfoModal';
|
||||
import DownloadFolderModal from '../components/DownloadFolderModal';
|
||||
import GlobalConfirmModal from '../components/GlobalConfirmModal';
|
||||
import ThemeMigrationNotice from '../components/ThemeMigrationNotice';
|
||||
import OrbitAccountPicker from '../components/OrbitAccountPicker';
|
||||
import OrbitHelpModal from '../components/OrbitHelpModal';
|
||||
import TooltipPortal from '../components/TooltipPortal';
|
||||
@@ -40,8 +38,7 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
|
||||
import { useOrbitGuest } from '../hooks/useOrbitGuest';
|
||||
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
|
||||
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
|
||||
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
|
||||
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
|
||||
@@ -54,11 +51,11 @@ import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'
|
||||
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
|
||||
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
|
||||
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
|
||||
import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilterSuspend';
|
||||
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import '../store/previewPlayerVolumeSync';
|
||||
import '../store/queueResolverBridge';
|
||||
@@ -108,10 +105,8 @@ export function AppShell() {
|
||||
useLiveSearchRouteScope();
|
||||
useNowPlayingPrewarm();
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const hasOfflineContent = offlineCtx.hasBrowsingContent;
|
||||
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
@@ -139,8 +134,7 @@ export function AppShell() {
|
||||
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
|
||||
}, [location.pathname, location.state]);
|
||||
|
||||
useOfflineAutoNav(connStatus, offlineNav, location, navigate);
|
||||
useOfflineLibraryFilterSuspend();
|
||||
useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate);
|
||||
|
||||
useEffect(() => {
|
||||
initializeFromServerQueue();
|
||||
@@ -179,13 +173,6 @@ export function AppShell() {
|
||||
return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar);
|
||||
}, [isSidebarCollapsed, setSidebarCollapsed]);
|
||||
|
||||
// Expose sidebar state on the theme root so themes can react with a
|
||||
// `[data-theme='x'][data-sidebar-collapsed='true']` compound (contract
|
||||
// `stateSelectors`). Other state attributes are set in App.tsx.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-sidebar-collapsed', isSidebarCollapsed ? 'true' : 'false');
|
||||
}, [isSidebarCollapsed]);
|
||||
|
||||
// Workaround for WebKitGTK 2.50.x text-input repaint hang on
|
||||
// Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in,
|
||||
// nudge WebKit awake on every input/textarea focus via a sync
|
||||
@@ -254,7 +241,6 @@ export function AppShell() {
|
||||
<div className="main-content-zoom">
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
{import.meta.env.DEV && <DevNetworkModeToggle />}
|
||||
<div className="spacer" />
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<LastfmIndicator />
|
||||
@@ -273,7 +259,7 @@ export function AppShell() {
|
||||
</header>
|
||||
<OrbitSessionBar />
|
||||
{connStatus === 'disconnected' && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
|
||||
)}
|
||||
<div className="content-body app-shell-route-host">
|
||||
<OverlayScrollArea
|
||||
@@ -319,7 +305,6 @@ export function AppShell() {
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<GlobalConfirmModal />
|
||||
<ThemeMigrationNotice />
|
||||
<OrbitAccountPicker />
|
||||
<OrbitHelpModal />
|
||||
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
|
||||
|
||||
@@ -13,12 +13,6 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
|
||||
import { initHotCachePrefetch } from '../hotCachePrefetch';
|
||||
import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation';
|
||||
import { initFavoritesOfflineSync } from '../utils/offline/favoritesOfflineSync';
|
||||
import { initPinnedOfflineSync } from '../utils/offline/pinnedOfflineSync';
|
||||
import { initResumeIncompleteOfflinePins, scheduleResumeIncompleteOfflinePins } from '../utils/offline/resumeIncompleteOfflinePins';
|
||||
import { runLegacyOfflineFileMigration } from '../utils/migrations/legacyOfflineFileMigration';
|
||||
import { reconcileLibraryTierForServer } from '../utils/offline/libraryTierReconcile';
|
||||
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
|
||||
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
|
||||
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
|
||||
@@ -104,28 +98,6 @@ export default function MainApp() {
|
||||
return initHotCachePrefetch();
|
||||
}, [migrationReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!migrationReady) return undefined;
|
||||
void (async () => {
|
||||
await runLegacyOfflineFileMigration();
|
||||
const servers = useAuthStore.getState().servers;
|
||||
for (const server of servers) {
|
||||
await reconcileLibraryTierForServer(server.id);
|
||||
}
|
||||
scheduleResumeIncompleteOfflinePins();
|
||||
})();
|
||||
const stopInvalidation = initLocalPlaybackInvalidation();
|
||||
const stopFavoritesSync = initFavoritesOfflineSync();
|
||||
const stopPinnedOfflineSync = initPinnedOfflineSync();
|
||||
const stopOfflineResume = initResumeIncompleteOfflinePins();
|
||||
return () => {
|
||||
stopInvalidation();
|
||||
stopFavoritesSync();
|
||||
stopPinnedOfflineSync();
|
||||
stopOfflineResume();
|
||||
};
|
||||
}, [migrationReady, serverIdsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!migrationReady) return;
|
||||
useGlobalShortcutsStore.getState().registerAll();
|
||||
|
||||
@@ -24,14 +24,10 @@ vi.mock('./windowKind', () => ({
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getWindowKind } from './windowKind';
|
||||
import {
|
||||
applyThemeAtStartup,
|
||||
installCrossWindowThemeSync,
|
||||
pushLoggingModeToBackend,
|
||||
pushUserAgentToBackend,
|
||||
runPreReactBootstrap,
|
||||
} from './bootstrap';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useInstalledThemesStore } from '../store/installedThemesStore';
|
||||
|
||||
const ORIGINAL_USER_AGENT = window.navigator.userAgent;
|
||||
|
||||
@@ -52,8 +48,6 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
setUserAgent(ORIGINAL_USER_AGENT);
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.head.querySelectorAll('style[data-installed-theme]').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
describe('pushUserAgentToBackend', () => {
|
||||
@@ -149,56 +143,3 @@ describe('runPreReactBootstrap', () => {
|
||||
// the bootstrap doesn't second-guess that decision.
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyThemeAtStartup', () => {
|
||||
const setPersistedTheme = (state: Record<string, unknown>) =>
|
||||
localStorage.setItem('psysonic_theme', JSON.stringify({ state, version: 1 }));
|
||||
|
||||
it('does nothing when there is no persisted theme', () => {
|
||||
applyThemeAtStartup();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets data-theme to the active theme (scheduler off)', () => {
|
||||
setPersistedTheme({ theme: 'kanagawa-wave', enableThemeScheduler: false });
|
||||
applyThemeAtStartup();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave');
|
||||
});
|
||||
|
||||
it('injects installed community themes up front', () => {
|
||||
setPersistedTheme({ theme: 'dracula', enableThemeScheduler: false });
|
||||
localStorage.setItem('psysonic_installed_themes', JSON.stringify({
|
||||
state: { themes: [{ id: 'dracula', name: 'Dracula', author: 'a', version: '1.0.0', description: '', mode: 'dark', css: "[data-theme='dracula']{--accent:#bd93f9;}", installedAt: 0 }] },
|
||||
version: 1,
|
||||
}));
|
||||
applyThemeAtStartup();
|
||||
expect(document.head.querySelector('style[data-installed-theme="dracula"]')).not.toBeNull();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dracula');
|
||||
});
|
||||
|
||||
it('does not throw on malformed storage', () => {
|
||||
localStorage.setItem('psysonic_theme', '{not json');
|
||||
expect(() => applyThemeAtStartup()).not.toThrow();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('installCrossWindowThemeSync', () => {
|
||||
it('rehydrates the matching store on a cross-window storage event', () => {
|
||||
const themeRehydrate = vi.spyOn(useThemeStore.persist, 'rehydrate').mockResolvedValue(undefined);
|
||||
const installedRehydrate = vi.spyOn(useInstalledThemesStore.persist, 'rehydrate').mockResolvedValue(undefined);
|
||||
installCrossWindowThemeSync();
|
||||
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_theme' }));
|
||||
expect(themeRehydrate).toHaveBeenCalled();
|
||||
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_installed_themes' }));
|
||||
expect(installedRehydrate).toHaveBeenCalled();
|
||||
|
||||
themeRehydrate.mockClear();
|
||||
installedRehydrate.mockClear();
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated-key' }));
|
||||
expect(themeRehydrate).not.toHaveBeenCalled();
|
||||
expect(installedRehydrate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
|
||||
import { configureStartupSplash } from './startupSplash';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getWindowKind } from './windowKind';
|
||||
import { migrateThemeSelection } from '../utils/themes/themeMigration';
|
||||
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
|
||||
import { syncInjectedThemes } from '../utils/themes/themeInjection';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
|
||||
|
||||
/** Sync backend HTTP User-Agent from the main webview once at startup. */
|
||||
export function pushUserAgentToBackend(): void {
|
||||
@@ -40,63 +35,6 @@ export function pushLoggingModeToBackend(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledThemes(): InstalledTheme[] {
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic_installed_themes');
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as { state?: { themes?: InstalledTheme[] } };
|
||||
return Array.isArray(parsed.state?.themes) ? (parsed.state!.themes as InstalledTheme[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the active theme synchronously, before React mounts, so the first paint
|
||||
* is already correct. Zustand rehydrate + the `data-theme` effect run after the
|
||||
* first paint, so without this a non-Mocha active theme (every light theme and
|
||||
* every installed community theme) flashes the `:root` Mocha default for a
|
||||
* frame. We set `data-theme` to the effective (scheduler-resolved) theme and
|
||||
* inject installed community themes' CSS up front. Runs after the migration so
|
||||
* the persisted ids are already resolved.
|
||||
*/
|
||||
export function applyThemeAtStartup(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic_theme');
|
||||
if (!raw) return; // fresh profile — the :root default is correct
|
||||
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
|
||||
const s = parsed.state;
|
||||
if (!s) return;
|
||||
syncInjectedThemes(readInstalledThemes());
|
||||
const effective = getScheduledTheme({
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
theme: String(s.theme ?? 'mocha'),
|
||||
themeDay: String(s.themeDay ?? 'latte'),
|
||||
themeNight: String(s.themeNight ?? 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart ?? '07:00'),
|
||||
timeNightStart: String(s.timeNightStart ?? '19:00'),
|
||||
});
|
||||
if (effective) document.documentElement.setAttribute('data-theme', effective);
|
||||
} catch {
|
||||
// Non-fatal — App's effects apply the theme after mount.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep theme state in sync across webviews (main ↔ mini player). Zustand
|
||||
* persist does not sync across windows on its own; the `storage` event fires in
|
||||
* *other* windows when localStorage changes, so rehydrate the relevant store
|
||||
* there. Installing/applying/uninstalling in one window then live-updates the
|
||||
* other (App's effects re-run and re-apply `data-theme` / injected styles).
|
||||
*/
|
||||
export function installCrossWindowThemeSync(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'psysonic_theme') void useThemeStore.persist?.rehydrate?.();
|
||||
else if (e.key === 'psysonic_installed_themes') void useInstalledThemesStore.persist?.rehydrate?.();
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark the document in Vite dev so CSS can show dev-only chrome. */
|
||||
export function markDevBuildDocument(): void {
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -108,13 +46,6 @@ export function markDevBuildDocument(): void {
|
||||
export function runPreReactBootstrap(): void {
|
||||
// Pre-warm the window-kind cache so subsequent reads are sync + safe.
|
||||
getWindowKind();
|
||||
// Reset any persisted theme that is no longer bundled and not installed, so
|
||||
// the store hydrates onto a paintable theme (no unstyled-:root flash).
|
||||
migrateThemeSelection();
|
||||
// Paint the correct theme on the very first frame (no Mocha flash).
|
||||
applyThemeAtStartup();
|
||||
configureStartupSplash();
|
||||
installCrossWindowThemeSync();
|
||||
markDevBuildDocument();
|
||||
pushUserAgentToBackend();
|
||||
pushLoggingModeToBackend();
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
STARTUP_SPLASH_ID,
|
||||
configureStartupSplash,
|
||||
dismissStartupSplash,
|
||||
} from './startupSplash';
|
||||
|
||||
vi.mock('./windowKind', () => ({
|
||||
getWindowKind: vi.fn(() => 'main'),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/themes/startupThemeAppearance', () => ({
|
||||
applyStartupSplashThemeFromStorage: vi.fn(() => 'mocha'),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/webviewWindow', () => ({
|
||||
getCurrentWebviewWindow: vi.fn(() => ({ show: vi.fn(() => Promise.resolve()) })),
|
||||
}));
|
||||
|
||||
import { getWindowKind } from './windowKind';
|
||||
import { applyStartupSplashThemeFromStorage } from '../utils/themes/startupThemeAppearance';
|
||||
|
||||
describe('startupSplash', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `<div id="${STARTUP_SPLASH_ID}"></div><div id="root"></div>`;
|
||||
vi.mocked(getWindowKind).mockReturnValue('main');
|
||||
vi.mocked(applyStartupSplashThemeFromStorage).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('removes splash on mini player webview', () => {
|
||||
vi.mocked(getWindowKind).mockReturnValue('mini');
|
||||
configureStartupSplash();
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it('re-applies theme from storage on main window', () => {
|
||||
configureStartupSplash();
|
||||
expect(applyStartupSplashThemeFromStorage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fades out and removes splash', () => {
|
||||
vi.useFakeTimers();
|
||||
dismissStartupSplash();
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)?.classList.contains('app-startup-splash--hide')).toBe(true);
|
||||
vi.runAllTimers();
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { applyStartupSplashThemeFromStorage } from '../utils/themes/startupThemeAppearance';
|
||||
import { getWindowKind } from './windowKind';
|
||||
|
||||
export const STARTUP_SPLASH_ID = 'app-startup-splash';
|
||||
|
||||
/** Ensure the native shell is visible once the webview bundle is alive. */
|
||||
export function revealStartupWindow(): void {
|
||||
if (getWindowKind() === 'mini') return;
|
||||
void getCurrentWebviewWindow().show().catch(() => {});
|
||||
}
|
||||
|
||||
/** Re-apply splash colors after bootstrap theme migration/injection. */
|
||||
export function configureStartupSplash(): void {
|
||||
const splash = document.getElementById(STARTUP_SPLASH_ID);
|
||||
if (!splash) return;
|
||||
|
||||
if (getWindowKind() === 'mini') {
|
||||
splash.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
applyStartupSplashThemeFromStorage();
|
||||
revealStartupWindow();
|
||||
}
|
||||
|
||||
/** Fade out the splash after the first React commit. */
|
||||
export function dismissStartupSplash(): void {
|
||||
const splash = document.getElementById(STARTUP_SPLASH_ID);
|
||||
if (!splash || splash.classList.contains('app-startup-splash--hide')) return;
|
||||
|
||||
splash.classList.add('app-startup-splash--hide');
|
||||
const remove = () => splash.remove();
|
||||
splash.addEventListener('transitionend', remove, { once: true });
|
||||
window.setTimeout(remove, 500);
|
||||
}
|
||||
|
||||
/** Schedule dismiss on the frame after the first paint. */
|
||||
export function scheduleStartupSplashDismiss(): void {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(dismissStartupSplash);
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
|
||||
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||
@@ -16,14 +17,12 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { resolveCoverDisplayTier } from '../cover/tiers';
|
||||
import { acquireUrl } from '../utils/imageCache/urlPool';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
||||
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: SubsonicAlbum;
|
||||
@@ -64,28 +63,21 @@ function AlbumCard({
|
||||
}: AlbumCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isHolding, pressBind } = useLongPressAction({
|
||||
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
|
||||
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
|
||||
onShortPress: () => playAlbum(album.id),
|
||||
onLongPress: () => playAlbumShuffled(album.id),
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const offlineServerId = album.serverId ?? activeServerId;
|
||||
const localEntries = useLocalPlaybackStore(s => s.entries);
|
||||
const isOffline = isOfflinePinComplete(album.id, offlineServerId);
|
||||
const albumLinkQuery = useMemo(
|
||||
() => appendServerQuery(linkQuery, album.serverId),
|
||||
[linkQuery, album.serverId],
|
||||
);
|
||||
void localEntries;
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isOffline = useOfflineStore(s => {
|
||||
const meta = s.albums[`${serverId}:${album.id}`];
|
||||
if (!meta || meta.trackIds.length === 0) return false;
|
||||
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||||
});
|
||||
const psyDrag = useDragDrop();
|
||||
const coverServerScope = useMemo(
|
||||
() => coverServerScopeForServerId(album.serverId),
|
||||
[album.serverId],
|
||||
);
|
||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve });
|
||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve });
|
||||
const dragCoverKey = useMemo(() => {
|
||||
if (!coverRef) return '';
|
||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
||||
@@ -96,7 +88,7 @@ function AlbumCard({
|
||||
|
||||
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
||||
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
||||
navigateToAlbum(album.id, { search: albumLinkQuery });
|
||||
navigateToAlbum(album.id, { search: linkQuery });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -190,13 +182,10 @@ function AlbumCard({
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const tracks = await fetchAlbumTracks(
|
||||
album.id,
|
||||
offlineServerId || undefined,
|
||||
);
|
||||
if (tracks.length > 0) enqueue(tracks);
|
||||
const data = await getAlbum(album.id);
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
} catch {
|
||||
// Unavailable offline or network failure — silent on hover action
|
||||
// Network failure — silent (toast would be too noisy for a hover action)
|
||||
}
|
||||
}}
|
||||
aria-label={t('contextMenu.enqueueAlbum')}
|
||||
|
||||
+94
-129
@@ -19,7 +19,6 @@ import { formatMb } from '../utils/format/formatBytes';
|
||||
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
/** True when the album artist label means "no single artist" — `getArtistInfo`
|
||||
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
|
||||
@@ -75,7 +74,7 @@ interface AlbumHeaderProps {
|
||||
resolvedCoverUrl: string | null;
|
||||
isStarred: boolean;
|
||||
downloadProgress: number | null;
|
||||
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
|
||||
offlineStatus: 'none' | 'downloading' | 'cached';
|
||||
offlineProgress: { done: number; total: number } | null;
|
||||
bio: string | null;
|
||||
bioOpen: boolean;
|
||||
@@ -92,8 +91,6 @@ interface AlbumHeaderProps {
|
||||
onEntityRatingChange: (rating: number) => void;
|
||||
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
|
||||
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
|
||||
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
@@ -120,9 +117,7 @@ export default function AlbumHeader({
|
||||
entityRatingValue,
|
||||
onEntityRatingChange,
|
||||
entityRatingSupport,
|
||||
actionPolicy,
|
||||
}: AlbumHeaderProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const goBack = useAlbumDetailBack();
|
||||
@@ -227,7 +222,7 @@ export default function AlbumHeader({
|
||||
<StarRating
|
||||
value={entityRatingValue}
|
||||
onChange={onEntityRatingChange}
|
||||
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
|
||||
disabled={entityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
@@ -255,16 +250,14 @@ export default function AlbumHeader({
|
||||
|
||||
{/* Row 2 — Secondary actions */}
|
||||
<div className="album-actions-row album-actions-row--secondary">
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
@@ -276,7 +269,7 @@ export default function AlbumHeader({
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
{showBioButton && policy.canShowBio && (
|
||||
{showBioButton && (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onBio}
|
||||
@@ -287,57 +280,44 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{policy.canDownload && (
|
||||
downloadProgress !== null ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Download size={14} />
|
||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onDownload}
|
||||
aria-label={t('albumDetail.download')}
|
||||
data-tooltip={t('albumDetail.download')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
)
|
||||
{downloadProgress !== null ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Download size={14} />
|
||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onDownload}
|
||||
aria-label={t('albumDetail.download')}
|
||||
data-tooltip={t('albumDetail.download')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{policy.canPinOffline && (
|
||||
offlineStatus === 'downloading' ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.offlineQueued')}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
)
|
||||
{offlineStatus === 'downloading' ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,15 +348,13 @@ export default function AlbumHeader({
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
@@ -388,7 +366,7 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBioButton && policy.canShowBio && (
|
||||
{showBioButton && (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-bio-btn"
|
||||
@@ -399,60 +377,47 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{policy.canDownload && (
|
||||
downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-download-btn"
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
)
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-download-btn"
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{policy.canPinOffline && (
|
||||
offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineQueued')}
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)
|
||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -251,7 +251,7 @@ export default function AlbumRow({
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{uniqueAlbums.map((a, idx) => (
|
||||
<AlbumCard
|
||||
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||
key={a.id}
|
||||
album={a}
|
||||
showRating={showRating}
|
||||
linkQuery={albumLinkQuery}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTracklistColumns } from '../utils/useTracklistColumns';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -15,7 +15,6 @@ import { TrackRow } from './albumTrackList/TrackRow';
|
||||
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
|
||||
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
|
||||
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
|
||||
|
||||
@@ -39,7 +38,6 @@ interface AlbumTrackListProps {
|
||||
sortKey?: SortKey;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
onSort?: (key: SortKey) => void;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
||||
@@ -62,9 +60,7 @@ export default function AlbumTrackList({
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
actionPolicy,
|
||||
}: AlbumTrackListProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
@@ -102,10 +98,6 @@ export default function AlbumTrackList({
|
||||
);
|
||||
|
||||
const currentTrackId = currentTrack?.id ?? null;
|
||||
const displayCols = useMemo(
|
||||
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
|
||||
[policy.canFavorite, visibleCols],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -147,7 +139,7 @@ export default function AlbumTrackList({
|
||||
>
|
||||
|
||||
<TracklistHeaderRow
|
||||
visibleCols={displayCols}
|
||||
visibleCols={visibleCols}
|
||||
gridStyle={gridStyle}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
@@ -178,7 +170,7 @@ export default function AlbumTrackList({
|
||||
key={song.id}
|
||||
song={song}
|
||||
globalIdx={globalIdx}
|
||||
visibleCols={displayCols}
|
||||
visibleCols={visibleCols}
|
||||
gridStyle={gridStyle}
|
||||
currentTrackId={currentTrackId}
|
||||
isPlaying={isPlaying}
|
||||
@@ -194,7 +186,6 @@ export default function AlbumTrackList({
|
||||
onToggleSelect={onToggleSelect}
|
||||
onDragStart={onDragStart}
|
||||
setContextMenuSongId={setContextMenuSongId}
|
||||
actionPolicy={policy}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -20,17 +18,12 @@ interface Props {
|
||||
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const coverServerScope = useMemo(
|
||||
() => coverServerScopeForServerId(artist.serverId),
|
||||
[artist.serverId],
|
||||
);
|
||||
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, coverServerScope, { libraryResolve });
|
||||
const artistLinkQuery = appendServerQuery(linkQuery, artist.serverId);
|
||||
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
|
||||
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
|
||||
>
|
||||
<div className="artist-card-avatar">
|
||||
{coverRef ? (
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ArtistRow({
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{artists.map(a => (
|
||||
<ArtistCardLocal
|
||||
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||
key={a.id}
|
||||
artist={a}
|
||||
linkQuery={artistLinkQuery}
|
||||
libraryResolve={libraryResolve}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
|
||||
interface BackToTopButtonProps {
|
||||
/** Id of the scroll viewport to watch/scroll. Defaults to the main route scroller. */
|
||||
viewportId?: string;
|
||||
/** Show the button once the viewport is scrolled past this many pixels. */
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A floating "back to top" affordance for long pages. Watches a scroll viewport
|
||||
* (the overlay-scroll element wrapping the routes by default) and, once it is
|
||||
* scrolled past `threshold`, shows a button that smooth-scrolls it back to the
|
||||
* top — reusing the same `getElementById(...).scrollTo` pattern AppShell uses
|
||||
* for its route-change scroll reset.
|
||||
*
|
||||
* The button is portalled into `.app-shell-route-host` and positioned
|
||||
* `absolute` against it: the scroll viewport itself sets `contain: paint`, which
|
||||
* would otherwise make a `position: fixed` child resolve against the *scrolling*
|
||||
* box (so it would drift with the content). The route host is a non-contained,
|
||||
* `position: relative` ancestor that spans exactly the content area (between the
|
||||
* sidebar and queue, above the player bar), so the button stays pinned to the
|
||||
* visible viewport corner regardless of scroll.
|
||||
*/
|
||||
export default function BackToTopButton({
|
||||
viewportId = APP_MAIN_SCROLL_VIEWPORT_ID,
|
||||
threshold = 400,
|
||||
}: BackToTopButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [host, setHost] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHost(document.querySelector<HTMLElement>('.app-shell-route-host'));
|
||||
const el = document.getElementById(viewportId);
|
||||
if (!el) return;
|
||||
const onScroll = () => setVisible(el.scrollTop > threshold);
|
||||
onScroll(); // sync immediately (e.g. switching back to an already-scrolled tab)
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [viewportId, threshold]);
|
||||
|
||||
if (!visible || !host) return null;
|
||||
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className="back-to-top-btn"
|
||||
onClick={() =>
|
||||
document.getElementById(viewportId)?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
aria-label={t('common.backToTop')}
|
||||
data-tooltip={t('common.backToTop')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<ArrowUp size={18} aria-hidden="true" />
|
||||
</button>,
|
||||
host,
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||
import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
@@ -519,13 +518,7 @@ export default function BecauseYouLikeRail({
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// Gate on poolKey (the stable top-anchor identity), not the `pool` array ref.
|
||||
// `pool` is rebuilt whenever Home's mostPlayed changes, so loading more Most
|
||||
// Played albums (which feeds this pool) would otherwise re-run this effect and
|
||||
// swap the cards — a height blip above the row that scroll anchoring turns into
|
||||
// an upward viewport jump. The sibling reserve effect already keys on poolKey.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poolKey, activeServerId, musicLibraryFilterVersion, disableArtwork]);
|
||||
}, [pool, activeServerId, musicLibraryFilterVersion, disableArtwork, poolKey]);
|
||||
|
||||
useLibraryCoverPrefetch(
|
||||
disableArtwork || recs.length === 0 ? [] : [{ albums: recs, priority: 'high' }],
|
||||
@@ -603,10 +596,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
||||
const handleEnqueue = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const data = await resolveAlbum(serverId, album.id);
|
||||
if (!data) return;
|
||||
const data = await getAlbum(album.id);
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
} catch {
|
||||
/* silent — toast would be too noisy for a hover action */
|
||||
|
||||
@@ -247,7 +247,7 @@ export default function CachedImage({
|
||||
};
|
||||
|
||||
const fallbackStyle: React.CSSProperties = isFallback
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' }
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
|
||||
: {};
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,23 +43,6 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useOfflineBrowseContext', () => ({
|
||||
useOfflineBrowseContext: () => ({
|
||||
active: false,
|
||||
serverId: 'srv-1',
|
||||
capabilities: {
|
||||
localLibrary: false,
|
||||
favorites: false,
|
||||
playlists: false,
|
||||
manualPins: false,
|
||||
playerStats: false,
|
||||
},
|
||||
hasBrowseCapability: false,
|
||||
hasBrowsingContent: false,
|
||||
connStatus: 'connected' as const,
|
||||
}),
|
||||
}));
|
||||
|
||||
import ContextMenu from './ContextMenu';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
|
||||
@@ -17,29 +17,8 @@ import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
|
||||
import { useContextMenuRating } from '../hooks/useContextMenuRating';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import {
|
||||
offlineActionPolicy,
|
||||
type OfflineSurface,
|
||||
} from '../utils/offline/offlineActionPolicy';
|
||||
import ContextMenuItems from './contextMenu/ContextMenuItems';
|
||||
|
||||
function contextMenuSurfaceForType(type: string | null): OfflineSurface {
|
||||
switch (type) {
|
||||
case 'album':
|
||||
case 'multi-album':
|
||||
return 'contextMenuAlbum';
|
||||
case 'artist':
|
||||
case 'multi-artist':
|
||||
return 'contextMenuArtist';
|
||||
case 'playlist':
|
||||
case 'multi-playlist':
|
||||
return 'contextMenuPlaylist';
|
||||
default:
|
||||
return 'contextMenuSong';
|
||||
}
|
||||
}
|
||||
|
||||
export { AddToPlaylistSubmenu };
|
||||
|
||||
|
||||
@@ -201,12 +180,6 @@ export default function ContextMenu() {
|
||||
|
||||
const downloadAlbum = downloadAlbumAction;
|
||||
|
||||
const { active: offlineBrowseActive } = useOfflineBrowseContext();
|
||||
const offlinePolicy = offlineActionPolicy(
|
||||
contextMenuSurfaceForType(type),
|
||||
offlineBrowseActive,
|
||||
);
|
||||
|
||||
if (!contextMenu.isOpen || !contextMenu.item) return null;
|
||||
|
||||
return (
|
||||
@@ -260,7 +233,6 @@ export default function ContextMenu() {
|
||||
isStarred={isStarred}
|
||||
pinToPlaybackServer={pinToPlaybackServer}
|
||||
navigateLibrary={navigateLibrary}
|
||||
offlinePolicy={offlinePolicy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Cloud, CloudOff } from 'lucide-react';
|
||||
import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
|
||||
|
||||
/** DEV-only: simulate full offline (disconnect UI, block Subsonic, local playback only). */
|
||||
export default function DevNetworkModeToggle() {
|
||||
const forceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||
const toggle = useDevOfflineBrowseStore(s => s.toggleForceOffline);
|
||||
|
||||
if (!import.meta.env.DEV) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`dev-network-mode-toggle${forceOffline ? ' dev-network-mode-toggle--offline' : ''}`}
|
||||
onClick={toggle}
|
||||
title={forceOffline ? 'DEV: forced offline (click for online)' : 'DEV: online (click to simulate offline)'}
|
||||
aria-pressed={forceOffline}
|
||||
>
|
||||
{forceOffline ? <CloudOff size={16} aria-hidden /> : <Cloud size={16} aria-hidden />}
|
||||
<span>{forceOffline ? 'Offline' : 'Online'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ function LiveOverlayPinnedMetric({
|
||||
);
|
||||
}
|
||||
|
||||
/** FPS + pipeline + pinned live metrics overlay (PsyLab). */
|
||||
/** FPS + pipeline + pinned live metrics overlay (Performance Probe). */
|
||||
export default function FpsOverlay() {
|
||||
const overlayMode = usePerfOverlayMode();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
+15
-42
@@ -1,5 +1,4 @@
|
||||
import { getRandomAlbums } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
@@ -34,25 +33,14 @@ function HeroBg({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const counter = useRef(url ? 1 : 0);
|
||||
const latestUrlRef = useRef(url);
|
||||
latestUrlRef.current = url;
|
||||
const counter = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setLayers([]);
|
||||
return;
|
||||
}
|
||||
if (!url) return;
|
||||
const id = counter.current++;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
const t1 = setTimeout(() => {
|
||||
if (latestUrlRef.current !== url) return;
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
}, 20);
|
||||
const t2 = setTimeout(() => {
|
||||
if (latestUrlRef.current !== url) return;
|
||||
setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 900);
|
||||
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 20);
|
||||
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 900);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]);
|
||||
|
||||
@@ -271,13 +259,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (!album || albumFormats[album.id] !== undefined) return;
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
resolveAlbum(serverId, album.id).then(data => {
|
||||
if (!data) {
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
|
||||
return;
|
||||
}
|
||||
getAlbum(album.id).then(data => {
|
||||
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
|
||||
}).catch(() => {
|
||||
@@ -291,20 +273,17 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
ensurePriority: 'high',
|
||||
});
|
||||
|
||||
// Per-album fallback so a cache miss on the current slide does not flash empty,
|
||||
// but never reuse another album's art (that caused bg/foreground desync on fast nav).
|
||||
const stableBgByAlbum = useRef<Record<string, string>>({});
|
||||
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
|
||||
// transition (which would cause the background to flash empty before fading in).
|
||||
const stableBgUrl = useRef('');
|
||||
const albumId = album?.id;
|
||||
useEffect(() => {
|
||||
if (bgHandle.src && albumId) {
|
||||
stableBgByAlbum.current[albumId] = bgHandle.src;
|
||||
}
|
||||
}, [bgHandle.src, albumId]);
|
||||
const heroBgUrl = bgHandle.src || (albumId ? stableBgByAlbum.current[albumId] ?? '' : '');
|
||||
const { isHolding, pressBind } = useLongPressAction({
|
||||
onShortPress: () => { if (albumId) playAlbum(albumId); },
|
||||
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
|
||||
});
|
||||
useEffect(() => {
|
||||
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
|
||||
}, [bgHandle.src, albumId]);
|
||||
|
||||
if (!album) return <div className="hero-placeholder" />;
|
||||
|
||||
@@ -317,7 +296,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={() => navigateToAlbum(album.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={heroBgUrl} />}
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
@@ -360,10 +339,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
const albumData = await getAlbum(album.id);
|
||||
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
|
||||
} catch (_) {}
|
||||
}}
|
||||
@@ -393,10 +369,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
const albumData = await getAlbum(album.id);
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) {}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
export default function LosslessFilterButton({ active, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = active ? t('albums.losslessTooltipOn') : t('albums.losslessTooltipOff');
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Settings, HardDriveDownload } from 'lucide-react';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
|
||||
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
||||
|
||||
@@ -16,10 +15,8 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
const { t } = useTranslation();
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const isServerOffline = offlineCtx.active;
|
||||
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const luckyMixBase = useLuckyMixAvailable();
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
|
||||
@@ -32,15 +29,6 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
cfg.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
|
||||
@@ -238,7 +238,7 @@ export default function MobilePlayerView() {
|
||||
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
// Scrubber touch/mouse drag
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function NowPlayingDropdown() {
|
||||
{visible.length > 0 && (
|
||||
<span style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--text-on-accent)',
|
||||
color: 'var(--ctp-crust)',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
padding: '2px 6px',
|
||||
@@ -161,7 +161,7 @@ export default function NowPlayingDropdown() {
|
||||
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
|
||||
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--card-placeholder-bg)' }}>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
||||
{stream.albumId && stream.coverArt ? (
|
||||
<TrackCoverArtImage
|
||||
song={{
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { HardDriveDownload, Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatBytes } from '../utils/format/formatBytes';
|
||||
|
||||
const OPEN_DELAY_MS = 450;
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
totalBytes: number | null;
|
||||
libraryBytes: number | null;
|
||||
favoritesBytes: number | null;
|
||||
}
|
||||
|
||||
export function OfflineLibraryDiskStat({
|
||||
label,
|
||||
totalBytes,
|
||||
libraryBytes,
|
||||
favoritesBytes,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
|
||||
const openTimerRef = useRef<number | null>(null);
|
||||
|
||||
const breakdownReady = libraryBytes !== null && favoritesBytes !== null;
|
||||
|
||||
const clearOpenTimer = () => {
|
||||
if (openTimerRef.current !== null) {
|
||||
clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleOpen = () => {
|
||||
if (!breakdownReady) return;
|
||||
clearOpenTimer();
|
||||
openTimerRef.current = window.setTimeout(() => setOpen(true), OPEN_DELAY_MS);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
clearOpenTimer();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const breakdownAriaLabel = breakdownReady
|
||||
? [
|
||||
t('connection.offlineLibraryDiskTierLibrary', { size: formatBytes(libraryBytes) }),
|
||||
t('connection.offlineLibraryDiskTierFavorites', { size: formatBytes(favoritesBytes) }),
|
||||
].join('. ')
|
||||
: undefined;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !anchorRef.current || !popoverRef.current) {
|
||||
setStyle({ opacity: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = anchorRef.current.getBoundingClientRect();
|
||||
const box = popoverRef.current.getBoundingClientRect();
|
||||
const GAP = 8;
|
||||
const MARGIN = 8;
|
||||
|
||||
let top = anchor.bottom + GAP;
|
||||
let left = anchor.left + anchor.width / 2 - box.width / 2;
|
||||
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
|
||||
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
|
||||
|
||||
setStyle({ opacity: 1, top, left });
|
||||
}, [open, libraryBytes, favoritesBytes]);
|
||||
|
||||
useEffect(() => () => clearOpenTimer(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={anchorRef}
|
||||
className={`offline-library-header-stat${breakdownReady ? ' offline-library-header-stat--interactive' : ''}`}
|
||||
aria-live="polite"
|
||||
aria-label={breakdownAriaLabel}
|
||||
onMouseEnter={scheduleOpen}
|
||||
onMouseLeave={close}
|
||||
onFocus={scheduleOpen}
|
||||
onBlur={close}
|
||||
tabIndex={breakdownReady ? 0 : undefined}
|
||||
>
|
||||
<span className="offline-library-disk-label">{label}</span>
|
||||
<span className="offline-library-disk-value">
|
||||
{totalBytes !== null ? formatBytes(totalBytes) : '…'}
|
||||
</span>
|
||||
</div>
|
||||
{open && breakdownReady && createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="offline-library-disk-breakdown"
|
||||
style={{ position: 'fixed', pointerEvents: 'auto', ...style }}
|
||||
role="tooltip"
|
||||
onMouseEnter={() => {
|
||||
clearOpenTimer();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={close}
|
||||
>
|
||||
<div className="offline-library-disk-breakdown-row">
|
||||
<span
|
||||
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--library"
|
||||
aria-hidden
|
||||
>
|
||||
<HardDriveDownload size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="offline-library-disk-breakdown-size">
|
||||
{formatBytes(libraryBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="offline-library-disk-breakdown-divider" aria-hidden />
|
||||
<div className="offline-library-disk-breakdown-row">
|
||||
<span
|
||||
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--favorites"
|
||||
aria-hidden
|
||||
>
|
||||
<Heart size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="offline-library-disk-breakdown-size">
|
||||
{formatBytes(favoritesBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export default function PSmallLogo({ className, style }: Props) {
|
||||
<svg viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
|
||||
<linearGradient id="psmallGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--accent)" />
|
||||
<stop offset="100%" stopColor="var(--accent-2)" />
|
||||
<stop offset="100%" stopColor="var(--ctp-blue)" />
|
||||
</linearGradient>
|
||||
</defs><g
|
||||
id="layer1"
|
||||
|
||||
@@ -10,13 +10,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
pingWithCredentials: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.55.0',
|
||||
openSubsonic: true,
|
||||
})),
|
||||
scheduleInstantMixProbeForServer: vi.fn(),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import StarRating from './StarRating';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
@@ -28,8 +29,6 @@ import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { coerceOpenArtistRefs } from '../utils/openArtistRefs';
|
||||
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
|
||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
|
||||
import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
|
||||
@@ -137,7 +136,7 @@ export default function PlayerBar() {
|
||||
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
@@ -148,8 +147,8 @@ export default function PlayerBar() {
|
||||
const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null;
|
||||
const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
|
||||
const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—');
|
||||
const displayArtistRefs = !showPreviewMeta && currentTrack && coerceOpenArtistRefs(currentTrack.artists).length > 0
|
||||
? resolveTrackArtistRefs(currentTrack)
|
||||
const displayArtistRefs = !showPreviewMeta && currentTrack?.artists && currentTrack.artists.length > 0
|
||||
? currentTrack.artists
|
||||
: undefined;
|
||||
|
||||
const coverArtId = showPreviewMeta
|
||||
@@ -181,7 +180,7 @@ export default function PlayerBar() {
|
||||
}, [volume, setVolume, utilityOverflow]);
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--bg-elevated) ${volume * 100}%)`,
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
};
|
||||
|
||||
const playerBarContent = (
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function PsysonicLogo({ className, style, gradientIdSuffix }: Pro
|
||||
<svg viewBox="0 0 594.45007 134.93138" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
|
||||
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--logo-color-start, var(--accent))" />
|
||||
<stop offset="100%" stopColor="var(--logo-color-end, var(--accent-2))" />
|
||||
<stop offset="100%" stopColor="var(--logo-color-end, var(--ctp-blue))" />
|
||||
</linearGradient>
|
||||
</defs><g
|
||||
id="layer1"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Play } from 'lucide-react';
|
||||
import { updatePlaylist } from '../api/subsonicPlaylists';
|
||||
import { resolvePlaylist, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
@@ -34,7 +33,6 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
|
||||
import { QueueList } from './queuePanel/QueueList';
|
||||
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
||||
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
||||
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
|
||||
|
||||
export default function QueuePanel() {
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
@@ -172,12 +170,11 @@ function QueuePanelHostOrSolo() {
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
const exportTrackIds = activeServerQueueTrackIds(queueItems);
|
||||
if (exportTrackIds.length === 0) return;
|
||||
if (queueItems.length === 0) return;
|
||||
if (activePlaylist) {
|
||||
setSaveState('saving');
|
||||
try {
|
||||
await updatePlaylist(activePlaylist.id, exportTrackIds);
|
||||
await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 1500);
|
||||
} catch (e) {
|
||||
@@ -199,8 +196,7 @@ function QueuePanelHostOrSolo() {
|
||||
};
|
||||
|
||||
const handleCopyQueueShare = async () => {
|
||||
const ids = activeServerQueueTrackIds(queueItems);
|
||||
if (ids.length === 0) {
|
||||
if (queueItems.length === 0) {
|
||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
@@ -211,6 +207,7 @@ function QueuePanelHostOrSolo() {
|
||||
if (!active) return;
|
||||
const srv = serverShareBaseUrl(active);
|
||||
if (!srv) return;
|
||||
const ids = queueItems.map(r => r.trackId);
|
||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
@@ -384,7 +381,7 @@ function QueuePanelHostOrSolo() {
|
||||
onSave={async (name) => {
|
||||
try {
|
||||
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
||||
const pl = await createPlaylist(name, activeServerQueueTrackIds(queueItems));
|
||||
const pl = await createPlaylist(name, queueItems.map(r => r.trackId));
|
||||
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
|
||||
setSaveModalOpen(false);
|
||||
} catch (e) {
|
||||
@@ -399,10 +396,7 @@ function QueuePanelHostOrSolo() {
|
||||
onClose={() => setLoadModalOpen(false)}
|
||||
onLoad={async (id, name, mode) => {
|
||||
try {
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const data = await resolvePlaylist(serverId, id);
|
||||
if (!data) return;
|
||||
const data = await getPlaylist(id);
|
||||
const tracks: Track[] = data.songs.map(songToTrack);
|
||||
if (tracks.length > 0) {
|
||||
if (mode === 'append') {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
@@ -23,9 +23,7 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea
|
||||
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
|
||||
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
|
||||
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
|
||||
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
|
||||
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
|
||||
import SidebarNavBody from './sidebar/SidebarNavBody';
|
||||
@@ -43,24 +41,15 @@ export default function Sidebar({
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const offlineJobs = useOfflineJobStore(s => s.jobs);
|
||||
const pinQueue = useOfflineJobStore(s => s.pinQueue);
|
||||
const cancelAllDownloadsStore = useOfflineJobStore(s => s.cancelAllDownloads);
|
||||
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
|
||||
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
|
||||
const activePin = pinQueue.find(p => p.status === 'downloading')
|
||||
?? pinQueue.find(p => p.status === 'queued');
|
||||
const queuedPinCount = pinQueue.filter(p => p.status === 'queued').length;
|
||||
const cancelAllDownloads = () => {
|
||||
clearOfflinePinTasks();
|
||||
cancelAllDownloadsStore();
|
||||
};
|
||||
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
|
||||
const syncJobDone = useDeviceSyncJobStore(s => s.done);
|
||||
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
|
||||
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
|
||||
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
|
||||
const isSyncing = syncJobStatus === 'running';
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const musicFolders = useAuthStore(s => s.musicFolders);
|
||||
@@ -72,8 +61,7 @@ export default function Sidebar({
|
||||
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
|
||||
const loggingMode = useAuthStore(s => s.loggingMode);
|
||||
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
|
||||
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||
const isServerOffline = offlineCtx.active;
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
@@ -94,7 +82,7 @@ export default function Sidebar({
|
||||
}, [playlistsRaw]);
|
||||
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
|
||||
const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl);
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1 && !isServerOffline;
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
|
||||
|
||||
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
|
||||
const selectedFolderName =
|
||||
@@ -113,34 +101,13 @@ export default function Sidebar({
|
||||
libraryItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
c.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav],
|
||||
[libraryItemsForReorder, luckyMixAvailable],
|
||||
);
|
||||
const visibleSystemConfigs = useMemo(
|
||||
() => systemItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
c.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[systemItemsForReorder, isServerOffline, offlineNav],
|
||||
() => systemItemsForReorder.filter(c => c.visible),
|
||||
[systemItemsForReorder],
|
||||
);
|
||||
|
||||
const sidebarItemsRef = useRef(sidebarItems);
|
||||
@@ -173,15 +140,10 @@ export default function Sidebar({
|
||||
|
||||
|
||||
const pickLibrary = (id: 'all' | string) => {
|
||||
if (isServerOffline) return;
|
||||
setMusicLibraryFilter(id);
|
||||
setLibraryDropdownOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isServerOffline) setLibraryDropdownOpen(false);
|
||||
}, [isServerOffline, setLibraryDropdownOpen]);
|
||||
|
||||
// Fetch playlists when expanded
|
||||
useEffect(() => {
|
||||
if (!playlistsExpanded || !isLoggedIn) return;
|
||||
@@ -269,8 +231,6 @@ export default function Sidebar({
|
||||
nowPlayingAtTop={nowPlayingAtTop}
|
||||
hasOfflineContent={hasOfflineContent}
|
||||
activeJobsCount={activeJobs.length}
|
||||
activePinName={activePin?.albumName ?? null}
|
||||
queuedPinCount={queuedPinCount}
|
||||
cancelAllDownloads={cancelAllDownloads}
|
||||
isSyncing={isSyncing}
|
||||
syncJobDone={syncJobDone}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../test/helpers/renderWithProviders';
|
||||
import SongCard from './SongCard';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
|
||||
const navigateToArtist = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useNavigateToArtist', () => ({
|
||||
useNavigateToArtist: () => navigateToArtist,
|
||||
}));
|
||||
|
||||
vi.mock('../cover/useLibraryCoverRef', () => ({
|
||||
useTrackCoverRef: () => undefined,
|
||||
}));
|
||||
|
||||
function song(overrides: Partial<SubsonicSong>): SubsonicSong {
|
||||
return {
|
||||
id: 's1', title: 'Track', artist: 'A', album: 'Alb', albumId: 'al1', duration: 100,
|
||||
...overrides,
|
||||
} as SubsonicSong;
|
||||
}
|
||||
|
||||
describe('SongCard', () => {
|
||||
it('splits OpenSubsonic artists into individual links', async () => {
|
||||
navigateToArtist.mockClear();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<SongCard
|
||||
disableArtwork
|
||||
song={song({
|
||||
artist: 'Apocalyptica', artistId: 'a1',
|
||||
artists: [{ id: 'a1', name: 'Apocalyptica' }, { id: 'a2', name: 'Joe Duplantier' }],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Apocalyptica')).toHaveClass('track-artist-link');
|
||||
expect(screen.getByText('Joe Duplantier')).toHaveClass('track-artist-link');
|
||||
await user.click(screen.getByText('Joe Duplantier'));
|
||||
expect(navigateToArtist).toHaveBeenCalledWith('a2');
|
||||
});
|
||||
});
|
||||
+15
-16
@@ -1,6 +1,7 @@
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus, Star, Disc3 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -12,9 +13,6 @@ import { enqueueAndPlay } from '../utils/playback/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
|
||||
|
||||
interface SongCardProps {
|
||||
song: SubsonicSong;
|
||||
@@ -33,7 +31,7 @@ function SongCard({
|
||||
}: SongCardProps) {
|
||||
const layoutPx = artworkSize ?? displayCssPx;
|
||||
const { t } = useTranslation();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const coverRef = useTrackCoverRef(song, undefined, { libraryResolve: false });
|
||||
@@ -57,7 +55,12 @@ function SongCard({
|
||||
};
|
||||
|
||||
const handleClick = handlePlay;
|
||||
const artistRefs = useMemo(() => resolveTrackArtistRefs(song), [song]);
|
||||
|
||||
const handleArtistClick = (e: React.MouseEvent) => {
|
||||
if (!song.artistId) return;
|
||||
e.stopPropagation();
|
||||
navigate(`/artist/${song.artistId}`);
|
||||
};
|
||||
|
||||
const handleAlbumClick = (e: React.MouseEvent) => {
|
||||
if (!song.albumId) return;
|
||||
@@ -139,16 +142,12 @@ function SongCard({
|
||||
</div>
|
||||
<div className="song-card-info">
|
||||
<p className="song-card-title truncate" title={song.title}>{song.title}</p>
|
||||
<p className="song-card-artist truncate" title={song.artist}>
|
||||
<OpenArtistRefInline
|
||||
refs={artistRefs}
|
||||
fallbackName={song.artist}
|
||||
onGoArtist={id => navigateToArtist(id)}
|
||||
as="none"
|
||||
linkTag="span"
|
||||
linkClassName="track-artist-link"
|
||||
/>
|
||||
</p>
|
||||
<p
|
||||
className={`song-card-artist truncate${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={handleArtistClick}
|
||||
title={song.artist}
|
||||
>{song.artist}</p>
|
||||
{song.albumId && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -10,7 +10,6 @@ import { enqueueAndPlay } from '../utils/playback/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
interface Props {
|
||||
@@ -40,7 +39,12 @@ function SongRow({ song, showBpm }: Props) {
|
||||
enqueue([songToTrack(song)]);
|
||||
};
|
||||
|
||||
const artistRefs = resolveTrackArtistRefs(song);
|
||||
// Split multi-artist tracks into individually clickable links (OpenSubsonic
|
||||
// `artists[]`), falling back to the single flat artist when absent — mirrors
|
||||
// the album tracklist so a "A · B" credit isn't one link to a single artist.
|
||||
const artistRefs = song.artists && song.artists.length > 0
|
||||
? song.artists
|
||||
: [{ id: song.artistId, name: song.artist }];
|
||||
|
||||
const bpmTooltip =
|
||||
song.localBpmSource === 'analysis'
|
||||
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
export default function StarFilterButton({ active, onChange, size = 'default' }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = active ? t('common.favoritesTooltipOn') : t('common.favoritesTooltipOff');
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
|
||||
|
||||
if (size === 'compact') {
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user