fix(paths): one file:// helper and a shape-based absolute-path test

Seven sites built format!("file://{p}") by hand or tested starts_with('/')
for "absolute path". Both are Unix-only: file://C:/... parses the drive
letter as the URL HOST, and C:\... does not start with '/'. On Windows
that silently cost every album cover, every runtime-tinted icon and the
local folder tree - a URL that resolves to nothing renders as an empty
rectangle, not as an error, so nothing complained.

qbz_models::fs_url now owns file_url / local_path / is_local_abs_path,
deciding by path SHAPE (POSIX, drive, UNC) rather than by host, so the
tests run and fail everywhere rather than only on Windows.

Linux behaviour is unchanged, deliberately: the backslash-to-slash rewrite
fires only for a Windows-shaped input, because a backslash is a legal
filename character on Unix and rewriting it would point /home/v/AC\DC at
a directory that does not exist. There is a test for exactly that.

Three of the seven sites were not in the plan's list and were found by its
step 8 grep: local_ephemeral.rs, local_playlist_qt.rs, playback_qt.rs.
Two starts_with('/') hits stay and are not local-path tests:
qbz-library/src/mount_info.rs is cfg(linux) mount-point arithmetic, and
qbz-theme's is parsing a COSMIC desktop config that Windows never reads.

cc
This commit is contained in:
vicrodh committed 2026-08-27 03:22:32 -06:00
1 parent 7129b9f70e
commit 8baf68db84
9 files changed
+203 -42

No files matched your search

+3 -19
View File
@@ -219,7 +219,7 @@ pub fn classify(url: &str) -> ArtUrl {
ArtUrl::Plex(fetch)
};
}
if url.starts_with('/') {
if qbz_models::fs_url::is_local_abs_path(url) {
return ArtUrl::LocalFile(url.to_string());
}
ArtUrl::Empty
@@ -240,27 +240,11 @@ pub fn classify(url: &str) -> ArtUrl {
/// result (a tint decode, a copy) for exactly the files the escaping exists to
/// support.
pub fn local_path(url: &str) -> String {
let raw = url.strip_prefix("file://").unwrap_or(url);
raw.replace("%23", "#")
.replace("%3F", "?")
.replace("%25", "%")
qbz_models::fs_url::local_path(url)
}
pub fn file_url(path: &str) -> String {
if path.starts_with("file://") {
return path.to_string();
}
let mut out = String::with_capacity(path.len() + 8);
out.push_str("file://");
for ch in path.chars() {
match ch {
'%' => out.push_str("%25"),
'#' => out.push_str("%23"),
'?' => out.push_str("%3F"),
_ => out.push(ch),
}
}
out
qbz_models::fs_url::file_url(path)
}
// ---------------------------------------------------------------------------
+6 -2
View File
@@ -622,8 +622,12 @@ impl qbz_icon_tint::QbzIconTint {
}
}
// Absolute paths start with '/', so this yields file:///home/...
QString::from(&format!("file://{}", root.join(&hex).display()))
// NOT format!("file://{path}"): on Windows that yields
// file://C:/... where the drive parses as the URL HOST, and the
// icon renders as an empty rectangle rather than an error.
QString::from(&qbz_models::fs_url::file_url(
&root.join(&hex).to_string_lossy(),
))
}
}
+1 -1
View File
@@ -791,7 +791,7 @@ pub fn set_session_artwork(path: &str) {
// `file://`), because that is the form the whole taxonomy downstream
// expects.
let ids: Vec<u64> = tracks.iter().map(|t| t.id as u64).collect();
let url = format!("file://{path}");
let url = qbz_models::fs_url::file_url(&path);
crate::spawn(async move {
let runtime = crate::app();
if runtime.core().patch_queue_artwork(&ids, &url).await {
+6 -2
View File
@@ -976,9 +976,13 @@ mod tests {
assert!(one.artwork_url.as_deref().unwrap().contains("Disc 01"));
assert!(five.artwork_url.as_deref().unwrap().contains("Disc 05"));
let one_resolved = crate::artwork_qt::cached_path(one.artwork_url.as_deref().unwrap());
// Compare separator-normalised: a `file://` URL is always `/`-separated,
// so the round trip through it cannot hand back a host's backslashes.
// Both forms open the same file on Windows.
let slash = |p: &str| p.replace('\\', "/");
assert_eq!(
crate::artwork_qt::local_path(&one_resolved),
disc_one.join("cover.jpg").to_string_lossy()
slash(&crate::artwork_qt::local_path(&one_resolved)),
slash(&disc_one.join("cover.jpg").to_string_lossy())
);
std::fs::remove_dir_all(&box_dir).unwrap();
+3 -7
View File
@@ -719,13 +719,9 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
album: album.clone(),
album_version: None,
duration_secs: *duration_secs,
artwork_url: artwork_path.clone().map(|path| {
if path.starts_with("file://") {
path
} else {
format!("file://{path}")
}
}),
artwork_url: artwork_path
.clone()
.map(|path| qbz_models::fs_url::file_url(&path)),
hires: bit_depth.map(|depth| depth >= 24).unwrap_or(false),
bit_depth: *bit_depth,
sample_rate: *sample_rate,
+1 -7
View File
@@ -1168,13 +1168,7 @@ async fn offline_album_queue(album_id: &str) -> Result<Vec<QueueTrack>, String>
let artwork = local
.and_then(|row| row.artwork_path.clone())
.or_else(|| cached.resolve_cover_path(&cache_path))
.map(|path| {
if path.starts_with("file://") {
path
} else {
format!("file://{path}")
}
});
.map(|path| qbz_models::fs_url::file_url(&path));
let album = cached.album.clone().unwrap_or_default();
let sample_rate = cached
.sample_rate