Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot] 48d2cf2640 chore(nix): refresh lock + npmDepsHash for v1.48.1 2026-06-16 23:44:38 +00:00
github-actions[bot] ab344c7c23 chore(release): finalize release version 1.48.1 2026-06-16 23:25:59 +00:00
github-actions[bot] fe01fc8ddc chore(nix): refresh lock + npmDepsHash for v1.48.1-rc.1 2026-06-16 22:38:45 +00:00
github-actions[bot] c966b9477e chore(release): bump next channel to 1.48.1-rc.1 2026-06-16 22:20:21 +00:00
cucadmuh 8cdb770250 docs(changelog): attribute 1.48.1 entries, fix spacing; refresh WHATS_NEW
CHANGELOG [1.48.1]: add missing blank line between the Opus seek-crash entry
and the next heading, and add "By @Psychotoxical" attribution (no PR number)
to the six entries that were committed directly to the hotfix branch (playback
freeze, macOS headphone restart, Discord cover art, minimize-to-tray, and the
two Windows media-controls fixes from issue #1102).

WHATS_NEW [1.48.1]: rewrite the section to reflect the full user-visible 1.48.1
changelog per the What's New rules — themed Fixed groups (Playback and audio;
Offline, Now Playing, and Navidrome; Themes and integrations; Other), ordered
by user impact, without author/PR/issue references.
2026-06-17 01:19:00 +03:00
cucadmuh 817e3c4685 chore(release): set hotfix line version to 1.48.1-dev
The fix/1.48.1 hotfix branch inherited 1.48.0 from release. No promotion
workflow bumps the patch digit, so set the base to 1.48.1 here (synced across
package.json, package-lock.json, Cargo.toml, Cargo.lock workspace crates, and
tauri.conf.json via scripts/sync-tauri-version-from-package.js) so the next
"Promote main to next" run (source_branch=fix/1.48.1) cuts 1.48.1-rc.N rather
than recomputing an already-shipped 1.48.0 tag.
2026-06-17 01:00:59 +03:00
Psychotoxical 76d028127d fix(windows): transcode WebP cover to PNG for the media controls (#1102)
Windows SMTC could not render our cached WebP album covers: souvlaki loads the
file and SetThumbnail/set_metadata succeed, but the lock screen and Quick
Settings media tile showed a blank cover, because the OS thumbnail decoder does
not handle WebP even with the Store WebP extension installed.

Transcode local file:// WebP covers to PNG (libwebp decode then image PNG
encode, into a single reusable temp file) before handing them to the OS media
controls, gated to Windows. macOS (ImageIO) and Linux pass through unchanged.
2026-06-16 23:51:19 +02:00
Psychotoxical 4fd85f2dd4 fix(windows): show app name in media controls via AppUserModelID (#1102)
The Windows system media controls (Quick Settings media tile, lock screen,
third-party media flyouts) labelled playback as "Unknown application" with no
icon, because souvlaki creates the SMTC via GetForWindow and Windows resolves
the source name from the process AppUserModelID, which was never set.

Call SetCurrentProcessExplicitAppUserModelID early in run() so the process has
an explicit identity that matches the installer shortcut's AppUserModelID;
Windows then resolves the name and icon to Psysonic.
2026-06-16 23:44:11 +02:00
cucadmuh bca0acbaff fix(library): use partial indexes for §6.9 remap lookup (#1105)
* fix(library): use partial indexes for §6.9 remap lookup

The delta-sync remap detection ran a single lookup with an OR across
content_hash and server_path. SQLite could not use the partial
idx_track_remap_hash / idx_track_remap_path indexes for it: a partial
index is only applied when the query's WHERE provably implies the index
predicate (… != ''), and an OR spanning two columns blocks the per-branch
index plan. The query degraded to a full track scan on every incoming
row → O(rows × catalog), causing multi-minute stalls on large libraries
(observed upsert_batch_remap exec_ms=162001 on a ~200k-track Navidrome
sync, which in turn blocked all other writers on the single write mutex).

Split it into two single-column lookups that each repeat the index
predicate so the planner picks the matching partial index (SEARCH, not
SCAN); hash is checked first, matching §6.9 strong-key priority. Adds an
EXPLAIN QUERY PLAN regression test asserting index usage.

* docs(changelog): note remap-lookup sync stall fix (PR #1105)
2026-06-16 23:41:46 +03:00
12 changed files with 240 additions and 39 deletions
+28
View File
@@ -15,11 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playback freeze on track changes
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* Changing tracks — skipping, or the automatic advance at the end of a song — could freeze the interface for several seconds while audio kept playing (the progress bar and lyrics stopped updating). The queue header recomputed its duration totals on every track change instead of only when the queue itself changes; it now recomputes only on queue changes, so track changes stay instant.
* This also resolves output-device changes not being applied on Windows: the same freeze was blocking playback from following the newly selected device.
### Paused or stopped playback restarting on headphone disconnect (macOS)
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On macOS, pausing or stopping playback and then disconnecting headphones (or otherwise switching the audio output device) could make playback restart on the newly selected device. Playback now reliably stays paused or stopped across a device change.
### Crash when seeking Opus/Ogg files
@@ -28,14 +32,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Scrubbing the seekbar on an Opus/Ogg file — and then pressing Stop — crashed the whole app (a 1.48 regression from the Symphonia 0.6 migration). The Ogg demuxer recorded its seek bounds only when the source was seekable during the format probe, but probing hid seekability, so the first seek panicked on the audio thread (`Option::unwrap()` on `None`) and took the process down at the audio backend boundary.
* Local and in-memory Opus/Ogg sources now stay seekable through the probe, so seeking works correctly. As a safety net, any decoder panic during a seek is contained instead of crashing the app; for Opus/Ogg streamed over HTTP, seeking is a no-op for now rather than a crash.
### Discord Rich Presence cover art missing with two server addresses
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* When a server profile had both a local and a public address, Discord Rich Presence showed the placeholder icon instead of the album cover. The cover URL used the local address, which Discord's servers can't reach; it now uses the public address (the same one used for share links).
### "Minimize to Tray" ignored on the macOS close button
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On macOS, closing the window with the red close button always quit the app, even with "Minimize to Tray" enabled. The close button now respects the setting — with it on, the window hides to the tray instead of quitting, the same as the tray icon's "Hide".
### Library sync stalling for many seconds on large Navidrome collections
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1105](https://github.com/Psychotoxical/psysonic/pull/1105)**
* On large libraries (reported with ~200,000 tracks on Navidrome), background library sync could lock up database writes for minutes at a time — playback history, ratings and other saves piled up waiting behind it.
* Root cause: the track id-remap step ran a database lookup that couldn't use its indexes and scanned the entire track table once per incoming track, so the cost grew with the square of the library size. The lookup now uses the proper indexes, bringing it back to a fast, near-instant operation.
### Album cover missing in Windows media controls
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) showed the track title and artist but no album cover. Windows could not decode the cached WebP cover art for its thumbnail, even with the Store WebP extension installed. The cover is now converted to PNG before it is handed to the media controls, so the artwork shows again. macOS and Linux are unaffected.
### Windows media controls showed "Unknown application"
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) labelled playback as "Unknown application" with no icon. The app now registers an explicit application identity at startup so Windows shows "Psysonic" and its icon as the playback source.
## [1.48.0]
+18 -1
View File
@@ -11,7 +11,24 @@ Within each section, order by **user impact** (most noticeable first) — not PR
## Fixed
- Fixed an issue with Opus playback where seeking the seekbar could crash the app.
### Playback and audio
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
### Offline, Now Playing, and Navidrome
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
### Themes and integrations
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
### Other
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
## [1.48.0]
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1781074563,
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
"lastModified": 1781577229,
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-ndXqYgws77qokAXznbQ6BXhXUo3VIaiF1AVs5jCkNCo="
"npmDepsHash": "sha256-T2zrXt3cvqjmeoEOjTPoEiJKuzLv6R5QMuwTeNBSH/8="
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.48.0",
"version": "1.48.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.48.0",
"version": "1.48.1",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.48.0",
"version": "1.48.1",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
+7 -7
View File
@@ -4114,7 +4114,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.48.0"
version = "1.48.1"
dependencies = [
"biquad",
"dasp_sample",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "psysonic-analysis"
version = "1.48.0"
version = "1.48.1"
dependencies = [
"ebur128",
"futures-util",
@@ -4186,7 +4186,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.48.0"
version = "1.48.1"
dependencies = [
"biquad",
"dasp_sample",
@@ -4216,7 +4216,7 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.48.0"
version = "1.48.1"
dependencies = [
"libc",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "psysonic-integration"
version = "1.48.0"
version = "1.48.1"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4242,7 +4242,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.48.0"
version = "1.48.1"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4257,7 +4257,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.48.0"
version = "1.48.1"
dependencies = [
"futures-util",
"id3",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.48.0"
version = "1.48.1"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
@@ -448,7 +448,10 @@ impl<'a> TrackRepository<'a> {
let mut remapped: Vec<RemapEntry> = Vec::new();
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
let mut remap_lookup = if unstable_track_ids {
Some(tx.prepare_cached(REMAP_LOOKUP_SQL)?)
Some((
tx.prepare_cached(REMAP_LOOKUP_BY_HASH_SQL)?,
tx.prepare_cached(REMAP_LOOKUP_BY_PATH_SQL)?,
))
} else {
None
};
@@ -459,11 +462,12 @@ impl<'a> TrackRepository<'a> {
// then do we retarget children to the new id, since
// child tables FK→track(server_id, id) and would refuse
// an UPDATE pointing at an id that doesn't exist yet.
let detected_old: Option<String> = if let Some(ref mut lookup) = remap_lookup {
detect_remap_target_cached(lookup, r)?
} else {
None
};
let detected_old: Option<String> =
if let Some((ref mut by_hash, ref mut by_path)) = remap_lookup {
detect_remap_target_cached(by_hash, by_path, r)?
} else {
None
};
upsert.execute(params![
r.server_id,
@@ -543,38 +547,76 @@ impl<'a> TrackRepository<'a> {
}
}
const REMAP_LOOKUP_SQL: &str = r#"
// Two single-column lookups instead of one `OR` across `content_hash`
// and `server_path`. The combined `OR` form could not use the partial
// `idx_track_remap_hash` / `idx_track_remap_path` indexes — SQLite only
// applies a partial index when the query's WHERE provably implies the
// index predicate (`… != ''`), and an `OR` spanning two columns blocks
// the per-branch index plan. The result was a full `track` scan per
// incoming row → O(rows × catalog) on large libraries (observed:
// `upsert_batch_remap exec_ms=162001` on a ~200k-track Navidrome sync).
// Each statement below repeats the index predicate so the planner picks
// the matching partial index (SEARCH, not SCAN); hash wins over path,
// matching §6.9's strong-key priority.
const REMAP_LOOKUP_BY_HASH_SQL: &str = r#"
SELECT id FROM track
WHERE server_id = ?1
AND deleted = 0
AND id != ?2
AND (
(?3 IS NOT NULL AND content_hash = ?3)
OR (?4 IS NOT NULL AND server_path = ?4)
)
AND content_hash IS NOT NULL
AND content_hash != ''
AND content_hash = ?2
AND id != ?3
LIMIT 1
"#;
const REMAP_LOOKUP_BY_PATH_SQL: &str = r#"
SELECT id FROM track
WHERE server_id = ?1
AND deleted = 0
AND server_path IS NOT NULL
AND server_path != ''
AND server_path = ?2
AND id != ?3
LIMIT 1
"#;
/// Run the `SELECT old.id` half of §6.9 — returns `Some(old_id)` if a
/// non-deleted row with a different id on this server matches the
/// incoming row's `content_hash` or `server_path`.
/// incoming row's `content_hash` or `server_path`. Hash is the stronger
/// key, so it is checked first.
fn detect_remap_target_cached(
lookup: &mut rusqlite::Statement<'_>,
by_hash: &mut rusqlite::Statement<'_>,
by_path: &mut rusqlite::Statement<'_>,
incoming: &TrackRow,
) -> rusqlite::Result<Option<String>> {
// Empty-string sentinels are *not* eligible — spec §6.9 explicitly
// excludes them so the file-tree default never collides.
let hash = incoming.content_hash.as_deref().filter(|s| !s.is_empty());
let path = incoming.server_path.as_deref().filter(|s| !s.is_empty());
if hash.is_none() && path.is_none() {
return Ok(None);
if let Some(hash) = hash {
let old = by_hash
.query_row(params![incoming.server_id, hash, incoming.id], |row| {
row.get::<_, String>(0)
})
.optional()?;
if old.is_some() {
return Ok(old);
}
}
lookup
.query_row(
params![incoming.server_id, incoming.id, hash, path],
|row| row.get::<_, String>(0),
)
.optional()
if let Some(path) = path {
let old = by_path
.query_row(params![incoming.server_id, path, incoming.id], |row| {
row.get::<_, String>(0)
})
.optional()?;
if old.is_some() {
return Ok(old);
}
}
Ok(None)
}
/// Run the §6.9 retarget half — UPDATE every FK-bound child to the
@@ -1247,6 +1289,48 @@ mod tests {
assert_eq!(count, 2, "both rows kept; identity-less rows can't shadow");
}
#[test]
fn remap_lookup_uses_partial_indexes_not_full_scan() {
// Regression: the §6.9 remap lookup must hit
// idx_track_remap_hash / idx_track_remap_path. The prior
// `OR`-based query fell back to a full `track` scan on every
// incoming row → O(rows × catalog) stalls on large libraries
// (`upsert_batch_remap exec_ms=162001` on a ~200k Navidrome sync).
let store = LibraryStore::open_in_memory();
let plan = |sql: &str| -> String {
store
.with_conn("misc", |c| {
let mut stmt = c.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
let rows: rusqlite::Result<Vec<String>> = stmt
.query_map(params!["s1", "v", "id"], |r| r.get::<_, String>(3))?
.collect();
rows
})
.unwrap()
.join("\n")
};
let hash_plan = plan(REMAP_LOOKUP_BY_HASH_SQL);
assert!(
hash_plan.contains("idx_track_remap_hash"),
"hash lookup must use idx_track_remap_hash, got: {hash_plan}"
);
assert!(
!hash_plan.contains("SCAN"),
"hash lookup must not full-scan track, got: {hash_plan}"
);
let path_plan = plan(REMAP_LOOKUP_BY_PATH_SQL);
assert!(
path_plan.contains("idx_track_remap_path"),
"path lookup must use idx_track_remap_path, got: {path_plan}"
);
assert!(
!path_plan.contains("SCAN"),
"path lookup must not full-scan track, got: {path_plan}"
);
}
#[test]
fn remap_is_noop_when_new_id_matches_existing_id() {
// Standard delta-sync: same id, same hash. Must not trigger
+21
View File
@@ -64,7 +64,28 @@ fn on_second_instance<R: tauri::Runtime>(
}
}
/// Windows: associate this process with an explicit AppUserModelID. Windows uses
/// it to name the app in taskbar grouping and the SMTC media controls; without it
/// the media tile reads "Unknown application". Must match the AppUserModelID the
/// installer sets on the Start-menu shortcut so the name/icon resolve.
#[cfg(target_os = "windows")]
fn set_app_user_model_id() {
use windows::core::w;
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
// SAFETY: a Win32 call with a static wide string; errors are non-fatal.
unsafe {
let _ = SetCurrentProcessExplicitAppUserModelID(w!("dev.psysonic.player"));
}
}
pub fn run() {
// Windows: bind this process to an explicit AppUserModelID before any window
// or the SMTC media controls are created, so the OS can resolve the app
// name/icon for taskbar grouping and the media tile (#1102 follow-up: the
// Quick-Settings / lock-screen media tile showed "Unknown application").
#[cfg(target_os = "windows")]
set_app_user_model_id();
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
#[cfg(target_os = "linux")]
{
@@ -84,6 +84,15 @@ pub(crate) fn mpris_set_metadata(
let duration = duration_secs.map(Duration::from_secs_f64);
let mut guard = controls.lock().unwrap();
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
// #1102: Windows SMTC cannot render our cached WebP covers. souvlaki loads
// the file and SetThumbnail/set_metadata succeed, but the lock screen and
// Quick-Settings media tile show a blank cover (the OS thumbnail decoder
// does not handle WebP, even with the Store WebP extension installed).
// Transcode local WebP covers to PNG for the OS media controls; macOS
// (ImageIO) decodes WebP fine, so other platforms pass through unchanged.
let cover_url = smtc_cover_url(cover_url);
ctrl.set_metadata(MediaMetadata {
title: title.as_deref(),
artist: artist.as_deref(),
@@ -94,6 +103,48 @@ pub(crate) fn mpris_set_metadata(
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
}
/// Rewrite a cached WebP cover URL to a PNG the OS media controls can render.
/// Windows SMTC cannot decode WebP thumbnails (#1102); other platforms and any
/// non-`file://`/non-WebP URL pass through unchanged.
fn smtc_cover_url(cover_url: Option<String>) -> Option<String> {
#[cfg(target_os = "windows")]
{
if let Some(url) = cover_url.as_deref() {
if let Some(path) = url.strip_prefix("file://") {
let is_webp = std::path::Path::new(path)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("webp"));
if is_webp {
match webp_file_to_temp_png(path) {
Ok(png) => return Some(format!("file://{png}")),
Err(e) => {
crate::app_eprintln!("[mpris] cover WebP->PNG transcode failed: {e}")
}
}
}
}
}
}
cover_url
}
/// Decode a WebP file (libwebp, the same codec that wrote the cover cache) and
/// re-encode it as a PNG in the temp dir, returning the native path. A single
/// reusable file is fine: souvlaki reads it synchronously inside `set_metadata`,
/// and the controls mutex serializes calls so it is never written concurrently.
#[cfg(target_os = "windows")]
fn webp_file_to_temp_png(webp_path: &str) -> Result<String, String> {
let bytes = std::fs::read(webp_path).map_err(|e| e.to_string())?;
let decoded = webp::Decoder::new(&bytes)
.decode()
.ok_or_else(|| "WebP decode returned None".to_string())?;
let img = decoded.to_image();
let out = std::env::temp_dir().join("psysonic-smtc-cover.png");
img.save_with_format(&out, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
Ok(out.to_string_lossy().into_owned())
}
#[tauri::command]
pub(crate) fn mpris_set_playback(
controls: tauri::State<MprisControls>,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.48.0",
"version": "1.48.1",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",