fix(cover): sanitize server_index_key so Windows :port URLs work (#889)

* fix(cover): sanitize server_index_key on disk so Windows accepts ":port" URLs

`serverIndexKeyFromUrl` (frontend) strips the URL scheme and leaves the rest of
the host as the index key — for a Navidrome instance running on the default
`:4533` port that is `host:4533/...`. On Linux/macOS the `:` is fine as a path
segment; on Windows `CreateDirectory` rejects the whole path with
`ERROR_INVALID_NAME` (os error 123). Result: every `cover_cache_ensure` and
`cover_cache_peek_batch` rejected its promise and the album / now-playing /
mainstage / lightbox surfaces stayed blank. Empirically verified — switching
the active server to a colon-free reverse-proxy URL made the covers load again
without any other change.

Centralize the fix in a new `cover_server_dir(root, key)` helper next to the
existing `cover_entity_relative_dir`: it runs `sanitize_path_segment` on the
server key the same way kind/entity ids are already cleaned, so `host:4533`
becomes `host_4533` and embedded URL paths collapse into one flat bucket
instead of nested directories. Every call site that wants the server bucket —
`cover_dir`, `count_cached_cover_ids`, `dir_usage_for_server`, the clear-server
command, and `clear_cover_fetch_failures` in the backfill worker — now goes
through it.

The on-disk layout changes (no more colons, no more nested URL paths), so bump
`LAYOUT_STAMP` to `canonical-segment-v4`. The existing stamp-mismatch sweep at
startup wipes the legacy buckets — users with a previously-working
(colon-free) layout rebuild the cache lazily as they browse. Library, offline,
and hot-cache data are not touched.

Adds two unit tests covering the sanitization on `cover_server_dir` and the
`cover_dir` passthrough.

Follow-up to #878 (which introduced `cover_cache_layout.rs` with
`sanitize_path_segment` applied only to kind/entity_id).

* chore(windows): silence dead_code warnings in debug taskbar_win build

`lib.rs` gates `taskbar_win::init` on `cfg(not(debug_assertions))` (PR #866 —
debug runs alongside an installed release instance and must not fight it for
the taskbar subclass). The `update_taskbar_icon` command still ships in debug
and early-returns until `init` populates the COM/HWND atomics, so the
init-only helpers (icon HICONs, button IDs, subclass plumbing, `make_buttons`,
`subclass_proc`, `init` itself) all look unused — 14 dead_code warnings on
every Windows debug `cargo build`.

File-level `#![cfg_attr(debug_assertions, allow(dead_code))]` suppresses those
warnings only in the debug profile. Release builds keep the strict dead-code
check, so a real removal would still surface there.

* docs(release): CHANGELOG for windows cover-cache server-key fix (PR #889)
This commit is contained in:
Frank Stellmacher
2026-05-28 23:13:27 +02:00
committed by GitHub
parent ae2e123a14
commit 839c438a6d
5 changed files with 58 additions and 9 deletions
@@ -4,14 +4,20 @@
//! Navidrome `album.id` is often a bare hash/snowflake; `coverArt` may use `al-*`.
//! Rarely `mf-*` / `dc-*` on disk when UI enables per-disc art. Path shape:
//!
//! `{root}/{server_index_key}/{kind}/{entity_id}/128.webp`
//! `{root}/{server_segment}/{kind}/{entity_id}/128.webp`
//!
//! `server_segment` is derived from the frontend's `serverIndexKeyFromUrl` (host + path,
//! no scheme). On Windows that key would otherwise drop a `:` straight into the filesystem
//! whenever the user runs Navidrome on a `:port` URL — `CreateDirectory` then rejects the
//! whole path with `ERROR_INVALID_NAME`. [`cover_server_dir`] sanitizes the key before it
//! hits disk; every caller that wants a server-scoped cover directory goes through it.
//!
//! Bump [`LAYOUT_STAMP`] when the on-disk format changes (app wipes legacy dirs on startup).
use std::path::{Path, PathBuf};
/// Written to `{cover_root}/.storage-layout` — mismatch triggers cache reset.
pub const LAYOUT_STAMP: &str = "canonical-segment-v3";
pub const LAYOUT_STAMP: &str = "canonical-segment-v4";
/// True for ids that are only valid as `getCoverArt` targets, not library entity keys.
pub fn is_fetch_only_cover_id(id: &str) -> bool {
@@ -36,13 +42,20 @@ pub fn sanitize_path_segment(segment: &str) -> String {
.collect()
}
/// Relative path under `{root}/{server_index_key}/` — change format here only.
/// Relative path under `{root}/{server_segment}/` — change format here only.
pub fn cover_entity_relative_dir(cache_kind: &str, cache_entity_id: &str) -> PathBuf {
let kind = sanitize_path_segment(cache_kind);
let entity = sanitize_path_segment(cache_entity_id);
PathBuf::from(kind).join(entity)
}
/// Per-server cache root (`{root}/{server_segment}/`). Sanitizes the index key so
/// `host:port` and embedded URL paths survive on Windows. Every caller that wants the
/// server bucket — list/count/clear/backfill — must go through this helper.
pub fn cover_server_dir(root: &Path, server_index_key: &str) -> PathBuf {
root.join(sanitize_path_segment(server_index_key))
}
/// Absolute directory for one cover entity (`…/album/al-…/` or `…/artist/ar-…/`).
pub fn cover_dir(
root: &Path,
@@ -50,7 +63,8 @@ pub fn cover_dir(
cache_kind: &str,
cache_entity_id: &str,
) -> PathBuf {
root.join(server_index_key).join(cover_entity_relative_dir(cache_kind, cache_entity_id))
cover_server_dir(root, server_index_key)
.join(cover_entity_relative_dir(cache_kind, cache_entity_id))
}
/// Resolved cover identity — keep in sync with TS `src/cover/resolveEntry.ts`.
@@ -193,6 +207,23 @@ mod tests {
assert_eq!(dir, root.join("srv").join("album").join("al-1"));
}
#[test]
fn server_segment_sanitizes_port_colon_and_url_path() {
let root = Path::new("/tmp/cover");
// Typical LAN URL key from `serverIndexKeyFromUrl`: `host:port/path`.
// The `:` is invalid on Windows; the `/` would otherwise create a
// nested directory rather than one bucket per server.
let dir = cover_server_dir(root, "192.168.1.10:4533/music");
assert_eq!(dir, root.join("192.168.1.10_4533_music"));
}
#[test]
fn cover_dir_passes_server_key_through_sanitizer() {
let root = Path::new("/tmp/cover");
let dir = cover_dir(root, "host:4533", "album", "al-1");
assert_eq!(dir, root.join("host_4533").join("album").join("al-1"));
}
#[test]
fn album_and_artist_segments_differ() {
let al = cover_entity_relative_dir("album", "al-1");