feat(local-library): complete catalog and metadata workflows

Add bounded native library models, complete grouping rails and scan progress. Bring source-aware metadata editing, media information and collection offline preflight to Qt, and repair inherited Plex album artwork without overriding healthy tokens.

cx
This commit is contained in:
vicrodh committed 2026-08-28 18:50:05 -06:00
1 parent 353ed7fff8
commit c7a3012955
79 files changed
+6576 -452

No files matched your search

+105 -11
View File
@@ -1,13 +1,15 @@
//! Headless local-favorites service.
//!
//! Frontend-agnostic store for favoriting LOCAL library items (genuine local
//! files + Plex — never the Qobuz offline cache). Mirrors `pinned_items.rs`
//! Frontend-agnostic store for favoriting LOCAL LIBRARY items (genuine local
//! files plus configured media servers — never the Qobuz offline cache).
//! Mirrors `pinned_items.rs`
//! (same pragmas, error style, in-memory `(kind, id)` set) per ADR-006; the
//! per-user lifecycle lives in the `qbz` crate wrapper (`crate::local_favorites`).
//!
//! Rows carry a display snapshot (title/subtitle/artwork) taken at favorite
//! time plus a denormalized `artist` (for per-artist counts) and `source`
//! (`local` | `plex`). The `CHECK` on `source` refuses `qobuz_download` at
//! (`local` | `plex` | `jellyfin` | `subsonic`). The `CHECK` on `source`
//! refuses `qobuz_download` at
//! write time, so the mixed-library feed built from this store is inherently
//! free of Qobuz-offline duplicates.
@@ -22,9 +24,9 @@ pub const DB_FILE_NAME: &str = "local_favorites.db";
/// A favorited local item with its display snapshot.
///
/// Ids are Strings: album = the local group key (`plex:…` / contains `|`/`/`),
/// artist = the artist NAME (local artists have no numeric id), track =
/// `file_path` (local) or `plex:<file_path>` (Plex) — the stable key.
/// Ids are Strings: album = the source-aware group key (`plex:…`,
/// `jellyfin:…`, `subsonic:…` or a local key containing `|`/`/`), artist =
/// the artist NAME, track = a source-aware stable key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalFavItem {
/// "album" | "artist" | "track".
@@ -35,7 +37,8 @@ pub struct LocalFavItem {
pub artwork_url: String,
/// Denormalized artist name (for per-artist counts); empty for kind="artist".
pub artist: String,
/// "local" | "plex" — never "qobuz_download".
/// "local" | "plex" | "jellyfin" | "subsonic" — never a Qobuz
/// download spelling.
pub source: String,
/// Unix seconds; the ordering key (newest first).
pub favorited_at: i64,
@@ -56,8 +59,10 @@ impl LocalFavoritesService {
let conn = Connection::open(db_path)
.map_err(|e| format!("Failed to open local favorites database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
conn.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;",
)
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
let service = Self {
conn,
@@ -97,17 +102,68 @@ impl LocalFavoritesService {
subtitle TEXT,
artwork_url TEXT,
artist TEXT,
source TEXT NOT NULL CHECK (source IN ('local','plex')),
source TEXT NOT NULL CHECK (
source IN ('local','plex','jellyfin','subsonic')
),
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
PRIMARY KEY (kind, id)
);
"#,
)
.map_err(|e| format!("Failed to initialize local favorites schema: {}", e))?;
// v1 accepted only local/Plex. SQLite cannot widen a CHECK in place,
// so rebuild the tiny snapshot table atomically while preserving every
// existing favorite. Index creation deliberately follows the rebuild:
// ALTER TABLE carries the old index names to the legacy table.
let table_sql = self
.conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='local_favorites'",
[],
|row| row.get::<_, String>(0),
)
.map_err(|e| format!("Failed to inspect local favorites schema: {e}"))?;
if !table_sql.contains("'jellyfin'") || !table_sql.contains("'subsonic'") {
self.conn
.execute_batch(
r#"
BEGIN IMMEDIATE;
ALTER TABLE local_favorites RENAME TO local_favorites_v1;
CREATE TABLE local_favorites (
kind TEXT NOT NULL CHECK (kind IN ('album','artist','track')),
id TEXT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
artwork_url TEXT,
artist TEXT,
source TEXT NOT NULL CHECK (
source IN ('local','plex','jellyfin','subsonic')
),
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
PRIMARY KEY (kind, id)
);
INSERT INTO local_favorites(
kind,id,title,subtitle,artwork_url,artist,source,favorited_at
)
SELECT kind,id,title,subtitle,artwork_url,artist,source,favorited_at
FROM local_favorites_v1;
DROP TABLE local_favorites_v1;
COMMIT;
"#,
)
.map_err(|e| format!("Failed to migrate local favorites schema: {e}"))?;
}
self.conn
.execute_batch(
r#"
CREATE INDEX IF NOT EXISTS idx_local_favorites_at
ON local_favorites(favorited_at);
CREATE INDEX IF NOT EXISTS idx_local_favorites_artist
ON local_favorites(kind, artist);
"#,
)
.map_err(|e| format!("Failed to initialize local favorites schema: {}", e))?;
.map_err(|e| format!("Failed to index local favorites schema: {e}"))?;
Ok(())
}
@@ -298,10 +354,48 @@ mod tests {
#[test]
fn source_check_rejects_offline() {
let s = LocalFavoritesService::new_in_memory().expect("svc");
s.favorite(&item("album", "jf", "J", "A", "jellyfin"))
.expect("Jellyfin belongs to the Local Library favorite domain");
s.favorite(&item("album", "sub", "S", "A", "subsonic"))
.expect("Subsonic belongs to the Local Library favorite domain");
assert!(
s.favorite(&item("album", "x", "X", "A", "qobuz_download"))
.is_err(),
"the source CHECK refuses qobuz-offline rows"
);
}
#[test]
fn v1_schema_migrates_without_losing_existing_favorites() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
r#"
CREATE TABLE local_favorites (
kind TEXT NOT NULL CHECK (kind IN ('album','artist','track')),
id TEXT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
artwork_url TEXT,
artist TEXT,
source TEXT NOT NULL CHECK (source IN ('local','plex')),
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
PRIMARY KEY (kind, id)
);
INSERT INTO local_favorites(
kind,id,title,subtitle,artwork_url,artist,source,favorited_at
) VALUES ('album','plex:old','Old','','','Artist','plex',42);
"#,
)
.unwrap();
let service = LocalFavoritesService {
conn,
keys: RwLock::new(HashSet::new()),
};
service.init_schema().unwrap();
service.load_from_db().unwrap();
assert!(service.is_favorite("album", "plex:old"));
service
.favorite(&item("album", "jellyfin:new", "New", "Artist", "jellyfin"))
.unwrap();
}
}
@@ -164,9 +164,6 @@ msgstr "Titel suchen..."
msgid "Title"
msgstr "Titel"
msgid "Duration"
msgstr "Dauer"
msgid "Quality"
msgstr "Qualität"
@@ -796,9 +793,6 @@ msgstr "CD"
msgid "Lossy"
msgstr "Verlustbehaftet"
msgid "Format"
msgstr "Format"
msgid "Other"
msgstr "Andere"
@@ -6082,3 +6076,121 @@ msgstr "Tastenschema"
msgid "Vim"
msgstr "Vim"
# Local Library QoL: Explorer, scan progress, and per-track metadata editor
msgid "All years"
msgstr "Alle Jahre"
msgid "Explorer columns"
msgstr "Explorer-Spalten"
msgid "Genre and year"
msgstr "Genre und Jahr"
msgid "Years"
msgstr "Jahre"
msgid "Edit track metadata"
msgstr "Track-Metadaten bearbeiten"
msgid "Building library index"
msgstr "Bibliotheksindex wird erstellt"
msgid "Local folders"
msgstr "Lokale Ordner"
msgid "Next track"
msgstr "Nächster Titel"
msgid "Overall"
msgstr "Gesamt"
msgid "Previous track"
msgstr "Vorheriger Titel"
msgid "Scanning library"
msgstr "Bibliothek wird gescannt"
msgid "The track is no longer in the library."
msgstr "Der Titel befindet sich nicht mehr in der Bibliothek."
msgid "Updating library index"
msgstr "Bibliotheksindex wird aktualisiert"
msgid "Edit album"
msgstr "Album bearbeiten"
msgid "Editing a local metadata sidecar"
msgstr "Lokale Metadaten-Sidecar-Datei wird bearbeitet"
msgid "Media-server metadata is stored in a local sidecar."
msgstr "Metadaten des Medienservers werden in einer lokalen Sidecar-Datei gespeichert."
msgid "Media-server metadata can only be edited with a sidecar."
msgstr "Metadaten des Medienservers können nur über eine Sidecar-Datei bearbeitet werden."
msgid "Network file"
msgstr "Netzwerkdatei"
msgid "Offline download"
msgstr "Offline-Download"
msgid "File path"
msgstr "Dateipfad"
msgid "Containing folder"
msgstr "Übergeordneter Ordner"
msgid "Media item ID"
msgstr "Medienobjekt-ID"
msgid "Album ID"
msgstr "Album-ID"
msgid "Server"
msgstr "Server"
msgid "Duration"
msgstr "Dauer"
msgid "Format"
msgstr "Format"
msgid "Channels"
msgstr "Kanäle"
msgid "File size"
msgstr "Dateigröße"
msgid "Location"
msgstr "Speicherort"
msgid "The media item is no longer available."
msgstr "Das Medienobjekt ist nicht mehr verfügbar."
msgid "The local album folder is not currently available."
msgstr "Der lokale Albumordner ist derzeit nicht verfügbar."
msgid "Everything is already available offline"
msgstr "Alles ist bereits offline verfügbar"
msgid "Some tracks are already available offline"
msgstr "Einige Titel sind bereits offline verfügbar"
msgid "{} of {} tracks in “{}” are already available offline."
msgstr "{} von {} Titeln in „{}“ sind bereits offline verfügbar."
msgid "Download the entire playlist again"
msgstr "Gesamte Playlist erneut herunterladen"
msgid "Download the entire album again"
msgstr "Gesamtes Album erneut herunterladen"
msgid "Re-downloads every track and can repair corrupt offline copies."
msgstr "Lädt jeden Titel erneut herunter und kann beschädigte Offline-Kopien reparieren."
msgid "Only missing tracks"
msgstr "Nur fehlende Titel"
msgid "Keeps existing offline copies and downloads tracks that are not available yet."
msgstr "Behält vorhandene Offline-Kopien bei und lädt noch nicht verfügbare Titel herunter."
@@ -1681,3 +1681,133 @@ msgstr "Keymap"
msgid "Vim"
msgstr "Vim"
# Local Library QoL: Explorer, scan progress, and per-track metadata editor
msgid "All years"
msgstr "All years"
msgid "Explorer columns"
msgstr "Explorer columns"
msgid "Genre and year"
msgstr "Genre and year"
msgid "Years"
msgstr "Years"
msgid "Edit track metadata"
msgstr "Edit track metadata"
msgid "Building library index"
msgstr "Building library index"
msgid "Local folders"
msgstr "Local folders"
msgid "Next track"
msgstr "Next track"
msgid "Overall"
msgstr "Overall"
msgid "Previous track"
msgstr "Previous track"
msgid "Scanning library"
msgstr "Scanning library"
msgid "Source"
msgstr "Source"
msgid "The track is no longer in the library."
msgstr "The track is no longer in the library."
msgid "Updating library index"
msgstr "Updating library index"
msgid "Edit album"
msgstr "Edit album"
msgid "Editing a local metadata sidecar"
msgstr "Editing a local metadata sidecar"
msgid "Media-server metadata is stored in a local sidecar."
msgstr "Media-server metadata is stored in a local sidecar."
msgid "Media-server metadata can only be edited with a sidecar."
msgstr "Media-server metadata can only be edited with a sidecar."
msgid "Network file"
msgstr "Network file"
msgid "Offline download"
msgstr "Offline download"
msgid "File path"
msgstr "File path"
msgid "Containing folder"
msgstr "Containing folder"
msgid "Media item ID"
msgstr "Media item ID"
msgid "Album ID"
msgstr "Album ID"
msgid "Server"
msgstr "Server"
msgid "Duration"
msgstr "Duration"
msgid "Format"
msgstr "Format"
msgid "Quality"
msgstr "Quality"
msgid "Channels"
msgstr "Channels"
msgid "File size"
msgstr "File size"
msgid "Location"
msgstr "Location"
msgid "The media item is no longer available."
msgstr "The media item is no longer available."
msgid "The local album folder is not currently available."
msgstr "The local album folder is not currently available."
msgid "Everything is already available offline"
msgstr "Everything is already available offline"
msgid "Couldn't load that playlist"
msgstr "Couldn't load that playlist"
msgid "This playlist has no playable tracks"
msgstr "This playlist has no playable tracks"
msgid "Some tracks are already available offline"
msgstr "Some tracks are already available offline"
msgid "{} of {} tracks in “{}” are already available offline."
msgstr "{} of {} tracks in “{}” are already available offline."
msgid "Download the entire playlist again"
msgstr "Download the entire playlist again"
msgid "Download the entire album again"
msgstr "Download the entire album again"
msgid "Re-downloads every track and can repair corrupt offline copies."
msgstr "Re-downloads every track and can repair corrupt offline copies."
msgid "Only missing tracks"
msgstr "Only missing tracks"
msgid "Keeps existing offline copies and downloads tracks that are not available yet."
msgstr "Keeps existing offline copies and downloads tracks that are not available yet."
@@ -6088,3 +6088,115 @@ msgstr "Mapa de teclas"
msgid "Vim"
msgstr "Vim"
# Local Library QoL: Explorer, scan progress, and per-track metadata editor
msgid "All years"
msgstr "Todos los años"
msgid "Explorer columns"
msgstr "Columnas del explorador"
msgid "Genre and year"
msgstr "Género y año"
msgid "Years"
msgstr "Años"
msgid "Edit track metadata"
msgstr "Editar metadatos de la pista"
msgid "Building library index"
msgstr "Creando el índice de la biblioteca"
msgid "Local folders"
msgstr "Carpetas locales"
msgid "Next track"
msgstr "Pista siguiente"
msgid "Overall"
msgstr "Total"
msgid "Previous track"
msgstr "Pista anterior"
msgid "Scanning library"
msgstr "Escaneando la biblioteca"
msgid "The track is no longer in the library."
msgstr "La pista ya no está en la biblioteca."
msgid "Updating library index"
msgstr "Actualizando el índice de la biblioteca"
msgid "Edit album"
msgstr "Editar álbum"
msgid "Editing a local metadata sidecar"
msgstr "Editando un sidecar de metadatos local"
msgid "Media-server metadata is stored in a local sidecar."
msgstr "Los metadatos del servidor multimedia se guardan en un sidecar local."
msgid "Media-server metadata can only be edited with a sidecar."
msgstr "Los metadatos del servidor multimedia solo pueden editarse mediante un sidecar."
msgid "Network file"
msgstr "Archivo de red"
msgid "Offline download"
msgstr "Descarga sin conexión"
msgid "File path"
msgstr "Ruta del archivo"
msgid "Containing folder"
msgstr "Carpeta contenedora"
msgid "Media item ID"
msgstr "ID del elemento multimedia"
msgid "Album ID"
msgstr "ID del álbum"
msgid "Server"
msgstr "Servidor"
msgid "Channels"
msgstr "Canales"
msgid "File size"
msgstr "Tamaño del archivo"
msgid "Location"
msgstr "Ubicación"
msgid "The media item is no longer available."
msgstr "El elemento multimedia ya no está disponible."
msgid "The local album folder is not currently available."
msgstr "La carpeta local del álbum no está disponible en este momento."
msgid "Everything is already available offline"
msgstr "Todo ya está disponible sin conexión"
msgid "Some tracks are already available offline"
msgstr "Algunas pistas ya están disponibles sin conexión"
msgid "{} of {} tracks in “{}” are already available offline."
msgstr "{} de {} pistas de “{}” ya están disponibles sin conexión."
msgid "Download the entire playlist again"
msgstr "Descargar toda la playlist de nuevo"
msgid "Download the entire album again"
msgstr "Descargar todo el álbum de nuevo"
msgid "Re-downloads every track and can repair corrupt offline copies."
msgstr "Vuelve a descargar cada pista y puede reparar copias sin conexión corruptas."
msgid "Only missing tracks"
msgstr "Solo las pistas faltantes"
msgid "Keeps existing offline copies and downloads tracks that are not available yet."
msgstr "Conserva las copias sin conexión existentes y descarga las pistas que aún no están disponibles."
@@ -164,9 +164,6 @@ msgstr "Rechercher des titres..."
msgid "Title"
msgstr "Titre"
msgid "Duration"
msgstr "Durée"
msgid "Quality"
msgstr "Qualité"
@@ -796,9 +793,6 @@ msgstr "CD"
msgid "Lossy"
msgstr "Avec perte"
msgid "Format"
msgstr "Format"
msgid "Other"
msgstr "Autre"
@@ -6080,3 +6074,121 @@ msgstr "Schéma de touches"
msgid "Vim"
msgstr "Vim"
# Local Library QoL: Explorer, scan progress, and per-track metadata editor
msgid "All years"
msgstr "Toutes les années"
msgid "Explorer columns"
msgstr "Colonnes de l’explorateur"
msgid "Genre and year"
msgstr "Genre et année"
msgid "Years"
msgstr "Années"
msgid "Edit track metadata"
msgstr "Modifier les métadonnées du morceau"
msgid "Building library index"
msgstr "Création de l’index de la bibliothèque"
msgid "Local folders"
msgstr "Dossiers locaux"
msgid "Next track"
msgstr "Morceau suivant"
msgid "Overall"
msgstr "Global"
msgid "Previous track"
msgstr "Morceau précédent"
msgid "Scanning library"
msgstr "Analyse de la bibliothèque"
msgid "The track is no longer in the library."
msgstr "Ce morceau n’est plus dans la bibliothèque."
msgid "Updating library index"
msgstr "Mise à jour de l’index de la bibliothèque"
msgid "Edit album"
msgstr "Modifier l’album"
msgid "Editing a local metadata sidecar"
msgstr "Modification d’un fichier sidecar de métadonnées local"
msgid "Media-server metadata is stored in a local sidecar."
msgstr "Les métadonnées du serveur multimédia sont stockées dans un fichier sidecar local."
msgid "Media-server metadata can only be edited with a sidecar."
msgstr "Les métadonnées du serveur multimédia ne peuvent être modifiées qu’avec un fichier sidecar."
msgid "Network file"
msgstr "Fichier réseau"
msgid "Offline download"
msgstr "Téléchargement hors ligne"
msgid "File path"
msgstr "Chemin du fichier"
msgid "Containing folder"
msgstr "Dossier parent"
msgid "Media item ID"
msgstr "ID de l’élément multimédia"
msgid "Album ID"
msgstr "ID de l’album"
msgid "Server"
msgstr "Serveur"
msgid "Duration"
msgstr "Durée"
msgid "Format"
msgstr "Format"
msgid "Channels"
msgstr "Canaux"
msgid "File size"
msgstr "Taille du fichier"
msgid "Location"
msgstr "Emplacement"
msgid "The media item is no longer available."
msgstr "L’élément multimédia n’est plus disponible."
msgid "The local album folder is not currently available."
msgstr "Le dossier local de l’album n’est pas disponible actuellement."
msgid "Everything is already available offline"
msgstr "Tout est déjà disponible hors ligne"
msgid "Some tracks are already available offline"
msgstr "Certaines pistes sont déjà disponibles hors ligne"
msgid "{} of {} tracks in “{}” are already available offline."
msgstr "{} pistes sur {} de « {} » sont déjà disponibles hors ligne."
msgid "Download the entire playlist again"
msgstr "Télécharger de nouveau toute la playlist"
msgid "Download the entire album again"
msgstr "Télécharger de nouveau tout l’album"
msgid "Re-downloads every track and can repair corrupt offline copies."
msgstr "Retélécharge chaque piste et peut réparer les copies hors ligne corrompues."
msgid "Only missing tracks"
msgstr "Uniquement les pistes manquantes"
msgid "Keeps existing offline copies and downloads tracks that are not available yet."
msgstr "Conserve les copies hors ligne existantes et télécharge les pistes qui ne sont pas encore disponibles."
@@ -163,9 +163,6 @@ msgstr "トラックを検索..."
msgid "Title"
msgstr "タイトル"
msgid "Duration"
msgstr "再生時間"
msgid "Quality"
msgstr "音質"
@@ -6064,3 +6061,118 @@ msgstr "キーマップ"
msgid "Vim"
msgstr "Vim"
# Local Library QoL: Explorer, scan progress, and per-track metadata editor
msgid "All years"
msgstr "すべての年"
msgid "Explorer columns"
msgstr "エクスプローラーの列"
msgid "Genre and year"
msgstr "ジャンルと年"
msgid "Years"
msgstr "年"
msgid "Edit track metadata"
msgstr "トラックのメタデータを編集"
msgid "Building library index"
msgstr "ライブラリインデックスを作成中"
msgid "Local folders"
msgstr "ローカルフォルダー"
msgid "Next track"
msgstr "次のトラック"
msgid "Overall"
msgstr "全体"
msgid "Previous track"
msgstr "前のトラック"
msgid "Scanning library"
msgstr "ライブラリをスキャン中"
msgid "The track is no longer in the library."
msgstr "このトラックはライブラリに存在しません。"
msgid "Updating library index"
msgstr "ライブラリインデックスを更新中"
msgid "Edit album"
msgstr "アルバムを編集"
msgid "Editing a local metadata sidecar"
msgstr "ローカルのメタデータサイドカーを編集中"
msgid "Media-server metadata is stored in a local sidecar."
msgstr "メディアサーバーのメタデータはローカルのサイドカーファイルに保存されます。"
msgid "Media-server metadata can only be edited with a sidecar."
msgstr "メディアサーバーのメタデータはサイドカーファイルでのみ編集できます。"
msgid "Network file"
msgstr "ネットワークファイル"
msgid "Offline download"
msgstr "オフラインダウンロード"
msgid "File path"
msgstr "ファイルパス"
msgid "Containing folder"
msgstr "保存フォルダー"
msgid "Media item ID"
msgstr "メディア項目 ID"
msgid "Album ID"
msgstr "アルバム ID"
msgid "Server"
msgstr "サーバー"
msgid "Duration"
msgstr "再生時間"
msgid "Channels"
msgstr "チャンネル"
msgid "File size"
msgstr "ファイルサイズ"
msgid "Location"
msgstr "場所"
msgid "The media item is no longer available."
msgstr "メディア項目は利用できなくなりました。"
msgid "The local album folder is not currently available."
msgstr "ローカルのアルバムフォルダーは現在利用できません。"
msgid "Everything is already available offline"
msgstr "すべてオフラインで利用できます"
msgid "Some tracks are already available offline"
msgstr "一部のトラックはすでにオフラインで利用できます"
msgid "{} of {} tracks in “{}” are already available offline."
msgstr "{} / {} 曲(“{}”)はすでにオフラインで利用できます。"
msgid "Download the entire playlist again"
msgstr "プレイリスト全体を再ダウンロード"
msgid "Download the entire album again"
msgstr "アルバム全体を再ダウンロード"
msgid "Re-downloads every track and can repair corrupt offline copies."
msgstr "全トラックを再ダウンロードし、破損したオフラインコピーを修復できます。"
msgid "Only missing tracks"
msgstr "不足しているトラックのみ"
msgid "Keeps existing offline copies and downloads tracks that are not available yet."
msgstr "既存のオフラインコピーを保持し、まだ利用できないトラックだけをダウンロードします。"
@@ -164,9 +164,6 @@ msgstr "Nummers zoeken..."
msgid "Title"
msgstr "Titel"
msgid "Duration"
msgstr "Duur"
msgid "Quality"
msgstr "Kwaliteit"
@@ -796,9 +793,6 @@ msgstr "CD"
msgid "Lossy"
msgstr "Lossy"
msgid "Format"
msgstr "Formaat"
msgid "Other"
msgstr "Overige"
@@ -6070,3 +6064,121 @@ msgstr "Toetsenschema"
msgid "Vim"
msgstr "Vim"
# Local Library QoL: Explorer, scan progress, and per-track metadata editor
msgid "All years"
msgstr "Alle jaren"
msgid "Explorer columns"
msgstr "Verkennerkolommen"
msgid "Genre and year"
msgstr "Genre en jaar"
msgid "Years"
msgstr "Jaren"
msgid "Edit track metadata"
msgstr "Trackmetadata bewerken"
msgid "Building library index"
msgstr "Bibliotheekindex opbouwen"
msgid "Local folders"
msgstr "Lokale mappen"
msgid "Next track"
msgstr "Volgende track"
msgid "Overall"
msgstr "Totaal"
msgid "Previous track"
msgstr "Vorige track"
msgid "Scanning library"
msgstr "Bibliotheek scannen"
msgid "The track is no longer in the library."
msgstr "De track staat niet meer in de bibliotheek."
msgid "Updating library index"
msgstr "Bibliotheekindex bijwerken"
msgid "Edit album"
msgstr "Album bewerken"
msgid "Editing a local metadata sidecar"
msgstr "Een lokaal metadata-sidecarbestand bewerken"
msgid "Media-server metadata is stored in a local sidecar."
msgstr "Metagegevens van de mediaserver worden opgeslagen in een lokaal sidecarbestand."
msgid "Media-server metadata can only be edited with a sidecar."
msgstr "Metagegevens van de mediaserver kunnen alleen via een sidecarbestand worden bewerkt."
msgid "Network file"
msgstr "Netwerkbestand"
msgid "Offline download"
msgstr "Offline download"
msgid "File path"
msgstr "Bestandspad"
msgid "Containing folder"
msgstr "Bovenliggende map"
msgid "Media item ID"
msgstr "Media-item-ID"
msgid "Album ID"
msgstr "Album-ID"
msgid "Server"
msgstr "Server"
msgid "Duration"
msgstr "Duur"
msgid "Format"
msgstr "Formaat"
msgid "Channels"
msgstr "Kanalen"
msgid "File size"
msgstr "Bestandsgrootte"
msgid "Location"
msgstr "Locatie"
msgid "The media item is no longer available."
msgstr "Het media-item is niet meer beschikbaar."
msgid "The local album folder is not currently available."
msgstr "De lokale albummap is momenteel niet beschikbaar."
msgid "Everything is already available offline"
msgstr "Alles is al offline beschikbaar"
msgid "Some tracks are already available offline"
msgstr "Sommige nummers zijn al offline beschikbaar"
msgid "{} of {} tracks in “{}” are already available offline."
msgstr "{} van {} nummers in ‘{}’ zijn al offline beschikbaar."
msgid "Download the entire playlist again"
msgstr "De volledige playlist opnieuw downloaden"
msgid "Download the entire album again"
msgstr "Het volledige album opnieuw downloaden"
msgid "Re-downloads every track and can repair corrupt offline copies."
msgstr "Downloadt elk nummer opnieuw en kan beschadigde offlinekopieën herstellen."
msgid "Only missing tracks"
msgstr "Alleen ontbrekende nummers"
msgid "Keeps existing offline copies and downloads tracks that are not available yet."
msgstr "Behoudt bestaande offlinekopieën en downloadt nummers die nog niet beschikbaar zijn."
@@ -164,9 +164,6 @@ msgstr "Buscar faixas..."
msgid "Title"
msgstr "Título"
msgid "Duration"
msgstr "Duração"
msgid "Quality"
msgstr "Qualidade"
@@ -796,9 +793,6 @@ msgstr "CD"
msgid "Lossy"
msgstr "Com perdas"
msgid "Format"
msgstr "Formato"
msgid "Other"
msgstr "Outra"
@@ -6080,3 +6074,121 @@ msgstr "Mapa de Teclas"
msgid "Vim"
msgstr "Vim"
# Local Library QoL: Explorer, scan progress, and per-track metadata editor
msgid "All years"
msgstr "Todos os anos"
msgid "Explorer columns"
msgstr "Colunas do explorador"
msgid "Genre and year"
msgstr "Gênero e ano"
msgid "Years"
msgstr "Anos"
msgid "Edit track metadata"
msgstr "Editar metadados da faixa"
msgid "Building library index"
msgstr "Criando o índice da biblioteca"
msgid "Local folders"
msgstr "Pastas locais"
msgid "Next track"
msgstr "Próxima faixa"
msgid "Overall"
msgstr "Geral"
msgid "Previous track"
msgstr "Faixa anterior"
msgid "Scanning library"
msgstr "Verificando a biblioteca"
msgid "The track is no longer in the library."
msgstr "A faixa não está mais na biblioteca."
msgid "Updating library index"
msgstr "Atualizando o índice da biblioteca"
msgid "Edit album"
msgstr "Editar álbum"
msgid "Editing a local metadata sidecar"
msgstr "Editando um sidecar de metadados local"
msgid "Media-server metadata is stored in a local sidecar."
msgstr "Os metadados do servidor de mídia são armazenados em um sidecar local."
msgid "Media-server metadata can only be edited with a sidecar."
msgstr "Os metadados do servidor de mídia só podem ser editados por meio de um sidecar."
msgid "Network file"
msgstr "Arquivo de rede"
msgid "Offline download"
msgstr "Download off-line"
msgid "File path"
msgstr "Caminho do arquivo"
msgid "Containing folder"
msgstr "Pasta que contém"
msgid "Media item ID"
msgstr "ID do item de mídia"
msgid "Album ID"
msgstr "ID do álbum"
msgid "Server"
msgstr "Servidor"
msgid "Duration"
msgstr "Duração"
msgid "Format"
msgstr "Formato"
msgid "Channels"
msgstr "Canais"
msgid "File size"
msgstr "Tamanho do arquivo"
msgid "Location"
msgstr "Localização"
msgid "The media item is no longer available."
msgstr "O item de mídia não está mais disponível."
msgid "The local album folder is not currently available."
msgstr "A pasta local do álbum não está disponível no momento."
msgid "Everything is already available offline"
msgstr "Tudo já está disponível off-line"
msgid "Some tracks are already available offline"
msgstr "Algumas faixas já estão disponíveis off-line"
msgid "{} of {} tracks in “{}” are already available offline."
msgstr "{} de {} faixas de “{}” já estão disponíveis off-line."
msgid "Download the entire playlist again"
msgstr "Baixar a playlist inteira novamente"
msgid "Download the entire album again"
msgstr "Baixar o álbum inteiro novamente"
msgid "Re-downloads every track and can repair corrupt offline copies."
msgstr "Baixa novamente todas as faixas e pode reparar cópias off-line corrompidas."
msgid "Only missing tracks"
msgstr "Apenas faixas ausentes"
msgid "Keeps existing offline copies and downloads tracks that are not available yet."
msgstr "Mantém as cópias off-line existentes e baixa as faixas que ainda não estão disponíveis."
@@ -163,9 +163,6 @@ msgstr "Поиск треков..."
msgid "Title"
msgstr "Название"
msgid "Duration"
msgstr "Длительность"
msgid "Quality"
msgstr "Качество"
@@ -796,9 +793,6 @@ msgstr "CD"
msgid "Lossy"
msgstr "С потерями"
msgid "Format"
msgstr "Формат"
msgid "Other"
msgstr "Другое"
@@ -6100,3 +6094,121 @@ msgstr "Раскладка клавиш"
msgid "Vim"
msgstr "Vim"
# Local Library QoL: Explorer, scan progress, and per-track metadata editor
msgid "All years"
msgstr "Все годы"
msgid "Explorer columns"
msgstr "Столбцы обозревателя"
msgid "Genre and year"
msgstr "Жанр и год"
msgid "Years"
msgstr "Годы"
msgid "Edit track metadata"
msgstr "Изменить метаданные трека"
msgid "Building library index"
msgstr "Создание индекса медиатеки"
msgid "Local folders"
msgstr "Локальные папки"
msgid "Next track"
msgstr "Следующий трек"
msgid "Overall"
msgstr "Всего"
msgid "Previous track"
msgstr "Предыдущий трек"
msgid "Scanning library"
msgstr "Сканирование медиатеки"
msgid "The track is no longer in the library."
msgstr "Трека больше нет в медиатеке."
msgid "Updating library index"
msgstr "Обновление индекса медиатеки"
msgid "Edit album"
msgstr "Изменить альбом"
msgid "Editing a local metadata sidecar"
msgstr "Редактирование локального sidecar-файла метаданных"
msgid "Media-server metadata is stored in a local sidecar."
msgstr "Метаданные медиасервера хранятся в локальном sidecar-файле."
msgid "Media-server metadata can only be edited with a sidecar."
msgstr "Метаданные медиасервера можно редактировать только через sidecar-файл."
msgid "Network file"
msgstr "Сетевой файл"
msgid "Offline download"
msgstr "Офлайн-загрузка"
msgid "File path"
msgstr "Путь к файлу"
msgid "Containing folder"
msgstr "Содержащая папка"
msgid "Media item ID"
msgstr "ID медиафайла"
msgid "Album ID"
msgstr "ID альбома"
msgid "Server"
msgstr "Сервер"
msgid "Duration"
msgstr "Длительность"
msgid "Format"
msgstr "Формат"
msgid "Channels"
msgstr "Каналы"
msgid "File size"
msgstr "Размер файла"
msgid "Location"
msgstr "Расположение"
msgid "The media item is no longer available."
msgstr "Медиафайл больше недоступен."
msgid "The local album folder is not currently available."
msgstr "Локальная папка альбома сейчас недоступна."
msgid "Everything is already available offline"
msgstr "Всё уже доступно офлайн"
msgid "Some tracks are already available offline"
msgstr "Некоторые треки уже доступны офлайн"
msgid "{} of {} tracks in “{}” are already available offline."
msgstr "{} из {} треков в «{}» уже доступны офлайн."
msgid "Download the entire playlist again"
msgstr "Скачать весь плейлист заново"
msgid "Download the entire album again"
msgstr "Скачать весь альбом заново"
msgid "Re-downloads every track and can repair corrupt offline copies."
msgstr "Повторно загружает каждый трек и может исправить повреждённые офлайн-копии."
msgid "Only missing tracks"
msgstr "Только недостающие треки"
msgid "Keeps existing offline copies and downloads tracks that are not available yet."
msgstr "Сохраняет существующие офлайн-копии и загружает ещё недоступные треки."
@@ -791,6 +791,44 @@ impl LastFmClient {
Ok(albums)
}
/// Public `artist.getInfo` lookup used by Local Library's conservative
/// portrait enrichment. No Last.fm user session is required; the proxy
/// supplies the application key. `autocorrect` is returned to the caller
/// as the canonical `name`, but consumers must still validate identity
/// before attaching the image to a local tag spelling.
pub async fn get_artist_info(&self, artist: &str) -> IntegrationResult<LastFmArtist> {
let url = format!("{}/artist.getInfo", LASTFM_PROXY_URL);
let response = self
.client
.post(&url)
.json(&json!({
"artist": artist,
"autocorrect": 1,
}))
.send()
.await?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"Last.fm artist.getInfo failed: {text}"
)));
}
let data: serde_json::Value = response.json().await?;
if let Some(error) = data.get("error") {
return Err(IntegrationError::api(
error.as_u64().unwrap_or(0) as u32,
data.get("message")
.and_then(|message| message.as_str())
.unwrap_or("Unknown error")
.to_string(),
));
}
let value = data.get("artist").ok_or_else(|| {
IntegrationError::internal("Last.fm returned no artist object".to_string())
})?;
parse_artist_info(value, artist)
}
/// Public `album.getInfo` lookup used by the local metadata editor for a
/// conservative artwork candidate. No Last.fm user session is required;
/// the existing proxy supplies the application key.
@@ -975,3 +1013,42 @@ fn parse_u64(value: Option<&serde_json::Value>) -> u64 {
})
.unwrap_or(0)
}
fn parse_artist_info(
value: &serde_json::Value,
fallback_name: &str,
) -> IntegrationResult<LastFmArtist> {
Ok(LastFmArtist {
name: value
.get("name")
.and_then(|name| name.as_str())
.unwrap_or(fallback_name)
.to_string(),
mbid: extract_mbid(value),
playcount: parse_u64(value.get("stats").and_then(|stats| stats.get("playcount"))),
image: extract_image(value),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn artist_info_keeps_autocorrected_identity_and_largest_image() {
let value = serde_json::json!({
"name": "Beyoncé",
"mbid": "859d0860-d480-4efd-970c-c05d5f1776b8",
"stats": { "playcount": "42" },
"image": [
{ "#text": "https://img/small.jpg", "size": "small" },
{ "#text": "https://img/large.jpg", "size": "extralarge" }
]
});
let parsed = parse_artist_info(&value, "Beyonce").unwrap();
assert_eq!(parsed.name, "Beyoncé");
assert_eq!(parsed.playcount, 42);
assert_eq!(parsed.image.as_deref(), Some("https://img/large.jpg"));
assert!(parsed.mbid.is_some());
}
}
+90 -2
View File
@@ -6018,6 +6018,39 @@ impl LibraryDatabase {
Ok(result)
}
/// Whether an artist-image lookup (positive or negative) was completed
/// within `max_age_secs`.
///
/// Negative rows deliberately keep `image_url` NULL. Remembering those
/// misses is what prevents every visit to a long Artists rail from
/// repeating the same Qobuz/Last.fm/Discogs requests. Custom artwork is
/// a positive answer regardless of the remote lookup timestamp.
pub fn artist_image_resolution_is_fresh(
&self,
artist_name: &str,
max_age_secs: i64,
) -> Result<bool, LibraryError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let cutoff = now.saturating_sub(max_age_secs.max(0));
self.conn
.query_row(
"SELECT EXISTS(
SELECT 1 FROM artist_images
WHERE artist_name = ?1
AND (custom_image_path IS NOT NULL OR fetched_at >= ?2)
)",
params![artist_name, cutoff],
|row| row.get::<_, i64>(0),
)
.map(|value| value != 0)
.map_err(|e| {
LibraryError::Database(format!("Failed to query artist image freshness: {e}"))
})
}
/// Get all custom artist images (for bulk lookup)
pub fn get_all_custom_artist_images(
&self,
@@ -6137,9 +6170,18 @@ impl LibraryDatabase {
.as_secs() as i64;
self.conn.execute(
"INSERT OR REPLACE INTO artist_images
"INSERT INTO artist_images
(artist_name, image_url, source, custom_image_path, canonical_name, fetched_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(artist_name) DO UPDATE SET
image_url = excluded.image_url,
source = excluded.source,
custom_image_path = COALESCE(excluded.custom_image_path,
artist_images.custom_image_path),
canonical_name = COALESCE(excluded.canonical_name,
artist_images.canonical_name),
fetched_at = excluded.fetched_at,
updated_at = excluded.updated_at",
params![artist_name, image_url, source, custom_image_path, canonical_name, now, now],
)
.map_err(|e| LibraryError::Database(format!("Failed to cache artist image: {}", e)))?;
@@ -8447,3 +8489,49 @@ mod metadata_editor_transaction_tests {
assert_eq!(after, first_row);
}
}
#[cfg(test)]
mod artist_image_cache_tests {
use super::*;
#[test]
fn remote_refresh_preserves_custom_art_and_negative_results_are_fresh() {
let tmp = tempfile::tempdir().unwrap();
let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap();
db.cache_artist_image_with_canonical(
"Beyonce",
None,
"custom",
Some("/pictures/beyonce.jpg"),
Some("Beyoncé"),
)
.unwrap();
db.cache_artist_image_with_canonical(
"Beyonce",
Some("https://cdn.example/beyonce.jpg"),
"qobuz",
None,
None,
)
.unwrap();
let image = db.get_artist_image("Beyonce").unwrap().unwrap();
assert_eq!(
image.custom_image_path.as_deref(),
Some("/pictures/beyonce.jpg")
);
assert_eq!(image.canonical_name.as_deref(), Some("Beyoncé"));
db.cache_artist_image_with_canonical("No Portrait", None, "miss", None, None)
.unwrap();
assert!(db
.artist_image_resolution_is_fresh("No Portrait", 60)
.unwrap());
assert!(db
.get_artist_image("No Portrait")
.unwrap()
.unwrap()
.image_url
.is_none());
}
}
+15 -14
View File
@@ -32,18 +32,19 @@ pub mod album_grouping;
mod cue_parser;
mod database;
pub mod ephemeral;
pub mod local_playlists;
pub mod qobuz_playlist_snapshot;
mod errors;
pub mod local_playlists;
mod metadata;
mod models;
mod mount_info;
pub mod qobuz_playlist_snapshot;
pub mod reachability;
mod remote_tag_sidecar;
mod sacd;
mod scan;
mod scanner;
mod sacd;
mod tag_writer;
mod tag_sidecar;
mod tag_writer;
mod thumbnails;
mod watcher;
@@ -58,19 +59,18 @@ pub use metadata::MetadataExtractor;
pub use models::*;
pub use mount_info::{is_network_path, network_fs_label};
pub use reachability::{probe, probe_default, Reach, DEFAULT_PROBE};
pub use scan::{scan_with_progress, ScanEvent};
pub use sacd::{SacdImageImport, SacdImportResult};
pub use scan::{scan_with_progress, ScanEvent};
pub use scanner::{
LibraryScanner, ScanEntry, ScanFileKind, ScanResult, ScanStream, ScanWalkError, SymlinkPolicy,
};
pub use tag_writer::{
compute_track_artist_match, inspect_album_tag_layers, read_editor_tag_snapshots,
write_album_tags_to_files, write_album_tags_to_files_extended, write_folder_front_cover,
write_album_tags_to_files_with_options, write_purchase_tags, AlbumTagInspection,
AlbumTagWrite, DirectTagWriteOptions, EditorTrackTagSnapshot, ExtendedAlbumTagWrite,
ExtendedTrackTagWrite, FrontCoverWrite, Id3v2WriteVersion, PurchaseTagWrite,
TagLayerInspection, TrackTagWrite,
};
pub use scanner::{
LibraryScanner, ScanEntry, ScanFileKind, ScanResult, ScanStream, ScanWalkError,
SymlinkPolicy,
write_album_tags_to_files, write_album_tags_to_files_extended,
write_album_tags_to_files_with_options, write_folder_front_cover, write_purchase_tags,
AlbumTagInspection, AlbumTagWrite, DirectTagWriteOptions, EditorTrackTagSnapshot,
ExtendedAlbumTagWrite, ExtendedTrackTagWrite, FrontCoverWrite, Id3v2WriteVersion,
PurchaseTagWrite, TagLayerInspection, TrackTagWrite,
};
pub use thumbnails::{
clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size,
@@ -84,6 +84,7 @@ pub use watcher::{LocalRootWatcher, RootWatchEvent};
pub mod database_exports {
pub use crate::database::*;
}
pub use remote_tag_sidecar::*;
pub use tag_sidecar::*;
use std::path::PathBuf;
@@ -0,0 +1,258 @@
//! Profile-scoped metadata sidecars for media-server albums.
//!
//! Physical Local Library albums keep their `.qbz.json` beside the audio
//! files. Plex/Jellyfin/Subsonic albums have no writable local directory, so
//! their equivalent document lives in one small SQLite sidecar database in
//! the active user's data directory. The payload is deliberately the SAME
//! [`AlbumTagSidecar`] used by physical albums: the editor, validation and
//! future schema migrations therefore have one document rather than a remote
//! approximation of it.
use std::path::Path;
use std::time::Duration;
use rusqlite::{params, Connection, OptionalExtension};
use crate::{AlbumTagSidecar, LibraryError};
/// Stable identity of one remote album. `source_instance` is the server's own
/// id (or `default` for old cache rows that predate it); `album_id` and every
/// track `file_path` inside the payload are source-native opaque ids.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RemoteTagTarget {
pub source: String,
pub source_instance: String,
pub album_id: String,
}
impl RemoteTagTarget {
pub fn new(
source: impl Into<String>,
source_instance: impl Into<String>,
album_id: impl Into<String>,
) -> Self {
let source_instance = source_instance.into();
Self {
source: source.into(),
source_instance: if source_instance.trim().is_empty() {
"default".to_string()
} else {
source_instance
},
album_id: album_id.into(),
}
}
}
/// One decoded row used by the derived catalog's bounded bootstrap overlay.
#[derive(Debug, Clone)]
pub struct StoredRemoteTagSidecar {
pub target: RemoteTagTarget,
pub sidecar: AlbumTagSidecar,
}
pub struct RemoteTagSidecarStore {
conn: Connection,
}
impl RemoteTagSidecarStore {
pub fn open(path: &Path) -> Result<Self, LibraryError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(LibraryError::Io)?;
}
let conn =
Connection::open(path).map_err(|error| LibraryError::Database(error.to_string()))?;
conn.busy_timeout(Duration::from_secs(5))
.map_err(|error| LibraryError::Database(error.to_string()))?;
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
CREATE TABLE IF NOT EXISTS remote_album_tag_sidecars (
source TEXT NOT NULL,
source_instance TEXT NOT NULL,
album_id TEXT NOT NULL,
payload_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY(source, source_instance, album_id)
);
CREATE INDEX IF NOT EXISTS idx_remote_tag_sidecars_updated
ON remote_album_tag_sidecars(source, source_instance, updated_at);",
)
.map_err(|error| LibraryError::Database(error.to_string()))?;
Ok(Self { conn })
}
pub fn get(&self, target: &RemoteTagTarget) -> Result<Option<AlbumTagSidecar>, LibraryError> {
let json = self
.conn
.query_row(
"SELECT payload_json FROM remote_album_tag_sidecars
WHERE source=?1 AND source_instance=?2 AND album_id=?3",
params![target.source, target.source_instance, target.album_id],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| LibraryError::Database(error.to_string()))?;
json.map(|json| {
serde_json::from_str(&json).map_err(|error| LibraryError::Metadata(error.to_string()))
})
.transpose()
}
/// Replace one complete album document atomically. A full payload is
/// intentional: track navigation edits one row in a validated snapshot,
/// then persists that snapshot just like the album editor does.
pub fn put(
&mut self,
target: &RemoteTagTarget,
sidecar: &AlbumTagSidecar,
) -> Result<(), LibraryError> {
let json = serde_json::to_string(sidecar)
.map_err(|error| LibraryError::Metadata(error.to_string()))?;
let tx = self
.conn
.transaction()
.map_err(|error| LibraryError::Database(error.to_string()))?;
tx.execute(
"INSERT INTO remote_album_tag_sidecars(
source,source_instance,album_id,payload_json,updated_at
) VALUES (?1,?2,?3,?4,?5)
ON CONFLICT(source,source_instance,album_id) DO UPDATE SET
payload_json=excluded.payload_json,
updated_at=excluded.updated_at",
params![
target.source,
target.source_instance,
target.album_id,
json,
sidecar.updated_at
],
)
.map_err(|error| LibraryError::Database(error.to_string()))?;
tx.commit()
.map_err(|error| LibraryError::Database(error.to_string()))
}
pub fn all(&self) -> Result<Vec<StoredRemoteTagSidecar>, LibraryError> {
let mut stmt = self
.conn
.prepare(
"SELECT source,source_instance,album_id,payload_json
FROM remote_album_tag_sidecars",
)
.map_err(|error| LibraryError::Database(error.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.map_err(|error| LibraryError::Database(error.to_string()))?;
let mut out = Vec::new();
for row in rows {
let (source, source_instance, album_id, json) =
row.map_err(|error| LibraryError::Database(error.to_string()))?;
let sidecar = match serde_json::from_str(&json) {
Ok(sidecar) => sidecar,
Err(error) => {
// One damaged optional overlay must not disable metadata
// edits for every other server album in the profile. The
// authoritative media caches remain readable; skip only
// the bad row and leave enough identity in the log to
// repair/remove it later.
log::warn!(
"[remote-tag-sidecar] ignoring corrupt row source={} instance={} album={}: {}",
source,
source_instance,
album_id,
error
);
continue;
}
};
out.push(StoredRemoteTagSidecar {
target: RemoteTagTarget::new(source, source_instance, album_id),
sidecar,
});
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AlbumMetadataOverride, TrackMetadataOverride};
#[test]
fn profile_sidecar_roundtrip_keeps_native_track_identity_and_clears() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("metadata_sidecars.db");
let target = RemoteTagTarget::new("subsonic", "navidrome-a", "album-9");
let sidecar = AlbumTagSidecar::new(
AlbumMetadataOverride {
album_title: Some("Edited album".into()),
album_artist: Some(String::new()),
year: Some(0),
genre: Some("Progressive rock".into()),
catalog_number: Some("CAT-1".into()),
},
vec![TrackMetadataOverride {
file_path: "native-track-4".into(),
cue_start_secs: None,
title: Some("Edited track".into()),
disc_number: Some(0),
track_number: Some(7),
}],
);
let mut store = RemoteTagSidecarStore::open(&path).unwrap();
store.put(&target, &sidecar).unwrap();
let read = store.get(&target).unwrap().unwrap();
assert_eq!(read.album.album_title.as_deref(), Some("Edited album"));
assert_eq!(read.album.year, Some(0));
assert_eq!(read.tracks[0].file_path, "native-track-4");
assert_eq!(read.tracks[0].disc_number, Some(0));
let all = store.all().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].target, target);
}
#[test]
fn old_cache_rows_use_a_stable_default_server_identity() {
assert_eq!(
RemoteTagTarget::new("plex", "", "album").source_instance,
"default"
);
}
#[test]
fn one_corrupt_payload_does_not_hide_other_sidecars() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("metadata_sidecars.db");
let mut store = RemoteTagSidecarStore::open(&path).unwrap();
let target = RemoteTagTarget::new("plex", "server", "good");
store
.put(
&target,
&AlbumTagSidecar::new(AlbumMetadataOverride::default(), Vec::new()),
)
.unwrap();
store
.conn
.execute(
"INSERT INTO remote_album_tag_sidecars VALUES
('plex','server','broken','{',1)",
[],
)
.unwrap();
let rows = store.all().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].target, target);
}
}
+1
View File
@@ -172,6 +172,7 @@ pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar)
if let Some(genre) = sidecar.album.genre.as_ref() {
track.genre = normalize(genre);
track.genres = track.genre.iter().cloned().collect();
}
if let Some(catalog_number) = sidecar.album.catalog_number.as_ref() {
@@ -163,6 +163,12 @@ pub struct BootstrapProgress {
pub generation: u64,
pub source: SourceKey,
pub committed_rows: u64,
pub source_rows_total: u64,
pub overall_rows_written: u64,
pub overall_rows_total: u64,
/// One-based position of this source in the current bootstrap.
pub source_index: usize,
pub source_count: usize,
pub checkpoint_cursor: String,
pub source_complete: bool,
}
+110 -5
View File
@@ -358,6 +358,114 @@ fn legacy_fixture_bootstraps_all_sources_read_only_and_is_idempotent() {
assert!(!BootstrapLayout::new(temp.path()).building_path(2).exists());
}
#[test]
fn plex_album_metadata_art_fills_missing_track_art_and_reconciles() {
let temp = tempdir().unwrap();
let path = temp.path().join("plex_cache.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE plex_cache_tracks (
rating_key TEXT PRIMARY KEY, server_id TEXT, title TEXT NOT NULL,
artist TEXT, album TEXT, duration_ms INTEGER, container TEXT,
bit_depth INTEGER, sampling_rate_hz INTEGER, artwork_path TEXT,
collection_artwork_path TEXT, updated_at INTEGER, year INTEGER,
disc_number INTEGER, track_number INTEGER, parent_rating_key TEXT
);
CREATE TABLE plex_cache_album_artwork (
server_id TEXT NOT NULL, parent_rating_key TEXT NOT NULL,
artwork_path TEXT, checked_at INTEGER NOT NULL,
PRIMARY KEY(server_id,parent_rating_key)
);
INSERT INTO plex_cache_tracks VALUES
('track-1','plex-server','Song','Rammstein','1994',180000,'flac',
16,44100,NULL,NULL,10,2002,1,1,'60335');
INSERT INTO plex_cache_album_artwork VALUES
('plex-server','60335','/library/metadata/60320/thumb/1',20);",
)
.unwrap();
drop(conn);
assert!(matches!(
bootstrap_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(),
BootstrapOutcome::Activated { track_count: 1, .. }
));
let track_ref = TrackRef {
source: SourceKind::Plex,
source_instance: "plex-server".to_string(),
native_id: "track-1".to_string(),
};
let layout = BootstrapLayout::new(temp.path());
let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else {
panic!("Plex artwork fixture did not activate")
};
assert_eq!(
catalog
.resolve(&track_ref)
.unwrap()
.unwrap()
.artwork_token
.as_deref(),
Some("/library/metadata/60320/thumb/1")
);
drop(catalog);
Connection::open(&path)
.unwrap()
.execute(
"UPDATE plex_cache_album_artwork
SET artwork_path='/library/metadata/60320/thumb/2',checked_at=21
WHERE server_id='plex-server' AND parent_rating_key='60335'",
[],
)
.unwrap();
assert!(matches!(
crate::reconcile_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(),
ProjectionOutcome::Activated {
changed_sources: 1,
..
}
));
let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else {
panic!("Plex artwork refresh did not activate")
};
assert_eq!(
catalog
.resolve(&track_ref)
.unwrap()
.unwrap()
.artwork_token
.as_deref(),
Some("/library/metadata/60320/thumb/2")
);
}
#[test]
fn one_source_bootstrap_reports_identical_source_and_overall_progress() {
let temp = tempdir().unwrap();
create_local_fixture(&temp.path().join("library.db"));
let mut progress = Vec::new();
let outcome = bootstrap_legacy_caches_at_with_progress(
&LegacyLocations::co_located(temp.path()),
&AtomicBool::new(false),
|event| progress.push(event.clone()),
)
.unwrap();
assert!(matches!(outcome, BootstrapOutcome::Activated { .. }));
assert!(!progress.is_empty());
assert!(progress.iter().all(|event| {
event.source_index == 1
&& event.source_count == 1
&& event.source_rows_total == event.overall_rows_total
&& event.committed_rows == event.overall_rows_written
}));
let final_event = progress.last().unwrap();
assert!(final_event.source_complete);
assert_eq!(final_event.source_rows_total, 2);
assert_eq!(final_event.overall_rows_total, 2);
}
/// Explicit recovery-cost gate using the production Rust legacy reader,
/// projector, materialized views, FTS activation check and side-by-side rename.
/// Ordinary unit runs skip it because the 200k case intentionally creates a
@@ -405,11 +513,8 @@ fn legacy_recovery_cost_uses_the_real_projector_at_scale() {
drop(source);
let changed_source = fs::read(&source_path).unwrap();
let reconcile_started = Instant::now();
let projection = crate::reconcile_legacy_caches(
temp.path(),
&AtomicBool::new(false),
)
.unwrap();
let projection =
crate::reconcile_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap();
let reconcile_elapsed = reconcile_started.elapsed();
assert!(matches!(
projection,
+104 -15
View File
@@ -1228,11 +1228,11 @@ impl Catalog {
"artist_source_stats",
"edition_artists",
] {
let result: String = self.conn.query_row(
&format!("PRAGMA quick_check('{table}')"),
[],
|row| row.get(0),
)?;
let result: String =
self.conn
.query_row(&format!("PRAGMA quick_check('{table}')"), [], |row| {
row.get(0)
})?;
if result != "ok" {
sqlite_ok = false;
failures.push(format!("{table}: {result}"));
@@ -1696,15 +1696,79 @@ fn filter_parts(
}
predicates.push(format!("({})", source_predicates.join(" OR ")));
}
if !descriptor.formats().is_empty() {
predicates.push(format!(
"{alias}.format IN ({})",
std::iter::repeat("?")
.take(descriptor.formats().len())
.collect::<Vec<_>>()
.join(",")
));
values.extend(descriptor.formats().iter().cloned().map(Value::Text));
if !descriptor.source_buckets().is_empty() {
let mut source_arms = Vec::new();
for bucket in descriptor.source_buckets() {
source_arms.push(match bucket.as_str() {
// A scanned file is owned by the Local catalog source and is
// normally spelled `user` in source_raw. Qobuz downloads can
// share that ownership, so raw provenance must remove them
// from Local and expose them through the Offline chip instead.
"local" => format!(
"({alias}.source_kind='local' AND {alias}.source_raw NOT IN ('qobuz_download','qobuz_purchase'))"
),
"offline" => format!(
"({alias}.source_kind='offline' OR {alias}.source_raw IN ('qobuz_download','qobuz_purchase'))"
),
"plex" => format!("{alias}.source_kind='plex'"),
"jellyfin" => format!("{alias}.source_kind='jellyfin'"),
"subsonic" => format!("{alias}.source_kind='subsonic'"),
_ => {
return Err(CatalogError::InvalidInput(format!(
"unknown track source bucket {bucket}"
)))
}
});
}
predicates.push(format!("({})", source_arms.join(" OR ")));
}
if !descriptor.formats().is_empty() || descriptor.other_formats() {
let mut format_arms = Vec::new();
if !descriptor.formats().is_empty() {
format_arms.push(format!(
"{alias}.format IN ({})",
std::iter::repeat("?")
.take(descriptor.formats().len())
.collect::<Vec<_>>()
.join(",")
));
values.extend(descriptor.formats().iter().cloned().map(Value::Text));
}
if descriptor.other_formats() {
format_arms.push(format!(
"{alias}.format NOT IN ('flac','alac','ape','wav','wave','mp3','aac')"
));
}
predicates.push(format!("({})", format_arms.join(" OR ")));
}
if !descriptor.quality_tiers().is_empty() {
// Keep this expression in lockstep with qbz-qt's `tier_of` and the
// albums_materialized quality_tier projection. Tracks deliberately do
// not materialize a second quality column just for this filter.
let tier = format!(
"CASE \
WHEN LOWER({alias}.format)='mp3' THEN 'mp3' \
WHEN LOWER({alias}.format) IN ('dsd','dsf','dff') THEN 'hires' \
WHEN {alias}.bit_depth>=24 AND {alias}.sample_rate_hz>96000 THEN 'max' \
WHEN {alias}.bit_depth>=24 THEN 'hires' \
WHEN {alias}.bit_depth IS NOT NULL THEN 'cd' \
WHEN {alias}.sample_rate_hz>=44100 THEN 'cd' \
ELSE '' END"
);
let mut quality_arms = Vec::new();
for quality in descriptor.quality_tiers() {
quality_arms.push(match quality.as_str() {
"hires" => format!("({tier}) IN ('hires','max')"),
"cd" => format!("({tier})='cd'"),
"lossy" => format!("({tier}) IN ('mp3','lossy')"),
_ => {
return Err(CatalogError::InvalidInput(format!(
"unknown track quality tier {quality}"
)))
}
});
}
predicates.push(format!("({})", quality_arms.join(" OR ")));
}
if let Some(cursor) = cursor {
@@ -2276,6 +2340,12 @@ fn map_album_row(row: &Row<'_>) -> rusqlite::Result<AlbumRowWithCursor> {
source,
native_album_id: row.get(2)?,
source_raw: row.get(3)?,
source_words: row
.get::<_, String>(23)?
.split('\u{1f}')
.filter(|word| !word.is_empty())
.map(str::to_string)
.collect(),
title: row.get(4)?,
artist: row.get(5)?,
all_artists: row.get(6)?,
@@ -2310,7 +2380,26 @@ const ALBUM_COLUMNS: &str = "
am.quality_tier,am.format,am.bit_depth,am.sample_rate_hz,
am.artwork_source,am.artwork_token,am.directory_path,am.folder_count,am.added_at,
am.sort_title,am.sort_artist,
CASE WHEN am.year IS NULL THEN 1 ELSE 0 END,COALESCE(am.year,0)";
CASE WHEN am.year IS NULL THEN 1 ELSE 0 END,COALESCE(am.year,0),
COALESCE((
SELECT group_concat(source_word,char(31))
FROM (
SELECT DISTINCT
CASE WHEN TRIM(t_sources.source_raw) != ''
THEN LOWER(TRIM(t_sources.source_raw))
ELSE sc_sources.source_kind END AS source_word,
CASE sc_sources.source_kind
WHEN 'local' THEN 0 WHEN 'offline' THEN 1
WHEN 'plex' THEN 2 WHEN 'jellyfin' THEN 3 ELSE 4 END
AS source_order
FROM source_copies sc_sources
JOIN tracks t_sources
ON t_sources.source_copy_id=sc_sources.source_copy_id
WHERE sc_sources.edition_id=am.edition_id
AND t_sources.available=1
ORDER BY source_order,source_word
)
),'')";
struct ArtistQueryParts {
from_sql: String,
+242 -12
View File
@@ -1,10 +1,11 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use rusqlite::types::ValueRef;
use rusqlite::{params, Connection, OpenFlags, Row};
use serde::Deserialize;
use crate::{
ActiveCatalog, ArtistCredit, BootstrapBatch, BootstrapLayout, BootstrapOutcome,
@@ -99,9 +100,10 @@ pub fn bootstrap_legacy_caches_at_with_progress(
}
let specs = discover_legacy_sources_at(locations)?;
let sidecars = locations.catalog_dir.join("metadata_sidecars.db");
let mut readers = specs
.into_iter()
.map(LegacyReader::open)
.map(|spec| LegacyReader::open(spec, &sidecars))
.collect::<Result<Vec<_>>>()?;
let probes = readers
.iter()
@@ -110,8 +112,11 @@ pub fn bootstrap_legacy_caches_at_with_progress(
let (mut session, _) = layout.prepare(&probes, None)?;
let mut resumed_rows = 0_u64;
let mut committed_rows = 0_u64;
let overall_rows_total = probes.iter().map(|probe| probe.row_count).sum::<u64>();
let source_count = readers.len();
let mut overall_rows_before_source = 0_u64;
for reader in &mut readers {
for (source_offset, reader) in readers.iter_mut().enumerate() {
let mut checkpoint = session.checkpoint(&reader.spec.source)?;
if let Some(saved) = &checkpoint {
if saved.checkpoint_version != reader.probe.snapshot_version
@@ -128,6 +133,8 @@ pub fn bootstrap_legacy_caches_at_with_progress(
.as_ref()
.is_some_and(|saved| saved.complete && saved.checkpoint_rows == reader.probe.row_count)
{
overall_rows_before_source =
overall_rows_before_source.saturating_add(reader.probe.row_count);
continue;
}
@@ -150,6 +157,12 @@ pub fn bootstrap_legacy_caches_at_with_progress(
generation: session.generation(),
source: reader.spec.source.clone(),
committed_rows: saved.checkpoint_rows,
source_rows_total: reader.probe.row_count,
overall_rows_written: overall_rows_before_source
.saturating_add(saved.checkpoint_rows.min(reader.probe.row_count)),
overall_rows_total,
source_index: source_offset + 1,
source_count,
checkpoint_cursor: cursor.clone(),
source_complete: saved.complete,
});
@@ -159,6 +172,8 @@ pub fn bootstrap_legacy_caches_at_with_progress(
std::thread::yield_now();
std::thread::sleep(Duration::from_millis(1));
}
overall_rows_before_source =
overall_rows_before_source.saturating_add(reader.probe.row_count);
}
if cancelled.load(Ordering::Acquire) {
@@ -211,9 +226,10 @@ pub fn reconcile_legacy_caches_at_with_progress(
}
};
let specs = discover_legacy_sources_at(locations)?;
let sidecars = locations.catalog_dir.join("metadata_sidecars.db");
let mut readers = specs
.into_iter()
.map(LegacyReader::open)
.map(|spec| LegacyReader::open(spec, &sidecars))
.collect::<Result<Vec<_>>>()?;
let probes = readers
.iter()
@@ -247,9 +263,16 @@ pub fn reconcile_legacy_caches_at_with_progress(
let (mut session, _) = layout.prepare_projection(&probes, None)?;
let mut resumed_rows = 0_u64;
let mut committed_rows = 0_u64;
for reader in readers
let overall_rows_total = changed_probes
.iter()
.map(|probe| probe.row_count)
.sum::<u64>();
let source_count = changed_probes.len();
let mut overall_rows_before_source = 0_u64;
for (source_offset, reader) in readers
.iter_mut()
.filter(|reader| changed_keys.contains(&reader.spec.source))
.enumerate()
{
let checkpoint = session.checkpoint(&reader.spec.source)?;
let mut cursor = if let Some(checkpoint) = checkpoint {
@@ -257,6 +280,8 @@ pub fn reconcile_legacy_caches_at_with_progress(
&& checkpoint.checkpoint_version == reader.probe.snapshot_version
&& checkpoint.checkpoint_rows == reader.probe.row_count
{
overall_rows_before_source =
overall_rows_before_source.saturating_add(reader.probe.row_count);
continue;
} else if checkpoint.checkpoint_version == reader.probe.snapshot_version
&& !checkpoint.complete
@@ -296,6 +321,12 @@ pub fn reconcile_legacy_caches_at_with_progress(
generation: session.generation(),
source: reader.spec.source.clone(),
rows_written: saved.checkpoint_rows,
source_rows_total: reader.probe.row_count,
overall_rows_written: overall_rows_before_source
.saturating_add(saved.checkpoint_rows.min(reader.probe.row_count)),
overall_rows_total,
source_index: source_offset + 1,
source_count,
checkpoint_cursor: cursor.clone(),
source_complete: saved.complete,
prune_authorized: saved.complete,
@@ -306,6 +337,8 @@ pub fn reconcile_legacy_caches_at_with_progress(
std::thread::yield_now();
std::thread::sleep(Duration::from_millis(1));
}
overall_rows_before_source =
overall_rows_before_source.saturating_add(reader.probe.row_count);
}
let stats = session.stats()?;
let manifest = session.activate(&changed_probes)?;
@@ -425,10 +458,11 @@ struct LegacyReader {
spec: LegacySourceSpec,
conn: Connection,
probe: SourceProbe,
overrides: RemoteOverrides,
}
impl LegacyReader {
fn open(spec: LegacySourceSpec) -> Result<Self> {
fn open(spec: LegacySourceSpec, sidecar_path: &Path) -> Result<Self> {
let conn = open_read_only(&spec.database_path)?;
conn.execute_batch("PRAGMA query_only=ON; BEGIN;")?;
let quick_check: String = conn.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
@@ -437,8 +471,11 @@ impl LegacyReader {
let schema_version: i64 = conn.query_row("PRAGMA schema_version", [], |row| row.get(0))?;
let (row_count, maximum_id, maximum_updated) = source_summary(&conn, &spec)?;
let file_epoch = sqlite_file_epoch(&spec.database_path)?;
let snapshot_version =
format!("v1:{schema_version}:{page_count}:{row_count}:{maximum_id}:{maximum_updated}:{file_epoch}");
let overrides = RemoteOverrides::load(sidecar_path, &spec.source)?;
let snapshot_version = format!(
"v2:{schema_version}:{page_count}:{row_count}:{maximum_id}:{maximum_updated}:{file_epoch}:{}",
overrides.revision
);
let probe = SourceProbe {
source: spec.source.clone(),
source_path: spec.database_path.clone(),
@@ -447,7 +484,12 @@ impl LegacyReader {
page_bytes: (page_size.max(0) as u64).saturating_mul(page_count.max(0) as u64),
integrity_ok: quick_check == "ok",
};
Ok(Self { spec, conn, probe })
Ok(Self {
spec,
conn,
probe,
overrides,
})
}
fn read_batch(&self, cursor: &str) -> Result<BootstrapBatch> {
@@ -463,6 +505,9 @@ impl LegacyReader {
LegacyTable::Plex => read_plex(&self.conn, &self.spec, after)?,
LegacyTable::Remote => read_remote(&self.conn, &self.spec, after)?,
};
for (_, track) in &mut tracks {
self.overrides.apply(track);
}
let complete = tracks.len() <= BOOTSTRAP_BATCH_ROWS;
tracks.truncate(BOOTSTRAP_BATCH_ROWS);
let next_cursor = tracks
@@ -480,6 +525,162 @@ impl LegacyReader {
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SidecarAlbum {
album_title: Option<String>,
album_artist: Option<String>,
year: Option<u32>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SidecarTrack {
file_path: String,
title: Option<String>,
disc_number: Option<u32>,
track_number: Option<u32>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SidecarExtendedAlbum {
artwork_path: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SidecarExtendedTrack {
file_path: String,
artist_credit: String,
musicbrainz_recording_id: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SidecarDocument {
#[serde(default)]
album: SidecarAlbum,
#[serde(default)]
tracks: Vec<SidecarTrack>,
extended_album: Option<SidecarExtendedAlbum>,
#[serde(default)]
extended_tracks: Vec<SidecarExtendedTrack>,
}
#[derive(Default)]
struct RemoteOverrides {
by_album: HashMap<String, SidecarDocument>,
revision: String,
}
impl RemoteOverrides {
fn load(path: &Path, source: &SourceKey) -> Result<Self> {
if matches!(source.source, SourceKind::Local | SourceKind::Offline) || !path.is_file() {
return Ok(Self::default());
}
let conn = open_read_only(path)?;
if !table_exists(&conn, "remote_album_tag_sidecars")? {
return Ok(Self::default());
}
let mut stmt = conn.prepare(
"SELECT album_id,payload_json,updated_at
FROM remote_album_tag_sidecars
WHERE source=?1 AND source_instance=?2",
)?;
let rows = stmt.query_map(
params![source.source.as_str(), source.source_instance],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
))
},
)?;
let mut by_album = HashMap::new();
let mut count = 0_u64;
let mut maximum_updated = 0_i64;
for row in rows {
let (album_id, json, updated_at) = row?;
count = count.saturating_add(1);
maximum_updated = maximum_updated.max(updated_at);
if let Ok(document) = serde_json::from_str(&json) {
by_album.insert(album_id, document);
}
}
let file_epoch = sqlite_file_epoch(path)?;
Ok(Self {
by_album,
revision: format!("{count}:{maximum_updated}:{file_epoch}"),
})
}
fn apply(&self, track: &mut ProjectedTrack) {
let Some(album_id) = track.native_album_id.as_deref() else {
return;
};
let Some(sidecar) = self.by_album.get(album_id) else {
return;
};
if let Some(title) = sidecar
.album
.album_title
.as_deref()
.map(str::trim)
.filter(|title| !title.is_empty())
{
track.album = title.to_string();
}
if let Some(artist) = sidecar.album.album_artist.as_deref() {
track.album_artist = artist.trim().to_string();
}
if let Some(year) = sidecar.album.year {
track.year = (year != 0).then_some(year);
}
if let Some(path) = sidecar
.extended_album
.as_ref()
.and_then(|album| album.artwork_path.as_deref())
.map(str::trim)
.filter(|path| !path.is_empty())
{
track.artwork_token = Some(format!("{}{path}", crate::SIDECAR_LOCAL_ART_PREFIX));
}
if let Some(row) = sidecar
.tracks
.iter()
.find(|row| row.file_path == track.track_ref.native_id)
{
if let Some(title) = row
.title
.as_deref()
.map(str::trim)
.filter(|title| !title.is_empty())
{
track.title = title.to_string();
}
if let Some(number) = row.disc_number {
track.disc_number = (number != 0).then_some(number);
}
if let Some(number) = row.track_number {
track.track_number = (number != 0).then_some(number);
}
}
if let Some(row) = sidecar
.extended_tracks
.iter()
.find(|row| row.file_path == track.track_ref.native_id)
{
if !row.artist_credit.trim().is_empty() {
track.artist = row.artist_credit.trim().to_string();
}
track.musicbrainz_recording_id = row.musicbrainz_recording_id.clone();
}
track.credits = credits(&track.artist, &track.album_artist);
}
}
type LegacyRow = (i64, ProjectedTrack);
fn legacy_cursor(row: &LegacyRow) -> i64 {
@@ -552,6 +753,25 @@ fn read_plex(conn: &Connection, spec: &LegacySourceSpec, after: i64) -> Result<V
&["rating_key", "title", "artist", "album", "duration_ms"],
"plex_cache_tracks",
)?;
let item_art = optional_column(&columns, "artwork_path", "NULL");
let collection_art = optional_column(&columns, "collection_artwork_path", "NULL");
let metadata_art = if columns.contains("parent_rating_key")
&& table_exists(conn, "plex_cache_album_artwork")?
{
"(SELECT NULLIF(album_art.artwork_path,'')
FROM plex_cache_album_artwork album_art
WHERE album_art.server_id =
COALESCE(NULLIF(plex_cache_tracks.server_id,''),'default')
AND album_art.parent_rating_key = plex_cache_tracks.parent_rating_key)"
.to_string()
} else {
"NULL".to_string()
};
// Track art keeps its existing precedence on track surfaces. Parent art
// and the album-metadata repair are fallback-only, so filling a missing
// card cannot replace a legitimate disc/track-specific image.
let effective_art =
format!("COALESCE(NULLIF({item_art},''),NULLIF({collection_art},''),{metadata_art})");
let sql = format!(
"SELECT rowid, rating_key, title, COALESCE(artist,''), COALESCE(album,''),
COALESCE(duration_ms,0), {year}, {disc}, {track}, {format}, {depth},
@@ -567,7 +787,7 @@ fn read_plex(conn: &Connection, spec: &LegacySourceSpec, after: i64) -> Result<V
format = format_expression(&columns),
depth = optional_column(&columns, "bit_depth", "NULL"),
rate = optional_column(&columns, "sampling_rate_hz", "NULL"),
art = optional_column(&columns, "artwork_path", "NULL"),
art = effective_art,
updated = optional_column(&columns, "updated_at", "0"),
album_id = if columns.contains("parent_rating_key") {
"parent_rating_key".to_string()
@@ -691,6 +911,14 @@ fn map_remote_like(
}
fn source_summary(conn: &Connection, spec: &LegacySourceSpec) -> Result<(u64, i64, i64)> {
let plex_artwork_updated =
if spec.table == LegacyTable::Plex && table_exists(conn, "plex_cache_album_artwork")? {
"(SELECT COALESCE(MAX(checked_at),0)
FROM plex_cache_album_artwork
WHERE server_id=?1)"
} else {
"0"
};
let (sql, params) = match spec.table {
LegacyTable::Local => (
"SELECT COUNT(*), COALESCE(MAX(id),0), COALESCE(MAX(indexed_at),0)
@@ -699,10 +927,12 @@ fn source_summary(conn: &Connection, spec: &LegacySourceSpec) -> Result<(u64, i6
Vec::new(),
),
LegacyTable::Plex => (
"SELECT COUNT(*), COALESCE(MAX(rowid),0), COALESCE(MAX(updated_at),0)
format!(
"SELECT COUNT(*), COALESCE(MAX(rowid),0),
MAX(COALESCE(MAX(updated_at),0),{plex_artwork_updated})
FROM plex_cache_tracks
WHERE COALESCE(NULLIF(server_id,''),'default') = ?1"
.to_string(),
),
vec![spec.source.source_instance.clone()],
),
LegacyTable::Remote => (
+5
View File
@@ -36,6 +36,11 @@ pub use projection::{
};
pub use schema::SCHEMA_VERSION;
/// Artwork stored locally by a media-server metadata sidecar. Qt strips this
/// marker and routes the remainder through the Local artwork provider instead
/// of handing a filesystem path back to Plex/Jellyfin/Subsonic as a token.
pub const SIDECAR_LOCAL_ART_PREFIX: &str = "qbz-local-art:";
#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
#[error("catalog sqlite error: {0}")]
+6
View File
@@ -378,6 +378,12 @@ pub struct AlbumRecord {
pub source: SourceKind,
pub native_album_id: String,
pub source_raw: String,
/// Distinct presentation source words contributed by every physical copy
/// of this edition. Unlike `source`, which selects one copy for routing,
/// this list is for multi-source badges and capabilities such as local
/// favorites. Raw words (for example `qobuz_purchase` or a Subsonic
/// brand) are preserved.
pub source_words: Vec<String>,
pub title: String,
pub artist: String,
pub all_artists: String,
@@ -23,6 +23,12 @@ pub struct ProjectionProgress {
pub generation: u64,
pub source: SourceKey,
pub rows_written: u64,
pub source_rows_total: u64,
pub overall_rows_written: u64,
pub overall_rows_total: u64,
/// One-based position among sources whose authoritative snapshot changed.
pub source_index: usize,
pub source_count: usize,
pub checkpoint_cursor: String,
pub source_complete: bool,
pub prune_authorized: bool,
@@ -377,6 +377,148 @@ fn legacy_catch_up_detects_authoritative_updates_and_then_is_a_no_op() {
assert_eq!(catalog.stats().unwrap().track_count, 3);
}
#[test]
fn remote_metadata_sidecar_overlays_bootstrap_and_triggers_catch_up() {
let temp = tempdir().unwrap();
let remote_path = temp.path().join("remote_cache.db");
Connection::open(&remote_path)
.unwrap()
.execute_batch(
"CREATE TABLE remote_cache_tracks (
id INTEGER PRIMARY KEY, source TEXT NOT NULL, item_id TEXT NOT NULL,
server_id TEXT, title TEXT, artist TEXT, album_artist TEXT,
album TEXT, duration_ms INTEGER, codec TEXT, container TEXT,
bit_depth INTEGER, sample_rate_hz INTEGER, artwork_token TEXT,
updated_at INTEGER, year INTEGER, disc_number INTEGER,
track_number INTEGER, album_id TEXT
);
INSERT INTO remote_cache_tracks VALUES
(1,'jellyfin','track-1','server-a','Original title','Original artist',
'Original album artist','Original album',220000,'flac','flac',24,
96000,'remote-art',30,2022,1,1,'album-9');",
)
.unwrap();
let sidecar_path = temp.path().join("metadata_sidecars.db");
let sidecar = Connection::open(&sidecar_path).unwrap();
sidecar
.execute_batch(
"CREATE TABLE remote_album_tag_sidecars (
source TEXT NOT NULL, source_instance TEXT NOT NULL,
album_id TEXT NOT NULL, payload_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY(source,source_instance,album_id)
);",
)
.unwrap();
let payload = serde_json::json!({
"version": 2,
"updatedAt": 100,
"album": {
"albumTitle": "Edited album",
"albumArtist": "Edited album artist",
"year": 2025,
"genre": "Progressive rock",
"catalogNumber": "CAT-9"
},
"tracks": [{
"filePath": "track-1",
"cueStartSecs": null,
"title": "Edited title",
"discNumber": 2,
"trackNumber": 7
}],
"extendedAlbum": {
"albumArtists": ["Edited album artist"],
"compilation": false,
"musicbrainzReleaseId": "release-9",
"musicbrainzReleaseGroupId": "group-9",
"musicbrainzAlbumArtistIds": ["artist-9"],
"discogsReleaseId": "99",
"artworkPath": "/cache/edited-cover.jpg"
},
"extendedTracks": [{
"filePath": "track-1",
"cueStartSecs": null,
"artistCredit": "Edited track artist",
"artists": ["Edited track artist"],
"composers": ["Composer"],
"performers": ["Player (guitar)"],
"musicbrainzRecordingId": "recording-1",
"musicbrainzTrackId": "mb-track-1",
"musicbrainzArtistIds": ["artist-1"]
}]
})
.to_string();
sidecar
.execute(
"INSERT INTO remote_album_tag_sidecars VALUES
('jellyfin','server-a','album-9',?1,100)",
[&payload],
)
.unwrap();
drop(sidecar);
assert!(matches!(
bootstrap_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(),
BootstrapOutcome::Activated {
generation: 1,
track_count: 1,
..
}
));
let layout = BootstrapLayout::new(temp.path());
let track_ref = TrackRef {
source: SourceKind::Jellyfin,
source_instance: "server-a".to_string(),
native_id: "track-1".to_string(),
};
let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else {
panic!("sidecar bootstrap did not activate")
};
let projected = catalog.resolve(&track_ref).unwrap().unwrap();
assert_eq!(projected.title, "Edited title");
assert_eq!(projected.artist, "Edited track artist");
assert_eq!(projected.album, "Edited album");
assert_eq!(projected.album_artist, "Edited album artist");
assert_eq!(projected.year, Some(2025));
assert_eq!(projected.disc_number, Some(2));
assert_eq!(projected.track_number, Some(7));
assert_eq!(
projected.artwork_token.as_deref(),
Some("qbz-local-art:/cache/edited-cover.jpg")
);
drop(catalog);
let changed = payload.replace("Edited title", "Changed from sidecar");
Connection::open(&sidecar_path)
.unwrap()
.execute(
"UPDATE remote_album_tag_sidecars
SET payload_json=?1,updated_at=101
WHERE source='jellyfin' AND source_instance='server-a'
AND album_id='album-9'",
[&changed],
)
.unwrap();
assert!(matches!(
reconcile_legacy_caches(temp.path(), &AtomicBool::new(false)).unwrap(),
ProjectionOutcome::Activated {
generation: 2,
track_count: 1,
changed_sources: 1,
..
}
));
let ActiveCatalog::Ready { catalog, .. } = layout.open_active() else {
panic!("sidecar catch-up did not activate")
};
assert_eq!(
catalog.resolve(&track_ref).unwrap().unwrap().title,
"Changed from sidecar"
);
}
fn create_local(path: &std::path::Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
+112 -4
View File
@@ -841,6 +841,52 @@ fn identical_native_ids_from_distinct_sources_never_collide() {
assert_eq!(resolved.source_raw, "qobuz_purchase");
}
#[test]
fn album_records_preserve_their_physical_source_words() {
let mut catalog = Catalog::open_in_memory(1).unwrap();
let mut local = projected(1, SourceKind::Local);
local.source_raw = "user".to_string();
let mut jellyfin = projected(2, SourceKind::Jellyfin);
jellyfin.source_raw = "jellyfin".to_string();
for (source, track) in [
(
SourceKey {
source: SourceKind::Local,
source_instance: source_instance(SourceKind::Local).to_string(),
},
local,
),
(
SourceKey {
source: SourceKind::Jellyfin,
source_instance: source_instance(SourceKind::Jellyfin).to_string(),
},
jellyfin,
),
] {
catalog
.apply_bootstrap_batch(&BootstrapBatch {
source,
snapshot_version: "source-words".to_string(),
expected_cursor: String::new(),
next_cursor: "1".to_string(),
tracks: vec![track],
complete: true,
})
.unwrap();
}
catalog.rebuild_materialized_views().unwrap();
let rows = collect_all_albums(&catalog, &QueryDescriptor::albums(), 20);
assert_eq!(rows.len(), 2);
assert!(rows
.iter()
.any(|row| row.source_words == vec!["user".to_string()]));
assert!(rows
.iter()
.any(|row| row.source_words == vec!["jellyfin".to_string()]));
}
#[test]
fn upsert_retains_identity_and_fts_tracks_updates_and_deletes() {
let mut catalog = Catalog::open_in_memory(1).unwrap();
@@ -1102,6 +1148,71 @@ fn search_source_format_availability_and_group_are_descriptor_scoped() {
));
}
#[test]
fn track_source_bucket_quality_and_other_format_filters_match_the_ui_funnel() {
let mut catalog = Catalog::open_in_memory(1).unwrap();
insert_fixture(&mut catalog, 2_500);
// Downloaded Qobuz copies can be owned by the Local catalog source. The
// UI nevertheless presents them through Offline, based on source_raw.
let mut purchased = projected(9_999, SourceKind::Local);
purchased.track_ref.native_id = "local-qobuz-purchase".to_string();
purchased.source_raw = "qobuz_purchase".to_string();
purchased.available = true;
catalog.upsert_tracks(&[purchased]).unwrap();
let local = QueryDescriptor::tracks().with_source_buckets(vec!["local".to_string()]);
let local_rows = collect_all(&catalog, &local);
assert!(!local_rows.is_empty());
assert!(local_rows.iter().all(|row| {
row.track_ref.source == SourceKind::Local
&& !matches!(row.source_raw.as_str(), "qobuz_download" | "qobuz_purchase")
}));
assert_eq!(
catalog.count_tracks(&local).unwrap(),
local_rows.len() as u64
);
let offline = QueryDescriptor::tracks().with_source_buckets(vec!["offline".to_string()]);
let offline_rows = collect_all(&catalog, &offline);
assert!(offline_rows
.iter()
.any(|row| row.source_raw == "qobuz_purchase"));
assert!(offline_rows.iter().all(|row| {
row.track_ref.source == SourceKind::Offline
|| matches!(row.source_raw.as_str(), "qobuz_download" | "qobuz_purchase")
}));
assert_eq!(
catalog.count_tracks(&offline).unwrap(),
offline_rows.len() as u64
);
let plex_hires_flac = QueryDescriptor::tracks()
.with_source_buckets(vec!["plex".to_string()])
.with_formats(vec!["flac".to_string()])
.with_quality_tiers(vec!["hires".to_string()]);
let plex_rows = collect_all(&catalog, &plex_hires_flac);
assert!(!plex_rows.is_empty());
assert!(plex_rows.iter().all(|row| {
row.track_ref.source == SourceKind::Plex
&& row.format == "flac"
&& row.bit_depth.is_some_and(|depth| depth >= 24)
}));
assert_eq!(
catalog.count_tracks(&plex_hires_flac).unwrap(),
plex_rows.len() as u64
);
let other = QueryDescriptor::tracks().including_other_formats(true);
let other_rows = collect_all(&catalog, &other);
assert!(!other_rows.is_empty());
assert!(other_rows.iter().all(|row| row.format == "dsf"));
assert_eq!(
catalog.count_tracks(&other).unwrap(),
other_rows.len() as u64
);
}
#[test]
fn artist_group_uses_track_artist_globally_across_keyset_pages() {
let mut catalog = Catalog::open_in_memory(1).unwrap();
@@ -1172,10 +1283,7 @@ fn sort_indices_match_the_actual_order_by_without_temp_sort() {
"idx_tracks_default",
"sort_album,sort_artist,disc_sort,track_sort,sort_title,catalog_id",
),
(
"idx_tracks_title_asc",
"sort_title,sort_artist,catalog_id",
),
("idx_tracks_title_asc", "sort_title,sort_artist,catalog_id"),
(
"idx_tracks_title_desc",
"sort_title DESC,sort_artist,catalog_id",
+305 -20
View File
@@ -14,6 +14,10 @@ use serde::{Deserialize, Serialize};
const MAX_PLEX_TRACK_PAGE_BYTES: usize = 32 * 1024 * 1024;
const DEFAULT_PLEX_TRACK_PAGE_SIZE: u64 = 250;
/// Album metadata is LAN-local and cheap, but a missing cover must not turn
/// into one request on every boot forever. Positive and negative results are
/// both rechecked weekly so a cover added later in Plex eventually appears.
pub const PLEX_ALBUM_ARTWORK_MAX_AGE_SECS: u64 = 7 * 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -332,6 +336,18 @@ fn open_plex_cache_db() -> Result<Connection, String> {
status TEXT NOT NULL DEFAULT 'idle',
updated_at INTEGER NOT NULL DEFAULT 0
);
-- `/library/sections/<id>/all?type=10` does not always put
-- `parentThumb` on Track rows, even when the album has a cover. Keep
-- the authoritative album-metadata answer separately: it is scoped by
-- server because Plex rating keys are only server-local identities.
CREATE TABLE IF NOT EXISTS plex_cache_album_artwork (
server_id TEXT NOT NULL,
parent_rating_key TEXT NOT NULL,
artwork_path TEXT,
checked_at INTEGER NOT NULL,
PRIMARY KEY(server_id, parent_rating_key)
);
",
)
.map_err(|e| format!("Failed to initialize Plex cache schema: {}", e))?;
@@ -757,6 +773,29 @@ fn parse_music_sections(xml: &str) -> Vec<PlexMusicSection> {
sections
}
/// The track listing is not an artwork authority: Plex may omit both `thumb`
/// and `parentThumb` there while `/library/metadata/<parentRatingKey>` still
/// exposes the album cover (and that cover can point at a *different* rating
/// key). Read the returned token instead of synthesizing a URL from the album
/// id; the latter is a real 404 for inherited/default Plex artwork.
fn parse_album_artwork_path(xml: &str) -> Option<String> {
let directory_thumb = collect_start_tags(xml, "Directory")
.into_iter()
.find(|tag| get_attr(tag, "type").as_deref() == Some("album"))
.and_then(|tag| get_attr(&tag, "thumb"));
directory_thumb
.or_else(|| {
collect_start_tags(xml, "Image")
.into_iter()
.find(|tag| get_attr(tag, "type").as_deref() == Some("coverPoster"))
.and_then(|tag| get_attr(&tag, "url"))
})
.map(|path| path.trim().to_string())
.filter(|path| {
!path.is_empty() && (path.starts_with("/library/") || path.starts_with("/photo/"))
})
}
fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option<PlexTrack> {
// Plex exposes album release year via parentYear on the Track element
// (same structure that drives the Plex client's year display); fall back
@@ -1131,6 +1170,35 @@ pub async fn plex_get_section_tracks_page(
parse_track_page(&xml, start, page_size)
}
/// Fetch the album-level cover Plex omitted from its flat track listing.
/// `parent_rating_key` is a path segment supplied by the same Plex server; it
/// is still validated before interpolation so malformed cache data cannot
/// escape the metadata endpoint.
pub async fn plex_get_album_artwork_path(
base_url: String,
token: String,
parent_rating_key: String,
) -> Result<Option<String>, String> {
let key = parent_rating_key.trim();
if key.is_empty() || key.chars().any(|ch| matches!(ch, '/' | '?' | '#')) {
return Err("Invalid Plex album rating key".to_string());
}
let client = build_plex_client()?;
let base = normalize_base_url(&base_url);
let url = with_token(&format!("{base}/library/metadata/{key}"), &token);
let xml = client
.get(url)
.send()
.await
.map_err(|e| safe_http_error("Plex album metadata request", &e))?
.error_for_status()
.map_err(|e| safe_http_error("Plex album metadata request", &e))?
.text()
.await
.map_err(|e| safe_http_error("Failed to read Plex album metadata response", &e))?;
Ok(parse_album_artwork_path(&xml))
}
pub async fn plex_get_track_metadata(
base_url: String,
token: String,
@@ -1337,6 +1405,111 @@ pub fn plex_cache_save_sections(
Ok(sections.len())
}
/// Collection artwork as seen by every cache reader. Direct `parentThumb`
/// remains authoritative; the metadata table only fills the hole left by the
/// flat track endpoint. Keep this expression centralized so Albums, Tracks,
/// playlists and the source registry cannot disagree about the same row.
const EFFECTIVE_COLLECTION_ARTWORK_SQL: &str =
"COALESCE(NULLIF(plex_cache_tracks.collection_artwork_path,''),
(SELECT NULLIF(album_art.artwork_path,'')
FROM plex_cache_album_artwork album_art
WHERE album_art.server_id =
COALESCE(NULLIF(plex_cache_tracks.server_id,''),'default')
AND album_art.parent_rating_key = plex_cache_tracks.parent_rating_key))";
fn album_artwork_candidates_in(
conn: &Connection,
server_id: &str,
stale_before: i64,
) -> Result<Vec<String>, String> {
let mut stmt = conn
.prepare(
"SELECT DISTINCT tracks.parent_rating_key
FROM plex_cache_tracks tracks
LEFT JOIN plex_cache_album_artwork album_art
ON album_art.server_id = COALESCE(NULLIF(tracks.server_id,''),'default')
AND album_art.parent_rating_key = tracks.parent_rating_key
WHERE COALESCE(NULLIF(tracks.server_id,''),'default') = ?1
AND COALESCE(tracks.parent_rating_key,'') != ''
AND COALESCE(tracks.artwork_path,'') = ''
AND COALESCE(tracks.collection_artwork_path,'') = ''
AND (album_art.parent_rating_key IS NULL OR album_art.checked_at <= ?2)
ORDER BY tracks.parent_rating_key",
)
.map_err(|e| format!("Failed to prepare Plex album artwork candidates: {e}"))?;
let rows = stmt
.query_map(params![server_id, stale_before], |row| {
row.get::<_, String>(0)
})
.map_err(|e| format!("Failed to query Plex album artwork candidates: {e}"))?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| format!("Failed to read Plex album artwork candidate: {e}"))
}
/// Album ids whose track rows omit both item and parent art, excluding recent
/// positive *and* negative metadata checks.
pub fn plex_cache_album_artwork_candidates(
server_id: Option<String>,
) -> Result<Vec<String>, String> {
let conn = open_plex_cache_db()?;
let server_id = server_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default");
let stale_before = now_epoch_secs()
.saturating_sub(i64::try_from(PLEX_ALBUM_ARTWORK_MAX_AGE_SECS).unwrap_or(i64::MAX));
album_artwork_candidates_in(&conn, server_id, stale_before)
}
fn save_album_artwork_in(
conn: &mut Connection,
server_id: &str,
rows: &[(String, Option<String>)],
checked_at: i64,
) -> Result<usize, String> {
if rows.is_empty() {
return Ok(0);
}
let tx = conn
.transaction()
.map_err(|e| format!("Failed to start Plex album artwork transaction: {e}"))?;
let mut saved = 0usize;
for (parent_rating_key, artwork_path) in rows {
saved = saved.saturating_add(
tx.execute(
"INSERT INTO plex_cache_album_artwork(
server_id,parent_rating_key,artwork_path,checked_at
) VALUES (?1,?2,?3,?4)
ON CONFLICT(server_id,parent_rating_key) DO UPDATE SET
artwork_path=excluded.artwork_path,
checked_at=excluded.checked_at",
params![server_id, parent_rating_key, artwork_path, checked_at],
)
.map_err(|e| format!("Failed to save Plex album artwork: {e}"))?,
);
}
tx.commit()
.map_err(|e| format!("Failed to commit Plex album artwork: {e}"))?;
Ok(saved)
}
/// Persist successful metadata responses. `None` is intentional negative
/// caching, not an error: it prevents a genuinely coverless album from being
/// fetched on every application start.
pub fn plex_cache_save_album_artwork(
server_id: Option<String>,
rows: Vec<(String, Option<String>)>,
) -> Result<usize, String> {
let mut conn = open_plex_cache_db()?;
let server_id = server_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default");
save_album_artwork_in(&mut conn, server_id, &rows, now_epoch_secs())
}
pub fn plex_cache_get_tracks(
section_key: Option<String>,
limit: Option<u32>,
@@ -1347,15 +1520,15 @@ pub fn plex_cache_get_tracks(
if let Some(section) = section_key {
let mut stmt = conn
.prepare(
.prepare(&format!(
"SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container,
codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number,
year, genre, genres_json, parent_rating_key, collection_artwork_path
year, genre, genres_json, parent_rating_key, {EFFECTIVE_COLLECTION_ARTWORK_SQL}
FROM plex_cache_tracks
WHERE section_key = ?1
ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, disc_number, track_number, title COLLATE NOCASE
LIMIT ?2",
)
))
.map_err(|e| format!("Failed to prepare Plex cache tracks query: {}", e))?;
let rows = stmt
.query_map(params![section, max], |row| {
@@ -1395,14 +1568,14 @@ pub fn plex_cache_get_tracks(
}
} else {
let mut stmt = conn
.prepare(
.prepare(&format!(
"SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container,
codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number,
year, genre, genres_json, parent_rating_key, collection_artwork_path
year, genre, genres_json, parent_rating_key, {EFFECTIVE_COLLECTION_ARTWORK_SQL}
FROM plex_cache_tracks
ORDER BY updated_at DESC
LIMIT ?1",
)
))
.map_err(|e| format!("Failed to prepare Plex cache tracks query: {}", e))?;
let rows = stmt
.query_map(params![max], |row| {
@@ -1464,10 +1637,9 @@ pub fn plex_cache_get_tracks_by_keys(rating_keys: &[String]) -> Result<Vec<PlexT
let sql = format!(
"SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container,
codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number,
year, genre, genres_json, parent_rating_key, collection_artwork_path
year, genre, genres_json, parent_rating_key, {EFFECTIVE_COLLECTION_ARTWORK_SQL}
FROM plex_cache_tracks
WHERE rating_key IN ({})",
placeholders
WHERE rating_key IN ({placeholders})"
);
let mut stmt = conn
.prepare(&sql)
@@ -1516,11 +1688,11 @@ pub fn plex_cache_get_tracks_by_keys(rating_keys: &[String]) -> Result<Vec<PlexT
pub fn plex_cache_get_albums() -> Result<Vec<PlexCachedAlbum>, String> {
let conn = open_plex_cache_db()?;
let mut stmt = conn
.prepare(
"SELECT artist, album, duration_ms, artwork_path, collection_artwork_path,
.prepare(&format!(
"SELECT artist, album, duration_ms, artwork_path, {EFFECTIVE_COLLECTION_ARTWORK_SQL},
container, sampling_rate_hz, bit_depth, year, genre
FROM plex_cache_tracks",
)
))
.map_err(|e| {
format!(
"Failed to prepare Plex cache album aggregation query: {}",
@@ -1671,9 +1843,10 @@ pub fn plex_cache_count_tracks() -> Result<usize, String> {
pub fn plex_cache_get_artists() -> Result<Vec<PlexCachedArtist>, String> {
let conn = open_plex_cache_db()?;
let mut stmt = conn
.prepare(
"SELECT artist, album, artwork_path, collection_artwork_path FROM plex_cache_tracks",
)
.prepare(&format!(
"SELECT artist, album, artwork_path, {EFFECTIVE_COLLECTION_ARTWORK_SQL}
FROM plex_cache_tracks"
))
.map_err(|e| {
format!(
"Failed to prepare Plex cache artist aggregation query: {}",
@@ -1772,7 +1945,7 @@ fn plex_cache_get_album_tracks_in(
let sql = format!(
"SELECT rating_key, title, artist, album, duration_ms, container, bit_depth,
sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key,
collection_artwork_path
{EFFECTIVE_COLLECTION_ARTWORK_SQL}
FROM plex_cache_tracks
WHERE {column} = ?1
ORDER BY disc_number, track_number, title COLLATE NOCASE"
@@ -1936,7 +2109,7 @@ fn plex_cache_search_tracks_page_filtered_in(
.prepare(&format!(
"SELECT rating_key, title, artist, album, duration_ms, container, bit_depth,
sampling_rate_hz, artwork_path, track_number, disc_number, year,
collection_artwork_path,
{EFFECTIVE_COLLECTION_ARTWORK_SQL},
TRIM(title) AS sort_title,
COALESCE(NULLIF(TRIM(artist), ''), 'Unknown Artist') AS sort_artist,
CASE
@@ -2120,10 +2293,9 @@ pub fn plex_cache_get_cached_tracks_by_keys(
let sql = format!(
"SELECT rating_key, title, artist, album, duration_ms, container, bit_depth,
sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key,
collection_artwork_path
{EFFECTIVE_COLLECTION_ARTWORK_SQL}
FROM plex_cache_tracks
WHERE rating_key IN ({})",
placeholders
WHERE rating_key IN ({placeholders})"
);
let mut stmt = conn
.prepare(&sql)
@@ -2755,6 +2927,8 @@ pub fn plex_cache_clear() -> Result<(), String> {
.map_err(|e| format!("Failed to clear Plex cache sections: {}", e))?;
tx.execute("DELETE FROM plex_cache_section_sync", [])
.map_err(|e| format!("Failed to clear Plex sync state: {}", e))?;
tx.execute("DELETE FROM plex_cache_album_artwork", [])
.map_err(|e| format!("Failed to clear Plex album artwork cache: {}", e))?;
tx.commit()
.map_err(|e| format!("Failed to commit Plex cache clear: {}", e))?;
Ok(())
@@ -2779,6 +2953,8 @@ pub fn plex_cache_prune_sections(keep: &[String]) -> Result<usize, String> {
.map_err(|e| format!("Failed to prune Plex cache tracks: {}", e))?;
tx.execute("DELETE FROM plex_cache_section_sync", [])
.map_err(|e| format!("Failed to prune Plex sync state: {}", e))?;
tx.execute("DELETE FROM plex_cache_album_artwork", [])
.map_err(|e| format!("Failed to prune Plex album artwork cache: {}", e))?;
tx.commit()
.map_err(|e| format!("Failed to commit Plex section selection prune: {}", e))?;
return Ok(removed);
@@ -2795,6 +2971,17 @@ pub fn plex_cache_prune_sections(keep: &[String]) -> Result<usize, String> {
.map_err(|e| format!("Failed to prune Plex cache tracks: {}", e))?;
tx.execute(&state_sql, params.as_slice())
.map_err(|e| format!("Failed to prune Plex sync state: {}", e))?;
tx.execute(
"DELETE FROM plex_cache_album_artwork
WHERE NOT EXISTS (
SELECT 1 FROM plex_cache_tracks tracks
WHERE COALESCE(NULLIF(tracks.server_id,''),'default') =
plex_cache_album_artwork.server_id
AND tracks.parent_rating_key = plex_cache_album_artwork.parent_rating_key
)",
[],
)
.map_err(|e| format!("Failed to prune orphan Plex album artwork: {}", e))?;
tx.commit()
.map_err(|e| format!("Failed to commit Plex section selection prune: {}", e))?;
Ok(removed)
@@ -2972,8 +3159,14 @@ mod tests {
rating_key TEXT PRIMARY KEY, title TEXT NOT NULL, artist TEXT,
album TEXT, duration_ms INTEGER, container TEXT, bit_depth INTEGER,
sampling_rate_hz INTEGER, artwork_path TEXT, collection_artwork_path TEXT,
server_id TEXT, parent_rating_key TEXT,
track_number INTEGER,
disc_number INTEGER, year INTEGER
);
CREATE TABLE plex_cache_album_artwork (
server_id TEXT NOT NULL, parent_rating_key TEXT NOT NULL,
artwork_path TEXT, checked_at INTEGER NOT NULL,
PRIMARY KEY(server_id,parent_rating_key)
);",
)
.unwrap();
@@ -3033,6 +3226,13 @@ mod tests {
sync_generation INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_sync_tracks_section ON plex_cache_tracks(section_key);
CREATE TABLE plex_cache_album_artwork (
server_id TEXT NOT NULL,
parent_rating_key TEXT NOT NULL,
artwork_path TEXT,
checked_at INTEGER NOT NULL,
PRIMARY KEY(server_id,parent_rating_key)
);
CREATE TABLE plex_cache_section_sync (
section_key TEXT PRIMARY KEY,
server_id TEXT,
@@ -3110,6 +3310,85 @@ mod tests {
assert_eq!(sections[0].key, "1");
}
#[test]
fn album_artwork_uses_the_metadata_token_instead_of_inventing_one() {
let xml = r#"<MediaContainer size="1">
<Directory ratingKey="60335" type="album" title="1994"
thumb="/library/metadata/60320/thumb/1665204207">
<Image type="coverPoster" url="/library/metadata/60320/thumb/1665204207"/>
</Directory>
</MediaContainer>"#;
assert_eq!(
parse_album_artwork_path(xml).as_deref(),
Some("/library/metadata/60320/thumb/1665204207")
);
assert_ne!(
parse_album_artwork_path(xml).as_deref(),
Some("/library/metadata/60335/thumb")
);
}
#[test]
fn album_artwork_cache_is_server_scoped_negative_cached_and_resolved() {
let mut conn = sync_db();
conn.execute(
"INSERT INTO plex_cache_tracks(
rating_key,section_key,server_id,title,artist,album,parent_rating_key,
updated_at,sync_generation
) VALUES ('track-1','music','server-a','Song','Artist','Album','album-1',1,1)",
[],
)
.unwrap();
assert_eq!(
album_artwork_candidates_in(&conn, "server-a", 100).unwrap(),
["album-1"]
);
assert!(album_artwork_candidates_in(&conn, "server-b", 100)
.unwrap()
.is_empty());
save_album_artwork_in(
&mut conn,
"server-a",
&[(
"album-1".into(),
Some("/library/metadata/cover/thumb/7".into()),
)],
200,
)
.unwrap();
assert!(album_artwork_candidates_in(&conn, "server-a", 199)
.unwrap()
.is_empty());
let effective: Option<String> = conn
.query_row(
&format!(
"SELECT {EFFECTIVE_COLLECTION_ARTWORK_SQL}
FROM plex_cache_tracks WHERE rating_key='track-1'"
),
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
effective.as_deref(),
Some("/library/metadata/cover/thumb/7")
);
save_album_artwork_in(&mut conn, "server-a", &[("album-1".into(), None)], 300).unwrap();
let effective: Option<String> = conn
.query_row(
&format!(
"SELECT {EFFECTIVE_COLLECTION_ARTWORK_SQL}
FROM plex_cache_tracks WHERE rating_key='track-1'"
),
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(effective, None);
}
#[test]
fn parses_tracks_with_stream_audio_metadata() {
let xml = r#"<MediaContainer>
@@ -3475,9 +3754,15 @@ mod tests {
rating_key TEXT PRIMARY KEY, title TEXT NOT NULL, artist TEXT,
album TEXT, duration_ms INTEGER, container TEXT, bit_depth INTEGER,
sampling_rate_hz INTEGER, artwork_path TEXT, collection_artwork_path TEXT,
server_id TEXT,
track_number INTEGER,
disc_number INTEGER, album_key TEXT, parent_rating_key TEXT
);
CREATE TABLE plex_cache_album_artwork (
server_id TEXT NOT NULL, parent_rating_key TEXT NOT NULL,
artwork_path TEXT, checked_at INTEGER NOT NULL,
PRIMARY KEY(server_id,parent_rating_key)
);
INSERT INTO plex_cache_tracks
(rating_key,title,artist,album,duration_ms,container,bit_depth,
sampling_rate_hz,track_number,disc_number,album_key,parent_rating_key)
+9 -1
View File
@@ -125,7 +125,11 @@ fn find_qsb() -> Option<std::path::PathBuf> {
}
// Windows: `qsb.exe`, and `Path::is_file()` does NOT append `.exe` (Rust's
// std only does that for a full path handed to `Command`), so probe both.
let names: &[&str] = if cfg!(windows) { &["qsb.exe", "qsb"] } else { &["qsb"] };
let names: &[&str] = if cfg!(windows) {
&["qsb.exe", "qsb"]
} else {
&["qsb"]
};
if let Ok(path) = std::env::var("PATH") {
for dir in std::env::split_paths(&path) {
for name in names {
@@ -605,6 +609,9 @@ fn main() {
"qml/controls/TrackReplacementModal.qml",
"qml/controls/DiscMetaModal.qml",
"qml/controls/TagEditorModal.qml",
"qml/controls/TrackMetadataModal.qml",
"qml/controls/LocalMediaInfoModal.qml",
"qml/controls/OfflineCacheChoiceModal.qml",
"qml/controls/TagEditorWorkspace.qml",
"qml/controls/RipWizardModal.qml",
"qml/controls/RipProgressModal.qml",
@@ -825,6 +832,7 @@ fn main() {
"qml/views/local/LocalArtistRow.qml",
"qml/views/local/LocalArtistsTab.qml",
"qml/views/local/LocalChrome.qml",
"qml/views/local/LocalScanProgress.qml",
"qml/views/local/LocalEphemeralPane.qml",
"qml/views/local/LocalFilterPopup.qml",
"qml/views/local/LocalFilterButton.qml",
+10
View File
@@ -1031,6 +1031,16 @@ ApplicationWindow {
onLoaded: if (screenLoader.item) screenLoader.item.hostWindow = window
}
// Compact per-track metadata editor. It lives above the routed shell so a
// successful save can republish Local Library without destroying the
// modal halfway through its own transaction.
Loader {
anchors.fill: parent
active: QbzTagEditor.trackEditorOpen
source: "controls/TrackMetadataModal.qml"
z: 4000
}
// Frameless hairline (app.slint's no-frame 1px edge) — paints at the
// very rim, over everything. SQUARE (the app draws no corner rounding).
Rectangle {
@@ -0,0 +1,293 @@
// Physical media facts for Local Library. This intentionally does not reuse
// the Qobuz Track/Album Info document: those modals describe a catalog record;
// this one describes the playable file/server copy and exposes copyable paths.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../theme"
Popup {
id: root
parent: Overlay.overlay
x: 0
y: 0
width: parent ? parent.width : 0
height: parent ? parent.height : 0
padding: 0
z: 3000
modal: true
dim: false
closePolicy: Popup.CloseOnEscape
QbzTheme { id: theme }
readonly property var doc: {
try { return JSON.parse(QbzLocal.localMediaInfoJson || "{}") }
catch (e) { return ({}) }
}
readonly property bool loading: QbzLocal.localMediaInfoLoading
function sourceName(kind) {
if (kind === "local") return QbzSession.tr("Local file", QbzSession.trRev)
if (kind === "network") return QbzSession.tr("Network file", QbzSession.trRev)
if (kind === "offline") return QbzSession.tr("Offline download", QbzSession.trRev)
if (kind === "plex") return "Plex"
if (kind === "jellyfin") return "Jellyfin"
if (kind === "subsonic") return "Subsonic / Navidrome"
return kind || ""
}
function sourceText() {
var values = root.doc.sourceKinds || []
var out = []
for (var i = 0; i < values.length; i++) out.push(root.sourceName(values[i]))
return out.join(", ")
}
function locationName(kind) {
if (kind === "file") return QbzSession.tr("File path", QbzSession.trRev)
if (kind === "folder") return QbzSession.tr("Containing folder", QbzSession.trRev)
if (kind === "item") return QbzSession.tr("Media item ID", QbzSession.trRev)
return QbzSession.tr("Album ID", QbzSession.trRev)
}
function factRows() {
var rows = []
function add(label, value) {
if (String(value || "") !== "") rows.push({"label": label, "value": value})
}
add(QbzSession.tr("Source", QbzSession.trRev), root.sourceText())
add(QbzSession.tr("Server", QbzSession.trRev), root.doc.server)
if (root.doc.kind === "album")
add(QbzSession.tr("Tracks", QbzSession.trRev), root.doc.trackCount)
add(QbzSession.tr("Duration", QbzSession.trRev), root.doc.duration)
add(QbzSession.tr("Format", QbzSession.trRev), root.doc.formats)
add(QbzSession.tr("Quality", QbzSession.trRev), root.doc.quality)
add(QbzSession.tr("Channels", QbzSession.trRev), root.doc.channels)
add(QbzSession.tr("File size", QbzSession.trRev), root.doc.fileSize)
return rows
}
Connections {
target: QbzLocal
function onLocalMediaInfoOpenChanged() {
if (QbzLocal.localMediaInfoOpen) root.open()
else root.close()
}
}
Component.onCompleted: if (QbzLocal.localMediaInfoOpen) open()
onClosed: if (QbzLocal.localMediaInfoOpen) QbzLocal.closeMediaInfo()
background: Rectangle { color: "#bf000000" }
contentItem: Item {
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
Rectangle {
id: card
width: Math.min(root.width - 40, 760)
height: Math.min(root.height - 40, 620)
anchors.centerIn: parent
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
clip: true
MouseArea { anchors.fill: parent }
Column {
id: header
anchors.left: parent.left
anchors.right: closeButton.left
anchors.leftMargin: 24
anchors.rightMargin: 16
anchors.top: parent.top
anchors.topMargin: 20
spacing: 4
Text {
width: parent.width
text: root.doc.kind === "album"
? QbzSession.tr("Album info", QbzSession.trRev)
: QbzSession.tr("Track info", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.doc.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.doc.subtitle || ""
visible: text !== ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Rectangle {
id: closeButton
width: 32
height: 32
radius: theme.radiusSm
anchors.right: parent.right
anchors.rightMargin: 16
anchors.top: parent.top
anchors.topMargin: 16
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon { anchors.centerIn: parent; name: "x"; width: 16; height: 16; tintName: "muted" }
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.close()
}
}
QbzSpinner {
visible: root.loading
width: 32
height: 32
anchors.centerIn: parent
}
Text {
visible: !root.loading && (root.doc.error || "") !== ""
anchors.centerIn: parent
width: parent.width - 80
text: root.doc.error || ""
color: theme.textMuted
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
Flickable {
id: flick
visible: !root.loading && (root.doc.error || "") === ""
anchors.left: parent.left
anchors.right: parent.right
anchors.top: header.bottom
anchors.bottom: parent.bottom
anchors.leftMargin: 24
anchors.rightMargin: 24
anchors.topMargin: 20
anchors.bottomMargin: 20
clip: true
contentWidth: width
contentHeight: body.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: body
width: flick.width - 18
spacing: 10
Repeater {
model: root.factRows()
delegate: Item {
required property var modelData
width: body.width
height: Math.max(34, factValue.implicitHeight + 8)
Text {
width: 142
anchors.verticalCenter: parent.verticalCenter
text: modelData.label
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
TextEdit {
id: factValue
x: 154
width: parent.width - x
anchors.verticalCenter: parent.verticalCenter
text: String(modelData.value)
color: theme.textPrimary
font.pixelSize: theme.fontLegal
readOnly: true
selectByMouse: true
wrapMode: TextEdit.Wrap
}
}
}
Rectangle {
visible: (root.doc.locations || []).length > 0
width: parent.width
height: 1
color: theme.borderSubtle
}
Text {
visible: (root.doc.locations || []).length > 0
text: QbzSession.tr("Location", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Repeater {
model: root.doc.locations || []
delegate: Rectangle {
required property var modelData
width: body.width
height: 68
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
x: 12
y: 8
text: root.locationName(modelData.kind)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
TextEdit {
x: 12
y: 28
width: parent.width - 92
height: 30
text: modelData.value || ""
color: theme.textPrimary
font.pixelSize: theme.fontLegal
readOnly: true
selectByMouse: true
wrapMode: TextEdit.NoWrap
clip: true
}
SettingsButton {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Copy", QbzSession.trRev)
onClicked: QbzLocal.copyMediaInfo(modelData.value || "")
}
}
}
}
}
QbzScrollBar {
visible: flick.visible && flick.contentHeight > flick.height
target: flick
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: flick.top
anchors.bottom: flick.bottom
}
}
}
}
@@ -0,0 +1,245 @@
// Shared whole-collection offline-cache preflight. Rust retains the complete
// album/playlist snapshot; this surface receives only counts and labels, so a
// 5,000-track playlist never becomes a QML object graph just to ask one
// question.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
readonly property var doc: {
try { return JSON.parse(QbzOffline.collectionChoiceJson || "{}") }
catch (e) { return {} }
}
readonly property bool opened: QbzOffline.collectionChoiceOpen
function tr(source) { return QbzSession.tr(source, QbzSession.trRev) }
function summary() {
return root.tr("{} of {} tracks in “{}” are already available offline.")
.replace("{}", String(root.doc.cachedTracks || 0))
.replace("{}", String(root.doc.totalTracks || 0))
.replace("{}", root.doc.title || "")
}
function restoreShellFocus() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
function cancel() {
QbzOffline.cancelCollectionCache()
root.restoreShellFocus()
}
function choose(mode) {
QbzOffline.confirmCollectionCache(mode)
root.restoreShellFocus()
}
visible: root.opened
enabled: root.opened
z: 3150
onOpenedChanged: {
if (root.opened) keyScope.forceActiveFocus()
}
FocusScope {
id: keyScope
anchors.fill: parent
Keys.onEscapePressed: function(event) {
root.cancel()
event.accepted = true
}
}
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root.cancel()
}
}
Rectangle {
x: panel.x
y: panel.y + 8
width: panel.width
height: panel.height
radius: theme.radiusMd
color: "#80000000"
opacity: 0.5
}
Rectangle {
id: panel
width: Math.min(root.width - 80, 520)
height: content.implicitHeight + 48
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceMain
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent }
Column {
id: content
x: 24
y: 24
width: parent.width - 48
spacing: 12
Text {
width: parent.width
text: root.tr("Some tracks are already available offline")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: root.summary()
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
Item { width: 1; height: 2 }
Rectangle {
width: parent.width
height: allCol.implicitHeight + 24
radius: theme.radiusSm
color: allArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: allArea.containsMouse ? theme.accent : theme.borderSubtle
Row {
anchors.fill: parent
anchors.margins: 12
spacing: 12
QbzIcon {
name: "refresh-cw"
width: 18
height: 18
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
Column {
id: allCol
width: parent.width - 30
spacing: 3
Text {
width: parent.width
text: root.doc.kind === "playlist"
? root.tr("Download the entire playlist again")
: root.tr("Download the entire album again")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: root.tr("Re-downloads every track and can repair corrupt offline copies.")
color: theme.textSecondary
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
}
}
MouseArea {
id: allArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.choose("all")
}
}
Rectangle {
width: parent.width
height: missingCol.implicitHeight + 24
radius: theme.radiusSm
color: missingArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: missingArea.containsMouse ? theme.accent : theme.borderSubtle
Row {
anchors.fill: parent
anchors.margins: 12
spacing: 12
QbzIcon {
name: "cloud-download"
width: 18
height: 18
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
Column {
id: missingCol
width: parent.width - 30
spacing: 3
Text {
width: parent.width
text: root.tr("Only missing tracks")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Text {
width: parent.width
text: root.tr("Keeps existing offline copies and downloads tracks that are not available yet.")
color: theme.textSecondary
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
}
}
MouseArea {
id: missingArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.choose("missing")
}
}
Item {
width: parent.width
height: 36
Rectangle {
anchors.right: parent.right
width: cancelLabel.implicitWidth + 32
height: 36
radius: theme.radiusSm
color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Text {
id: cancelLabel
anchors.centerIn: parent
text: root.tr("Cancel")
color: theme.textPrimary
font.pixelSize: theme.fontBody
}
MouseArea {
id: cancelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.cancel()
}
}
}
}
}
}
+38 -8
View File
@@ -22,8 +22,28 @@ Item {
/// [{ letter, index }] — index = the row ordinal in the flat model.
property var jumps: []
/// Paint the stable # A-Z affordance even when a query has no row for a
/// letter. Missing buckets are deliberately inert and dimmed; present
/// buckets retain their exact native/global row index.
property bool completeAlphabet: false
signal jump(int ordinal, int index)
function entryAt(position) {
if (!root.completeAlphabet)
return (root.jumps || [])[position] || ({ "letter": "", "index": -1 })
var targets = ({})
var source = root.jumps || []
for (var i = 0; i < source.length; i++) {
var letter = String(source[i].letter || "#").toUpperCase()
if (letter < "A" || letter > "Z") letter = "#"
if (targets[letter] === undefined)
targets[letter] = Number(source[i].index)
}
var letter = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".charAt(position)
return { "letter": letter,
"index": targets[letter] === undefined ? -1 : targets[letter] }
}
QbzTheme { id: theme }
width: 18
@@ -32,18 +52,27 @@ Item {
anchors.centerIn: parent
spacing: 0
Repeater {
model: root.jumps
// An integer model makes the full rail structural: it always
// creates exactly 27 delegates, regardless of when the async
// jump document lands or how a JS-array binding is invalidated.
model: root.completeAlphabet ? 27 : (root.jumps || []).length
delegate: Item {
id: cell
required property var modelData
required property int index
readonly property var entry: root.entryAt(index)
width: 18
height: 15
// A complete rail is 27 entries (# + A-Z). Keep the reference
// 15px row when it fits, then compress uniformly on short
// windows instead of painting letters outside the viewport.
height: Math.max(1, Math.min(15,
root.height / Math.max(1, root.completeAlphabet
? 27 : (root.jumps || []).length)))
Text {
anchors.fill: parent
text: cell.modelData.letter
color: cellArea.containsMouse ? theme.accent : theme.textMuted
font.pixelSize: 9
text: cell.entry.letter
color: cell.entry.index < 0 ? theme.alphaTier(24)
: cellArea.containsMouse ? theme.accent : theme.textMuted
font.pixelSize: Math.min(9, Math.max(7, parent.height - 1))
font.weight: theme.weightSemibold
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
@@ -51,9 +80,10 @@ Item {
MouseArea {
id: cellArea
anchors.fill: parent
enabled: cell.entry.index >= 0
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.jump(cell.index, cell.modelData.index)
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.jump(cell.index, cell.entry.index)
}
}
}
+92 -18
View File
@@ -1,7 +1,8 @@
// Full-page local album metadata editor. The historical filename is retained
// so existing qrc registrations stay stable; ContentRouter is its only mount.
// The selected physical version is immutable for the lifetime of this view:
// QML edits row ids and values only; Rust owns paths and verifies direct writes.
// Shared full-depth metadata editor. ContentRouter mounts it as the album
// workspace; TrackMetadataModal mounts the same component as a dialog with
// sibling navigation. The selected source version is immutable for the
// lifetime of the view: QML edits row ids and values only; Rust owns physical
// paths/server identities and verifies the chosen persistence mode.
import QtQuick
import com.blitzfc.qbz
@@ -12,6 +13,9 @@ Item {
visible: true
enabled: true
property bool trackMode: false
property bool leaveOnDestruction: true
property string albumTitle: ""
property string albumArtist: ""
property string albumArtistsText: ""
@@ -26,6 +30,7 @@ Item {
property var inspection: ({})
property bool canDirectWrite: false
property string directReason: ""
property bool remoteSidecarOnly: false
property bool seeded: false
property var remoteResults: []
property var remoteMetadata: null
@@ -38,6 +43,9 @@ Item {
property var artworkResults: []
property string artworkProvider: "musicbrainz"
property int selectedTrackIndex: -1
readonly property var currentTrack: selectedTrackIndex >= 0
&& selectedTrackIndex < tracks.length ? tracks[selectedTrackIndex] : null
readonly property bool busy: QbzTagEditor.editorLoading || QbzTagEditor.editorSaving
function tr(s) { return QbzSession.tr(s, QbzSession.trRev) }
function parse(value) {
@@ -100,12 +108,16 @@ Item {
inspection = doc.inspection || ({})
canDirectWrite = doc.canDirectWrite === true
directReason = doc.directWriteReason || ""
remoteSidecarOnly = doc.remoteSidecarOnly === true
persistence = "sidecar"
id3Version = "2.4"
syncSecondary = false
remoteResults = []
remoteMetadata = null
selectedTrackIndex = tracks.length > 0 ? 0 : -1
var requestedIndex = trackMode
? Number(QbzTagEditor.trackEditorInitialIndex || 0) : 0
selectedTrackIndex = tracks.length > 0
? Math.max(0, Math.min(tracks.length - 1, requestedIndex)) : -1
seeded = true
keyScope.forceActiveFocus()
}
@@ -223,7 +235,8 @@ Item {
seed(parse(QbzTagEditor.editorJson))
}
Component.onDestruction: {
QbzTagEditor.leave()
if (root.leaveOnDestruction)
QbzTagEditor.leave()
restoreShellFocus()
}
@@ -268,12 +281,29 @@ Item {
}
Rectangle {
id: panel
anchors.fill: parent
radius: 0
visible: root.trackMode
color: "#bf000000"
MouseArea {
anchors.fill: parent
enabled: !root.busy
onClicked: root.closeEditor()
}
}
Rectangle {
id: panel
anchors.centerIn: parent
width: root.trackMode ? Math.min(parent.width - 48, 1280) : parent.width
height: root.trackMode ? Math.min(parent.height - 48, 840) : parent.height
radius: root.trackMode ? theme.radiusLg : 0
color: theme.surfaceMain
border.width: root.trackMode ? 1 : 0
border.color: theme.borderStrong
clip: true
MouseArea { anchors.fill: parent }
Item {
id: header
width: parent.width
@@ -281,13 +311,14 @@ Item {
Column {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: closeButton.left
anchors.right: headerActions.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.tr("Edit metadata")
text: root.trackMode
? root.tr("Edit track metadata") : root.tr("Edit metadata")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
@@ -295,21 +326,64 @@ Item {
}
Text {
width: parent.width
text: root.tr("Editing the selected physical version")
text: root.remoteSidecarOnly
? root.tr("Editing a local metadata sidecar")
: root.trackMode && root.currentTrack
? root.currentTrack.fileName
: root.tr("Editing the selected physical version")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
QbzIconButton {
id: closeButton
Row {
id: headerActions
anchors.right: parent.right
anchors.rightMargin: 20
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
name: "x"
tooltipText: root.tr("Close without saving")
btnEnabled: !QbzTagEditor.editorSaving
onClicked: root.closeEditor()
spacing: 6
SettingsButton {
visible: root.trackMode
text: root.tr("Edit album")
iconName: "disc-3"
minWidth: 0
enabled: !root.busy
onClicked: QbzTagEditor.promoteToAlbumEditor()
}
QbzIconButton {
visible: root.trackMode
name: "chevron-left"
tooltipText: root.tr("Previous track")
btnEnabled: !root.busy && root.selectedTrackIndex > 0
onClicked: root.selectedTrackIndex--
}
Text {
visible: root.trackMode
width: 54
height: 32
text: root.tracks.length > 0
? (root.selectedTrackIndex + 1) + " / " + root.tracks.length
: "0 / 0"
color: theme.textSecondary
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
QbzIconButton {
visible: root.trackMode
name: "chevron-right"
tooltipText: root.tr("Next track")
btnEnabled: !root.busy
&& root.selectedTrackIndex + 1 < root.tracks.length
onClicked: root.selectedTrackIndex++
}
QbzIconButton {
name: "x"
tooltipText: root.tr("Close without saving")
btnEnabled: !QbzTagEditor.editorSaving
onClicked: root.closeEditor()
}
}
}
Rectangle { y: header.height; width: parent.width; height: 1; color: theme.borderSubtle }
@@ -0,0 +1,22 @@
// Per-track entry point for the shared metadata workspace. It deliberately
// does not own a second form: every album/track field, provider lookup,
// artwork action, validation rule and persistence choice comes from
// TagEditorModal. The only additions are dialog chrome, sibling navigation
// and promotion to the routed full-album view.
import QtQuick
import com.blitzfc.qbz
Item {
id: root
visible: QbzTagEditor.trackEditorOpen
enabled: visible
TagEditorModal {
anchors.fill: parent
trackMode: true
// Promotion destroys this wrapper while preserving Rust's immutable
// session for the full-page instance mounted by ContentRouter.
leaveOnDestruction: false
}
}
+22 -2
View File
@@ -52,6 +52,11 @@ Item {
/// which is what PlaylistView.slint and MixView.slint do.
property bool favoriteGlyph: false
property bool downloadGlyph: false
/// When enabled, the cloud header becomes the whole-collection cache
/// button. It remains an inert column label everywhere else.
property bool downloadActionEnabled: false
property bool downloadActionLoading: false
signal downloadClicked()
/// Label band height. 0 = the label's own line height, which is what the
/// playlist / mix headers use (Slint gives those HorizontalLayouts no
@@ -171,12 +176,27 @@ Item {
width: cols.colDownload
height: root.bandH
QbzIcon {
visible: root.downloadGlyph
visible: root.downloadGlyph && !root.downloadActionLoading
anchors.centerIn: parent
name: "cloud-download"
width: 14
height: 14
tintName: "muted"
tintName: downloadArea.containsMouse && root.downloadActionEnabled
? "accent" : "muted"
}
QbzSpinner {
visible: root.downloadActionLoading
anchors.centerIn: parent
size: 14
}
MouseArea {
id: downloadArea
anchors.fill: parent
enabled: root.downloadGlyph && root.downloadActionEnabled
&& !root.downloadActionLoading
hoverEnabled: enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.downloadClicked()
}
}
// ⋯ column.
+6
View File
@@ -818,6 +818,12 @@ Rectangle {
PlaylistCreateModal {
anchors.fill: parent
}
// Whole-album / whole-playlist offline preflight. Mounted once at shell
// level because either detail view can open it and the retained track
// snapshot lives in QbzOffline, not in the page that launched it.
OfflineCacheChoiceModal {
anchors.fill: parent
}
// The hotkeys pair (2026-08-03 hotkeys-port contract §4.4/§4.5, block
// B3): the read-only cheatsheet (`?` / the HeaderBar menu row) and the
// editable customize editor. Same declaration-order convention as their
+6
View File
@@ -1376,6 +1376,12 @@ Rectangle {
showDownload: true
favoriteGlyph: true
downloadGlyph: true
downloadActionEnabled: (albumHeader.id || "") !== ""
&& (root.album.tracks || []).length > 0
&& QbzSession.offlineMode === 0
downloadActionLoading: QbzOffline.collectionPreflightLoading
&& QbzOffline.collectionPreflightKey === "album:" + (albumHeader.id || "")
onDownloadClicked: QbzOffline.cacheAlbum(albumHeader.id)
}
}
+6 -2
View File
@@ -585,7 +585,7 @@ Rectangle {
// on the letter's FIRST card instead of near it. Same strip, same
// buckets, an exact landing.
readonly property bool alphaActive:
(activeTab === "tracks" && tracksGroup === "name")
(activeTab === "tracks" && tracksGroup !== "off")
|| (activeTab === "albums" && albumsGroup === "alpha")
|| (activeTab === "artists" && artistsGroup === "alpha" && artistsView === "grid")
readonly property var alphaJumps: {
@@ -599,7 +599,10 @@ Rectangle {
// itself at the top of the viewport.
if (activeTab === "tracks") {
if (rows[i].kind !== "group-header") continue
out.push({ "letter": rows[i].title, "index": i })
var trackKey = root.alphaKey(rows[i].title)
if (trackKey === last) continue
out.push({ "letter": trackKey, "index": i })
last = trackKey
continue
}
var key = root.alphaKey(rows[i].title)
@@ -1427,6 +1430,7 @@ Rectangle {
anchors.topMargin: 16
anchors.bottom: parent.bottom
jumps: root.alphaJumps
completeAlphabet: true
onJump: function (ordinal, index) { root.alphaJumpTo(index) }
}
+73 -2
View File
@@ -141,12 +141,20 @@ Rectangle {
// Genres column browser. Selections are maps so Ctrl/Cmd toggles stay
// O(1), and an empty map is the explicit All state.
property string genresSearch: ""
property string genreYearsSearch: ""
property string genreArtistsSearch: ""
property string genreAlbumsSearch: ""
property var selectedGenres: ({})
property var selectedGenreYears: ({})
property var selectedGenreArtists: ({})
property var selectedGenreAlbums: ({})
// Which leading Library Explorer facet is mounted. Genre remains the
// default; "both" deliberately adds a fourth column for larger screens.
// Seeded from locallibrary_ui.json; navigation-state restoration may
// temporarily reassign it while rebuilding a Back/Forward snapshot.
property string explorerColumns: QbzLocal.localExplorerColumns // genre | year | both
readonly property int selectedGenreCount: Object.keys(selectedGenres).length
readonly property int selectedGenreYearCount: Object.keys(selectedGenreYears).length
readonly property int selectedGenreArtistCount: Object.keys(selectedGenreArtists).length
readonly property int selectedGenreAlbumCount: Object.keys(selectedGenreAlbums).length
property bool genresBrowserCollapsed: false
@@ -182,11 +190,14 @@ Rectangle {
selectedArtist: root.selectedArtist,
tracksSearch: root.tracksSearch,
genresSearch: root.genresSearch,
genreYearsSearch: root.genreYearsSearch,
genreArtistsSearch: root.genreArtistsSearch,
genreAlbumsSearch: root.genreAlbumsSearch,
selectedGenres: root.selectedGenres,
selectedGenreYears: root.selectedGenreYears,
selectedGenreArtists: root.selectedGenreArtists,
selectedGenreAlbums: root.selectedGenreAlbums,
explorerColumns: root.explorerColumns,
genresBrowserCollapsed: root.genresBrowserCollapsed,
genresView: root.genresView,
genresSort: root.genresSort
@@ -248,14 +259,20 @@ Rectangle {
root.selectedArtist = text("selectedArtist", root.selectedArtist)
root.tracksSearch = text("tracksSearch", root.tracksSearch)
root.genresSearch = text("genresSearch", root.genresSearch)
root.genreYearsSearch = text("genreYearsSearch", root.genreYearsSearch)
root.genreArtistsSearch = text("genreArtistsSearch", root.genreArtistsSearch)
root.genreAlbumsSearch = text("genreAlbumsSearch", root.genreAlbumsSearch)
if (saved.selectedGenres && typeof saved.selectedGenres === "object")
root.selectedGenres = saved.selectedGenres
if (saved.selectedGenreYears && typeof saved.selectedGenreYears === "object")
root.selectedGenreYears = saved.selectedGenreYears
if (saved.selectedGenreArtists && typeof saved.selectedGenreArtists === "object")
root.selectedGenreArtists = saved.selectedGenreArtists
if (saved.selectedGenreAlbums && typeof saved.selectedGenreAlbums === "object")
root.selectedGenreAlbums = saved.selectedGenreAlbums
var savedExplorerColumns = text("explorerColumns", root.explorerColumns)
if (["genre", "year", "both"].indexOf(savedExplorerColumns) >= 0)
root.explorerColumns = savedExplorerColumns
if (typeof saved.genresBrowserCollapsed === "boolean")
root.genresBrowserCollapsed = saved.genresBrowserCollapsed
root.genresView = text("genresView", root.genresView)
@@ -976,6 +993,7 @@ Rectangle {
}
function albumHasSelectedGenre(album) {
if (explorerColumns === "year") return true
if (selectedGenreCount === 0) return true
var genres = album.genres || []
for (var i = 0; i < genres.length; i++)
@@ -983,6 +1001,16 @@ Rectangle {
return false
}
function albumHasSelectedYear(album) {
if (explorerColumns === "genre") return true
if (selectedGenreYearCount === 0) return true
var years = album.years && album.years.length > 0
? album.years : [album.year || ""]
for (var i = 0; i < years.length; i++)
if (selectedGenreYears[String(years[i])] === true) return true
return false
}
function albumHasSelectedArtist(album) {
if (selectedGenreArtistCount === 0) return true
var names = albumArtistNames(album)
@@ -1009,11 +1037,31 @@ Rectangle {
return out
}
readonly property var genreYearOptions: {
var values = ({})
for (var i = 0; i < genreBaseAlbums.length; i++) {
var album = genreBaseAlbums[i]
if (!albumHasSelectedGenre(album)) continue
var years = album.years && album.years.length > 0
? album.years : [album.year || ""]
for (var j = 0; j < years.length; j++) {
var key = String(years[j] || "").trim()
if (key !== "") values[key] = true
}
}
var out = [], q = genreYearsSearch.trim().toLowerCase()
for (var key in values)
if (q === "" || key.toLowerCase().indexOf(q) >= 0)
out.push({ "key": key, "label": key })
out.sort(function(a, b) { return Number(b.key) - Number(a.key) })
return out
}
readonly property var genreArtistOptions: {
var values = {}, display = {}
for (var i = 0; i < genreBaseAlbums.length; i++) {
var album = genreBaseAlbums[i]
if (!albumHasSelectedGenre(album)) continue
if (!albumHasSelectedGenre(album) || !albumHasSelectedYear(album)) continue
var names = albumArtistNames(album)
for (var j = 0; j < names.length; j++) {
var key = names[j].toLowerCase()
@@ -1032,7 +1080,8 @@ Rectangle {
var out = [], q = genreAlbumsSearch.trim().toLowerCase()
for (var i = 0; i < genreBaseAlbums.length; i++) {
var album = genreBaseAlbums[i]
if (!albumHasSelectedGenre(album) || !albumHasSelectedArtist(album)) continue
if (!albumHasSelectedGenre(album) || !albumHasSelectedYear(album)
|| !albumHasSelectedArtist(album)) continue
if (q !== "" && (album.title || "").toLowerCase().indexOf(q) < 0) continue
out.push({ "key": album.id, "label": album.title || "", "album": album })
}
@@ -1052,6 +1101,12 @@ Rectangle {
function toggleGenre(key, modifiers) {
selectedGenres = nextFacetSelection(selectedGenres, key, modifiers)
selectedGenreYears = ({})
selectedGenreArtists = ({})
selectedGenreAlbums = ({})
}
function toggleGenreYear(key, modifiers) {
selectedGenreYears = nextFacetSelection(selectedGenreYears, key, modifiers)
selectedGenreArtists = ({})
selectedGenreAlbums = ({})
}
@@ -1062,6 +1117,20 @@ Rectangle {
function toggleGenreAlbum(key, modifiers) {
selectedGenreAlbums = nextFacetSelection(selectedGenreAlbums, key, modifiers)
}
function setExplorerColumns(mode) {
if (["genre", "year", "both"].indexOf(mode) < 0 || mode === explorerColumns)
return
explorerColumns = mode
QbzLocal.setExplorerColumns(mode)
// A hidden facet must never keep filtering the visible chain.
if (mode === "genre") selectedGenreYears = ({})
else if (mode === "year") selectedGenres = ({})
selectedGenreArtists = ({})
selectedGenreAlbums = ({})
}
readonly property int explorerFacetCount: explorerColumns === "both" ? 4 : 3
readonly property int explorerLeadingCount: explorerColumns === "year"
? genreYearOptions.length : genreNames.length
function nextFacetSelection(current, key, modifiers) {
if (key === "") return ({})
var additive = (modifiers & Qt.ControlModifier) !== 0
@@ -1561,4 +1630,6 @@ Rectangle {
&& ["albums", "artists", "genres", "tracks"].indexOf(root.activeTab) >= 0
view: root
}
LocalMediaInfoModal { }
}
@@ -91,6 +91,8 @@ Rectangle {
readonly property string viewMode: root.doc.viewMode || "grid"
readonly property bool folderMode: root.doc.folderMode !== false
readonly property bool foldersCollapsed: root.doc.foldersCollapsed === true
readonly property string currentFolderId: root.doc.currentFolderId || ""
readonly property string currentFolderName: root.doc.currentFolderName || ""
/// Computed in RUST (`sort == "custom" && sortAsc && query empty`, D9);
/// QML must never re-derive it.
readonly property bool canReorder: root.doc.canReorder === true
@@ -205,6 +207,8 @@ Rectangle {
viewMode: root.viewMode
folderMode: root.folderMode
foldersCollapsed: root.foldersCollapsed
currentFolderId: root.currentFolderId
currentFolderName: root.currentFolderName
canReorder: root.canReorder
folderCount: root.folderCount
playlistCount: root.playlistCount
+9 -1
View File
@@ -37,7 +37,8 @@
// placeholder that clears when the playlist's own cover lands.
//
// Known remaining parity deltas (playlist_qt.rs has the full list):
// Suggested Songs and whole-playlist offline download are not ported.
// Suggested Songs are not ported. Whole-playlist offline download lives on
// the cloud glyph in the table header and shares the album preflight.
import QtQuick
import QtQuick.Controls
@@ -859,6 +860,12 @@ Rectangle {
showSource: root.isLocal || !root.online
showAlbum: true
showDownload: true
downloadGlyph: !root.isLocal
downloadActionEnabled: !root.isLocal && root.online
&& root.allTracks.length > 0
downloadActionLoading: QbzOffline.collectionPreflightLoading
&& QbzOffline.collectionPreflightKey === "playlist:" + String(root.doc.id || "")
onDownloadClicked: QbzOffline.cachePlaylist(String(root.doc.id || ""))
// MIRROR the delegate's arm, or the labels slide by 22 + 14 the
// moment the list becomes reorderable (TrackListHeader's rule 2).
showReorder: root.canReorder
@@ -904,6 +911,7 @@ Rectangle {
showArtwork: true
showSource: root.isLocal || !root.online
showAlbum: true
artistLink: true
showDownload: true
// Alternating row tint, like the local album page
// (LocalAlbumView.qml). TrackRow already owns the stripe —
@@ -299,11 +299,12 @@ Item {
}
}
QbzAlphaStrip {
visible: root.alphaJumps.length > 0
visible: root.entries.length > 0
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
jumps: root.alphaJumps
completeAlphabet: true
onJump: function (ordinal, index) {
railList.positionViewAtIndex(index, ListView.Beginning)
}
@@ -503,6 +503,15 @@ Item {
isFavorite: root.view
? root.view.albumFavorite(cardCell.slot)
: cardCell.slot.isFavorite === true
extraMenuEntries: [
{ "label": QbzSession.tr("Album info", QbzSession.trRev),
"icon": "info", "action": "album-info" }
]
onExtraMenuAction: function (action) {
if (action === "album-info")
QbzLocal.bulkAction("album",
JSON.stringify([String(cardCell.slot.id)]), action)
}
onFavoriteRequested: if (root.view) {
root.view.toggleAlbumFavorite(
cardCell.slot, albumCard.artSource)
@@ -6,9 +6,9 @@
// column and the trailing ⋯ overflow. Multi-select puts the shared checkbox
// in front of the cover, as the collection view does.
//
// The ⋯ menu carries the same local/Plex favorite as the grid overlay. Remote-
// only rows omit it because the shared LocalFavoritesService deliberately
// accepts genuine local and Plex snapshots only.
// The ⋯ menu carries the same Local Library favorite as the grid overlay.
// Genuine local and configured media-server albums are snapshot-backed;
// Qobuz-offline-only rows stay in the catalog-favorite domain.
import QtQuick
import com.blitzfc.qbz
@@ -96,7 +96,8 @@ Rectangle {
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": QbzSession.tr("Album info", QbzSession.trRev), "icon": "info", "action": "album-info" }
]
if (root.item.favoriteable === true) {
rows.push({
@@ -113,6 +114,8 @@ Rectangle {
if (a === "open") root.opened()
else if (a === "play") root.playRequested()
else if (a === "favorite") root.favoriteRequested()
else if (a === "album-info")
QbzLocal.bulkAction("album", JSON.stringify([String(root.item.id)]), a)
else root.enqueueRequested(a)
}
}
@@ -151,7 +151,7 @@ Item {
}
QbzAlphaStrip {
visible: root.view.albumsGroup === "alpha"
&& collection.alphaJumps.length > 0
&& root.albumTotal > 0
anchors.right: parent.right
// Out past the tab's 32 padding to 12 from the window edge,
// which is where LibraryView.qml:1269 puts the same strip.
@@ -159,6 +159,7 @@ Item {
anchors.top: parent.top
anchors.bottom: parent.bottom
jumps: collection.alphaJumps
completeAlphabet: true
onJump: function (ordinal, index) { collection.jumpToEntry(index) }
}
}
@@ -280,11 +280,12 @@ Item {
}
}
QbzAlphaStrip {
visible: root.alphaJumps.length > 0
visible: root.artistTotal > 0
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
jumps: root.alphaJumps
completeAlphabet: true
onJump: function (ordinal, index) {
rail.positionViewAtIndex(index, ListView.Beginning)
}
+16 -1
View File
@@ -39,6 +39,7 @@ Item {
width: parent.width
height: 46
Row {
id: headerActions
x: 32
anchors.verticalCenter: parent.verticalCenter
spacing: 12
@@ -176,6 +177,20 @@ Item {
}
}
}
// The unused centre of the title row carries scan state without
// changing the 91px chrome contract. It reads counters only; no model
// row or delegate is created, so large-library virtualization remains
// untouched. On a narrow window the toolbar keeps priority and the
// compact indicator yields rather than overlapping controls.
LocalScanProgress {
anchors.left: headerActions.right
anchors.leftMargin: 28
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
visible: active && width >= 260
}
}
// ---- Row 2: tabs (left) + per-tab toolbar (right) ----
@@ -202,7 +217,7 @@ Item {
var byId = {
"albums": { "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.counts.albums || 0 },
"artists": { "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 },
"genres": { "id": "genres", "label": QbzSession.tr("Library Explorer", QbzSession.trRev), "count": root.view.genreNames.length },
"genres": { "id": "genres", "label": QbzSession.tr("Library Explorer", QbzSession.trRev), "count": root.view.explorerLeadingCount },
"folders": { "id": "folders", "label": QbzSession.tr("Folders", QbzSession.trRev), "count": root.view.counts.folders || 0 },
"tracks": { "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.counts.tracks || 0 }
}
@@ -1,5 +1,6 @@
// Local Library Genres — a retractable three-stage browser over the logical
// album set, surrounding grid/list/expanded-details results from any edge.
// Local Library Explorer — a retractable three- or four-stage browser over
// the logical album set, surrounding grid/list/expanded-details results from
// any edge. The leading facet is Genre, Year, or both.
import QtQuick
import com.blitzfc.qbz
@@ -18,39 +19,60 @@ Item {
readonly property int collapsedThickness: 24
readonly property int horizontalHeight: 266
readonly property var facetKinds: root.view && root.view.explorerColumns === "both"
? ["genre", "year", "artist", "album"]
: root.view && root.view.explorerColumns === "year"
? ["year", "artist", "album"]
: ["genre", "artist", "album"]
function facetKind(index) { return facetKinds[index] || "album" }
function facetTitle(index) {
if (index === 0) return QbzSession.tr("Genres", QbzSession.trRev)
if (index === 1) return QbzSession.tr("Artists", QbzSession.trRev)
var kind = facetKind(index)
if (kind === "genre") return QbzSession.tr("Genres", QbzSession.trRev)
if (kind === "year") return QbzSession.tr("Years", QbzSession.trRev)
if (kind === "artist") return QbzSession.tr("Artists", QbzSession.trRev)
return QbzSession.tr("Albums", QbzSession.trRev)
}
function facetAllLabel(index) {
if (index === 0) return QbzSession.tr("All genres", QbzSession.trRev)
if (index === 1) return QbzSession.tr("All artists", QbzSession.trRev)
var kind = facetKind(index)
if (kind === "genre") return QbzSession.tr("All genres", QbzSession.trRev)
if (kind === "year") return QbzSession.tr("All years", QbzSession.trRev)
if (kind === "artist") return QbzSession.tr("All artists", QbzSession.trRev)
return QbzSession.tr("All albums", QbzSession.trRev)
}
function facetQuery(index) {
if (index === 0) return root.view.genresSearch
if (index === 1) return root.view.genreArtistsSearch
var kind = facetKind(index)
if (kind === "genre") return root.view.genresSearch
if (kind === "year") return root.view.genreYearsSearch
if (kind === "artist") return root.view.genreArtistsSearch
return root.view.genreAlbumsSearch
}
function facetOptions(index) {
if (index === 0) return root.view.genreNames
if (index === 1) return root.view.genreArtistOptions
var kind = facetKind(index)
if (kind === "genre") return root.view.genreNames
if (kind === "year") return root.view.genreYearOptions
if (kind === "artist") return root.view.genreArtistOptions
return root.view.genreAlbumOptions
}
function facetSelected(index) {
if (index === 0) return root.view.selectedGenres
if (index === 1) return root.view.selectedGenreArtists
var kind = facetKind(index)
if (kind === "genre") return root.view.selectedGenres
if (kind === "year") return root.view.selectedGenreYears
if (kind === "artist") return root.view.selectedGenreArtists
return root.view.selectedGenreAlbums
}
function setFacetQuery(index, value) {
if (index === 0) root.view.genresSearch = value
else if (index === 1) root.view.genreArtistsSearch = value
var kind = facetKind(index)
if (kind === "genre") root.view.genresSearch = value
else if (kind === "year") root.view.genreYearsSearch = value
else if (kind === "artist") root.view.genreArtistsSearch = value
else root.view.genreAlbumsSearch = value
}
function toggleFacet(index, key, modifiers) {
if (index === 0) root.view.toggleGenre(key, modifiers)
else if (index === 1) root.view.toggleGenreArtist(key, modifiers)
var kind = facetKind(index)
if (kind === "genre") root.view.toggleGenre(key, modifiers)
else if (kind === "year") root.view.toggleGenreYear(key, modifiers)
else if (kind === "artist") root.view.toggleGenreArtist(key, modifiers)
else {
// The details Loader already exists while the third column is
// visible. Ask it immediately instead of waiting for the filtered
@@ -68,7 +90,7 @@ Item {
// A query can hide a selected option from its own column. Resolve
// albums against the unfiltered logical set; genre/artist keys are
// already readable names, merely normalized to lower case.
if (index === 2) {
if (facetKind(index) === "album") {
for (var j = 0; j < root.view.albums.length; j++)
if (root.view.albums[j].id === key)
return root.view.albums[j].title || key
@@ -78,7 +100,7 @@ Item {
function buildActiveFilterChips() {
if (!root.view) return []
var out = []
for (var facet = 0; facet < 3; facet++) {
for (var facet = 0; facet < root.facetKinds.length; facet++) {
var query = root.facetQuery(facet).trim()
if (query !== "")
out.push({ kind: "query", facet: facet,
@@ -121,8 +143,15 @@ Item {
else root.view.toggleFilter(chip.key)
}
function clearAllActiveFilters() {
for (var i = 0; i < 3; i++) root.setFacetQuery(i, "")
// Clear all four backing queries, including a currently hidden Genre
// or Year facet. Switching column layouts later must not resurrect a
// stale search that the visible Clear action appeared to remove.
root.view.genresSearch = ""
root.view.genreYearsSearch = ""
root.view.genreArtistsSearch = ""
root.view.genreAlbumsSearch = ""
root.view.selectedGenres = ({})
root.view.selectedGenreYears = ({})
root.view.selectedGenreArtists = ({})
root.view.selectedGenreAlbums = ({})
root.view.clearFilter()
@@ -148,7 +177,7 @@ Item {
// the GUI thread and makes the next keystroke feel stuck. The edit
// itself remains immediate; only the derived result waits for the
// short typing pause.
debounceMs: facetIndex === 2 ? 140 : 90
debounceMs: root.facetKind(facetIndex) === "album" ? 140 : 90
onQueryEdited: function(value) { root.setFacetQuery(facetIndex, value) }
onToggled: function(key, modifiers) { root.toggleFacet(facetIndex, key, modifiers) }
}
@@ -158,11 +187,12 @@ Item {
Row {
spacing: 8
Repeater {
model: 3
model: root.facetKinds.length
delegate: GenreFacet {
required property int index
facetIndex: index
width: (parent.width - 16) / 3
width: (parent.width - 8 * (root.facetKinds.length - 1))
/ root.facetKinds.length
height: parent.height
}
}
@@ -174,12 +204,13 @@ Item {
Column {
spacing: 8
Repeater {
model: 3
model: root.facetKinds.length
delegate: GenreFacet {
required property int index
facetIndex: index
width: parent.width
height: (parent.height - 16) / 3
height: (parent.height - 8 * (root.facetKinds.length - 1))
/ root.facetKinds.length
}
}
}
@@ -0,0 +1,171 @@
// Compact, read-only progress for the Local Library header. It combines the
// authoritative folder scanner with the derived catalog projection, but
// never reads rows from either model: only bounded counters cross into QML,
// preserving native paging and first-paint performance.
import QtQuick
import com.blitzfc.qbz
import "../../theme"
Item {
id: root
QbzTheme { id: theme }
readonly property var settingsDoc: {
try { return JSON.parse(QbzBridge.settingsJson || "{}") }
catch (e) { return ({}) }
}
readonly property var localScan: settingsDoc.library || ({})
readonly property var catalog: {
try { return JSON.parse(QbzLocal.localCatalogProgressJson || "{}") }
catch (e) { return ({}) }
}
readonly property bool folderActive: localScan.scanning === true
readonly property bool catalogActive: catalog.active === true
readonly property bool active: folderActive || catalogActive
readonly property real sourceDone: folderActive
? Number(localScan.sourceProcessed || 0) : Number(catalog.sourceDone || 0)
readonly property real sourceTotal: folderActive
? Number(localScan.sourceTotal || 0) : Number(catalog.sourceTotal || 0)
readonly property real overallDone: folderActive
? Number(localScan.processed || 0) : Number(catalog.overallDone || 0)
readonly property real overallTotal: folderActive
? Number(localScan.total || 0) : Number(catalog.overallTotal || 0)
readonly property int sourceIndex: folderActive
? Number(localScan.sourceIndex || 0) : Number(catalog.sourceIndex || 0)
readonly property int sourceCount: folderActive
? Number(localScan.sourceCount || 0) : Number(catalog.sourceCount || 0)
function boundedFraction(done, total) {
return total > 0 ? Math.max(0, Math.min(1, done / total)) : 0
}
function folderName() {
var id = Number(localScan.currentRootId || 0)
var folders = localScan.folders || []
for (var i = 0; i < folders.length; i++) {
if (Number(folders[i].id || 0) === id)
return folders[i].displayName || folders[i].path || ""
}
return QbzSession.tr("Local folders", QbzSession.trRev)
}
function catalogSourceName() {
var source = String(catalog.source || "")
if (source === "local")
return QbzSession.tr("Local folders", QbzSession.trRev)
if (source === "offline")
return QbzSession.tr("Offline", QbzSession.trRev)
if (source === "plex") return "Plex"
if (source === "jellyfin") return "Jellyfin"
if (source === "subsonic") return "Subsonic"
return String(catalog.sourceInstance || source)
}
readonly property string sourceName: folderActive ? folderName() : catalogSourceName()
readonly property string phaseLabel: folderActive
? QbzSession.tr("Scanning library", QbzSession.trRev)
: (catalog.phase === "bootstrap"
? QbzSession.tr("Building library index", QbzSession.trRev)
: QbzSession.tr("Updating library index", QbzSession.trRev))
readonly property string sourceOrdinal: sourceCount > 1 && sourceIndex > 0
? " · " + sourceIndex + "/" + sourceCount : ""
visible: active
height: 42
Text {
id: statusText
width: parent.width
height: 15
text: root.phaseLabel + " · " + root.sourceName + root.sourceOrdinal
color: theme.textSecondary
font.pixelSize: 11
font.weight: theme.weightMedium
elide: Text.ElideMiddle
verticalAlignment: Text.AlignVCenter
}
Item {
id: sourceLine
y: 17
width: parent.width
height: 10
Text {
id: sourceLabel
width: 42
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Source", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 9
}
Rectangle {
anchors.left: sourceLabel.right
anchors.right: sourceCountText.left
anchors.leftMargin: 6
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
height: 3
radius: 2
color: theme.surfaceElevated
clip: true
Rectangle {
width: parent.width * root.boundedFraction(root.sourceDone, root.sourceTotal)
height: parent.height
radius: parent.radius
color: theme.accent
}
}
Text {
id: sourceCountText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 78
horizontalAlignment: Text.AlignRight
text: Math.round(root.sourceDone) + " / " + Math.round(root.sourceTotal)
color: theme.textMuted
font.pixelSize: 9
}
}
Item {
y: 29
width: parent.width
height: 10
Text {
id: overallLabel
width: 42
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Overall", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 9
}
Rectangle {
anchors.left: overallLabel.right
anchors.right: overallCountText.left
anchors.leftMargin: 6
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
height: 3
radius: 2
color: theme.surfaceElevated
clip: true
Rectangle {
width: parent.width * root.boundedFraction(root.overallDone, root.overallTotal)
height: parent.height
radius: parent.radius
color: theme.accent
opacity: 0.72
}
}
Text {
id: overallCountText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 78
horizontalAlignment: Text.AlignRight
text: Math.round(root.overallDone) + " / " + Math.round(root.overallTotal)
color: theme.textMuted
font.pixelSize: 9
}
}
}
@@ -348,6 +348,22 @@ Row {
visible: root.view && root.view.activeTab === "genres"
spacing: 8
height: parent.height
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "layout-grid"
label: QbzSession.tr("Explorer columns", QbzSession.trRev)
menuWidth: 190
options: [
QbzSession.tr("Genre", QbzSession.trRev),
QbzSession.tr("Year", QbzSession.trRev),
QbzSession.tr("Genre and year", QbzSession.trRev),
]
currentIndex: Math.max(0, ["genre", "year", "both"]
.indexOf(root.view.explorerColumns))
onSelected: function(i) {
root.view.setExplorerColumns(["genre", "year", "both"][i])
}
}
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "arrow-up-down"
@@ -97,6 +97,14 @@ Item {
// routes are name/key routes, so each entry is gated on its own datum.
readonly property bool canGoAlbum: (item.albumId || "") !== ""
readonly property bool canGoArtist: (item.artist || "") !== ""
// Local/network files may write a sidecar or their canonical tag layer;
// media-server rows use the same editor but are deliberately sidecar-only.
// Offline Qobuz copies are excluded: their cache file is not user-owned
// library metadata and has no stable server-album identity.
readonly property bool canEditMetadata: [
"local", "plex", "jellyfin", "subsonic", "navidrome",
"gonic", "airsonic", "astiga"
].indexOf(String(item.source || "").toLowerCase()) >= 0
// Zebra is painted HERE, not forwarded to the shared row. TrackRow tints
// its own background rectangle, which is `parent.width` wide — and this
@@ -248,6 +256,8 @@ Item {
]
if (root.canGoAlbum) m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" })
if (root.canGoArtist) m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
m.push({ "label": QbzSession.tr("Track info", QbzSession.trRev), "icon": "info", "action": "track-info" })
if (root.canEditMetadata) m.push({ "label": QbzSession.tr("Edit metadata", QbzSession.trRev), "icon": "pen-line", "action": "edit-metadata" })
return m
}
onPicked: function (a) {
@@ -255,7 +265,8 @@ Item {
// LOCAL-mode adds: one-element selection through the bulk entry
// point, which owns the only source-aware row -> ref resolver
// (see the file header).
else if (a === "add-to-playlist" || a === "add-to-mixtape") {
else if (a === "add-to-playlist" || a === "add-to-mixtape"
|| a === "edit-metadata" || a === "track-info") {
if (root.nativeActions)
QbzLocal.tracksNativeRowAction(root.nativeIndex, a)
else
@@ -206,6 +206,12 @@ Item {
// --------------------------- entry model ------------------------------
property var entries: []
property var alphaJumps: []
function alphaKey(value) {
var text = String(value || "").trim()
if (text.length === 0) return "#"
var first = text.charAt(0).toUpperCase()
return first >= "A" && first <= "Z" ? first : "#"
}
/// entry index -> index into `view.tracksVisible`, -1 for a group header.
/// Group headers inflate the entry indices, so reporting them straight
/// into the row array asked for the covers of tracks BELOW the ones on
@@ -233,6 +239,7 @@ Item {
var mode = view ? view.tracksGroup : "off"
var out = []
var jumps = []
var jumpSeen = ({})
var idx = []
var prev = null
for (var i = 0; i < rows.length; i++) {
@@ -242,7 +249,11 @@ Item {
: mode === "name" ? (t.title || "").slice(0, 1).toUpperCase()
: ""
if (mode !== "off" && key !== "" && key !== prev) {
if (mode === "name") jumps.push({ "letter": key, "index": out.length })
var letter = root.alphaKey(key)
if (jumpSeen[letter] !== true) {
jumpSeen[letter] = true
jumps.push({ "letter": letter, "index": out.length })
}
out.push({ "t": 0, "label": key })
idx.push(-1)
prev = key
@@ -407,7 +418,11 @@ Item {
ListView {
id: list
anchors.fill: parent
anchors.rightMargin: root.view.tracksGroup === "name" ? 20 : 0
// The tab's 32px content inset already is the rail lane. Keep
// rows aligned with every other Local Library surface, then
// place A-Z and the scrollbar *inside that existing gutter*
// below instead of shrinking the rows by another 42px.
anchors.rightMargin: 0
clip: true
cacheBuffer: 50 * 12
boundsBehavior: Flickable.StopAtBounds
@@ -566,11 +581,17 @@ Item {
}
}
QbzAlphaStrip {
visible: root.view.tracksGroup === "name" && root.alphaJumps.length > 0
visible: root.view.tracksGroup !== "off"
anchors.right: parent.right
// This parent ends 32px before the view edge. A negative
// margin consumes that outer gutter: the 18px alphabet ends
// 12px before the window edge, leaving a distinct 8px lane
// for the auto-hiding scrollbar at 4px.
anchors.rightMargin: -20
anchors.top: parent.top
anchors.bottom: parent.bottom
jumps: root.alphaJumps
completeAlphabet: true
onJump: function (ordinal, index) {
list.positionViewAtIndex(index, ListView.Beginning)
}
@@ -585,13 +606,7 @@ Item {
// empty channel with nothing in it (owner, 2026-08-16). The
// negative margin cancels the inset and leaves the same 4px
// gap the other lists have; the ROWS keep their inset.
//
// The "name" arm is untouched on purpose: there the alpha
// strip owns the right edge at the inset, and 22 is what
// clears it. Pushing the bar out there would jump it past the
// strip.
anchors.rightMargin: root.view.tracksGroup === "name"
? 22 : 4 - root.sideInset
anchors.rightMargin: 4 - root.sideInset
anchors.top: parent.top
anchors.bottom: parent.bottom
target: list
@@ -3,10 +3,9 @@
// folder is hidden (the ONLY hidden affordance on a folder — no eye glyph, no
// badge).
//
// BOTH click targets open the folder EDITOR (contract D7): the reference wires
// `open =>` and `edit =>` to the SAME callback, `edit-folder(folder.id)`
// (`:1364-1365`). You cannot enter a folder from the grid — folder navigation
// is the tree. Do not "fix" this into a drill-down.
// The body enters the folder in the current grid/list presentation. Only the
// top-right pencil opens the editor; conflating the two made ordinary folder
// navigation impossible (owner correction, 2026-08-28).
//
// Hover semantics are the reference's OR (`ta.has-hover || edit-ta.has-hover`,
// `:349`): the pencil's own MouseArea sits above the card's and would otherwise
@@ -25,8 +24,9 @@ Rectangle {
/// One `QbzPlaylistManager.foldersJson` entry (contract §4.1).
property var folder: ({})
/// Emitted by the body AND by the pencil — one action, two targets (D7).
/// Body navigation and pencil editing are deliberately distinct targets.
signal openRequested()
signal editRequested()
QbzTheme { id: theme }
@@ -114,7 +114,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openRequested()
onClicked: root.editRequested()
}
}
}
@@ -3,7 +3,7 @@
// forces `width: 184` (`:1382`) so every cell in the wrapping grid has the same
// pitch; the component's own intrinsic width is never used.
//
// Body and pencil both open the folder EDITOR (contract D7), like the card.
// The body enters the folder; only the pencil opens the folder editor.
//
// Two hover-revealed children — the count and the pencil — at opacity 0/1 with
// the reference's 150 ms fade. OPACITY, not visibility: the count keeps its
@@ -32,6 +32,7 @@ Rectangle {
/// One `QbzPlaylistManager.foldersJson` entry (contract §4.1).
property var folder: ({})
signal openRequested()
signal editRequested()
QbzTheme { id: theme }
@@ -121,7 +122,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openRequested()
onClicked: root.editRequested()
}
}
}
@@ -41,6 +41,8 @@ Item {
property string viewMode: "grid"
property bool folderMode: true
property bool foldersCollapsed: false
property string currentFolderId: ""
property string currentFolderName: ""
property bool canReorder: false
/// `managerJson.folderCount` — equal to `folders.length` by construction,
/// and what the COUNTER row reads (the section header reads the array).
@@ -53,6 +55,7 @@ Item {
QbzTheme { id: theme }
readonly property bool foldersVisible: !root.loading && root.folderMode
&& root.currentFolderId === ""
&& root.viewMode !== "tree" && (root.folders || []).length > 0
implicitHeight: 6 + col.height
@@ -76,9 +79,14 @@ Item {
// ONE string with TWO placeholders (`:1314`), not two strings.
// `String.replace` with a string pattern replaces only the
// FIRST occurrence, so the two calls chain.
text: QbzSession.tr("{} folders, {} playlists", QbzSession.trRev)
.replace("{}", root.folderCount)
.replace("{}", root.playlistCount)
text: root.currentFolderId !== ""
? (root.playlistCount === 1
? QbzSession.tr("1 playlist", QbzSession.trRev)
: QbzSession.tr("{} playlists", QbzSession.trRev)
.replace("{}", root.playlistCount))
: QbzSession.tr("{} folders, {} playlists", QbzSession.trRev)
.replace("{}", root.folderCount)
.replace("{}", root.playlistCount)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
@@ -108,6 +116,41 @@ Item {
}
}
// Grid/list folder drill-down. The manager keeps the selected view
// mode; this compact breadcrumb is the explicit route back to the
// folders + root-playlists surface.
Item {
visible: !root.loading && root.currentFolderId !== ""
width: parent.width
height: visible ? 40 : 0
QbzToolButton {
id: folderBack
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
name: "chevron-left"
label: QbzSession.tr("Back", QbzSession.trRev)
large: true
onClicked: QbzPlaylistManager.closeFolder()
}
Text {
anchors.left: folderBack.right
anchors.leftMargin: 12
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.currentFolderName
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
}
Item {
visible: !root.loading && root.currentFolderId !== ""
width: 1
height: visible ? 14 : 0
}
// ---- FOLDERS section ----------------------------------------------
// The WHOLE section disappears with zero folders — no header, no
// placeholder (`:1343`). It is also the only collapsible section, and
@@ -152,7 +195,8 @@ Item {
x: (index % folderGrid.cols) * 176
y: Math.floor(index / folderGrid.cols) * 166
folder: modelData
onOpenRequested: QbzFolderEdit.openEditor(String(modelData.id || ""))
onOpenRequested: QbzPlaylistManager.openFolder(String(modelData.id || ""))
onEditRequested: QbzFolderEdit.openEditor(String(modelData.id || ""))
}
}
}
@@ -177,7 +221,8 @@ Item {
y: Math.floor(index / folderChips.cols) * 60
width: 184
folder: modelData
onOpenRequested: QbzFolderEdit.openEditor(String(modelData.id || ""))
onOpenRequested: QbzPlaylistManager.openFolder(String(modelData.id || ""))
onEditRequested: QbzFolderEdit.openEditor(String(modelData.id || ""))
}
}
}
+4 -3
View File
@@ -244,9 +244,10 @@ impl qbz_album_bridge::QbzAlbum {
crate::offline_cache_qt::cache_album(album_id.to_string());
}
pub fn album_refresh_offline(self: Pin<&mut Self>, album_id: QString) {
// The menu's "Refresh offline copy" re-downloads the WHOLE album,
// not just the failed rows (Slint main.rs:12204).
crate::offline_cache_qt::redownload_album(album_id.to_string(), false);
// Compatibility entry point for older QML. Whole-album access now
// always runs the same preflight so the user chooses between repairing
// every copy and downloading only what is missing.
crate::offline_cache_qt::cache_album(album_id.to_string());
}
pub fn album_bulk_action(
self: Pin<&mut Self>,
+16 -18
View File
@@ -567,8 +567,8 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow {
/// Toggle a Local Library album using the card's denormalized display
/// snapshot. This is the Qt port of Slint's `toggle_album_favorite`, with the
/// source guard made explicit so a remote-only row cannot write a bogus local
/// favorite.
/// source guard made explicit so a Qobuz-offline row cannot write a duplicate
/// local-library favorite. Media-server rows are valid snapshots here.
pub(crate) fn toggle_album_favorite(
id: String,
title: String,
@@ -578,7 +578,7 @@ pub(crate) fn toggle_album_favorite(
) {
let sources: Vec<String> = serde_json::from_str(&sources_json).unwrap_or_default();
let Some(source) = album_favorite_source(&sources) else {
log::warn!("[qbz-qt] local favorite refused for remote-only album {id}");
log::warn!("[qbz-qt] local favorite refused for unsupported album {id}");
return;
};
let item = LocalFavItem {
@@ -929,14 +929,9 @@ pub fn add_to_mixtape(id: String) {
// Subsonic tokens carry their source prefix without credentials. The
// collection layer wins because this payload represents an ALBUM, not
// one disc/track.
artwork_url: tracks
.iter()
.find_map(|track| {
crate::local_rows::portable_artwork_ref(
track,
crate::local_rows::ArtworkScope::Album,
)
}),
artwork_url: tracks.iter().find_map(|track| {
crate::local_rows::portable_artwork_ref(track, crate::local_rows::ArtworkScope::Album)
}),
year: None,
track_count: Some(tracks.len() as i32),
};
@@ -1118,19 +1113,22 @@ mod version_tests {
};
let versions = vec![(
"subsonic:eternal-box".to_string(),
vec![track(910_001, 1, "dc-disc-one"), track(910_002, 2, "dc-disc-two")],
vec![
track(910_001, 1, "dc-disc-one"),
track(910_002, 2, "dc-disc-two"),
],
)];
let doc = genre_detail_doc("test:remote-disc-art", &versions, 0);
assert_eq!(doc.discs.len(), 2);
assert_eq!(doc.discs[0].art_key, "track:910001");
assert_eq!(doc.discs[1].art_key, "track:910002");
state(|s| {
assert_eq!(s.art_index["track:910001"].1, "dc-disc-one");
assert_eq!(s.art_index["track:910002"].1, "dc-disc-two");
s.art_index.remove("track:910001");
s.art_index.remove("track:910002");
s.art_index.remove(&doc.art_key);
with_art(|art| {
assert_eq!(art["track:910001"].1, "dc-disc-one");
assert_eq!(art["track:910002"].1, "dc-disc-two");
art.remove("track:910001");
art.remove("track:910002");
art.remove(&doc.art_key);
});
}
+31 -16
View File
@@ -109,7 +109,7 @@ pub fn load_albums_blocking() -> Result<Vec<AlbumRow>, String> {
/// Resolve which persisted local-favorite album ids still belong to the
/// active Local Library. A favorite is only a display snapshot; it is not
/// proof that the underlying local/Plex album still exists.
/// proof that the underlying local/media-server album still exists.
///
/// The common case checks the handful of favorite ids on one DB connection.
/// A `logical:*` id represents several physical copies and can only be
@@ -133,9 +133,18 @@ pub fn existing_favorite_album_ids_blocking(
}
}
for (id, _) in candidates
.iter()
.filter(|(_, source)| matches!(source.as_str(), "jellyfin" | "subsonic"))
{
if crate::media_servers_qt::album_tracks(id).is_some_and(|tracks| !tracks.is_empty()) {
existing.insert(id.clone());
}
}
let local_ids = candidates
.iter()
.filter(|(_, source)| source != "plex")
.filter(|(_, source)| source == "local")
.map(|(id, _)| id.clone())
.collect::<Vec<_>>();
if local_ids.is_empty() {
@@ -475,7 +484,8 @@ pub fn load_folders_blocking() -> Result<Vec<AlbumRow>, String> {
let exclude_network = crate::offline_fwd::exclude_network_folders_now();
let albums = with_db(|db| {
db.get_albums_with_full_filter(
/* include_hidden */ false, /* include_qobuz_downloads */ true,
/* include_hidden */ false,
/* include_qobuz_downloads */ true,
exclude_network,
)
})
@@ -523,10 +533,8 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
// SAME query the Albums tab runs (Plex-aware union when the toggle is on),
// so an artist's album count matches the grid the user sees.
let (artists, albums, custom) = with_db(|db| {
let artists = db.get_artists_with_filter(
/* include_qobuz_downloads */ true,
exclude_network,
)?;
let artists =
db.get_artists_with_filter(/* include_qobuz_downloads */ true, exclude_network)?;
let albums = if plex_on || remote_on {
db.get_albums_metadata_page(
0,
@@ -544,7 +552,8 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
.albums
} else {
db.get_albums_with_full_filter(
/* include_hidden */ false, /* include_qobuz_downloads */ true,
/* include_hidden */ false,
/* include_qobuz_downloads */ true,
exclude_network,
)?
};
@@ -638,10 +647,19 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
.into_iter()
.map(|m| {
let key = artist_key(&m.name);
if !m.image_path.is_empty() {
if let Some(t) =
crate::local_rows::art_token(Some(&m.image_source), &m.image_path)
{
let cached = crate::local_artist_images_qt::register(
&key,
&m.name,
!m.image_path.is_empty(),
);
let image_path = cached.as_deref().unwrap_or(&m.image_path);
let image_source = if cached.is_some() {
"local"
} else {
m.image_source.as_str()
};
if !image_path.is_empty() {
if let Some(t) = crate::local_rows::art_token(Some(image_source), image_path) {
art.insert(key.clone(), t);
}
}
@@ -1086,10 +1104,7 @@ fn fetch_source_album_tracks_blocking(id: &str) -> Vec<LocalTrack> {
/// The routed Local Library detail, constrained to the media funnel that was
/// active at the click site. A logical card may represent several sources;
/// opening it must not make filtered-out physical versions selectable again.
pub fn load_album_detail_filtered_blocking(
id: &str,
filter_json: &str,
) -> Option<AlbumDetailDoc> {
pub fn load_album_detail_filtered_blocking(id: &str, filter_json: &str) -> Option<AlbumDetailDoc> {
let mut tracks = fetch_album_tracks_blocking(id);
if tracks.is_empty() {
return None;
+15 -11
View File
@@ -12,12 +12,12 @@ use qbz_local_catalog::{
ActiveCatalog, AlbumCursor, AlbumRecord, BootstrapLayout, QueryDescriptor, SourceKey,
SourceKind, TrackGroup, TrackSort,
};
use qbz_source::SourceId;
use serde::Serialize;
use crate::local_bridge::ui;
use crate::local_rows::{
album_favorite_source, badge_source, badge_source_raw, detail_of, total_duration, AlbumRow,
album_favorite_source, badge_source, badge_source_raw, catalog_album_sources, detail_of,
total_duration, AlbumRow,
};
const PAGE_ENTRIES: usize = 100;
@@ -243,9 +243,14 @@ pub(crate) fn requested() -> bool {
if SESSION_FAILED.load(Ordering::Acquire) {
return false;
}
std::env::var("QBZ_LOCAL_CATALOG_ALBUMS")
!std::env::var("QBZ_LOCAL_CATALOG_ALBUMS")
.ok()
.is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"))
.is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
)
})
}
pub(crate) fn reset(
@@ -1018,9 +1023,11 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
let id = album_action_id(record);
let art_key = format!("catalog-album:{}", record.edition_id);
if !record.artwork_token.is_empty() {
if let Some(source) = SourceId::from_word(&record.artwork_source) {
if let Some(reference) =
crate::local_rows::art_token(Some(&record.artwork_source), &record.artwork_token)
{
crate::local_state::with_art(|art| {
art.insert(art_key.clone(), (source, record.artwork_token.clone()));
art.insert(art_key.clone(), reference);
});
}
}
@@ -1037,11 +1044,7 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
);
let source = badge_source(Some(record.source_raw_or_kind()));
let source_raw = badge_source_raw(Some(record.source_raw_or_kind()));
let sources = vec![if source_raw.is_empty() {
source.clone()
} else {
source_raw.clone()
}];
let sources = catalog_album_sources(record);
let favoriteable = album_favorite_source(&sources).is_some();
NativeAlbum {
row: AlbumRow {
@@ -1413,6 +1416,7 @@ mod tests {
source: SourceKind::Local,
native_album_id: format!("album-{index:05}"),
source_raw: "local".to_string(),
source_words: vec!["local".to_string()],
title: format!("Album {index:05}"),
artist: format!("Artist {:03}", index % 17),
all_artists: format!("Artist {:03}", index % 17),
+456
View File
@@ -0,0 +1,456 @@
//! Visible-window artist portrait enrichment for Local Library.
//!
//! The Artists rail can contain hundreds or thousands of tag spellings. A
//! request-per-row loader is both slow and hostile to provider rate limits, so
//! this module is deliberately tied to the existing artwork window:
//!
//! - mapping a row only registers its `(art key, display name)` and performs
//! one profile-wide cache seed;
//! - only keys reported as visible are queued;
//! - one sequential worker deduplicates normalized names and resolves at most
//! 50 artists per process;
//! - positive and negative results are persisted. Negative results have a
//! seven-day TTL, preventing every revisit from repeating misses;
//! - Qobuz, Last.fm and Discogs are image providers. MusicBrainz is used only
//! for conservative canonical-name resolution; it does not host portraits.
//!
//! A track/album cover already indexed for the artist remains the immediate
//! fallback and does not spend a network request. Custom/cached portraits win
//! over that fallback on every later mapping pass.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::Duration;
use qbz_integrations::musicbrainz::MatchConfidence;
use qbz_library::LibraryDatabase;
use qbz_source::SourceId;
use tokio::sync::mpsc;
const MAX_REMOTE_ARTISTS_PER_PROCESS: usize = 50;
const NEGATIVE_TTL_SECS: i64 = 7 * 24 * 60 * 60;
const BETWEEN_ARTISTS: Duration = Duration::from_millis(1_200);
const LASTFM_PLACEHOLDER_HASH: &str = "2a96cbd8b46e442fc41c2b86b821562f";
#[derive(Clone)]
struct Candidate {
key: String,
name: String,
normalized: String,
profile: PathBuf,
needs_remote: bool,
}
#[derive(Default)]
struct Registry {
profile: Option<PathBuf>,
images: HashMap<String, String>,
candidates: HashMap<String, Candidate>,
}
#[derive(Clone)]
struct Resolution {
url: String,
source: &'static str,
canonical_name: Option<String>,
}
#[derive(Default)]
struct StoredResolution {
preferred_path: Option<String>,
fresh: bool,
}
static REGISTRY: LazyLock<Mutex<Registry>> = LazyLock::new(|| Mutex::new(Registry::default()));
static ATTEMPTED: LazyLock<Mutex<HashSet<(PathBuf, String)>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
static QUEUE: OnceLock<mpsc::UnboundedSender<Candidate>> = OnceLock::new();
static REMOTE_ATTEMPTS: AtomicUsize = AtomicUsize::new(0);
fn normalize(name: &str) -> String {
crate::local_artist_match::normalize_artist(name)
}
fn load_cached_images(profile: &Path) -> HashMap<String, String> {
if !profile.is_file() {
return HashMap::new();
}
let Ok(db) = LibraryDatabase::open(profile) else {
return HashMap::new();
};
db.get_all_artist_image_urls()
.unwrap_or_default()
.into_iter()
.filter_map(|(name, path)| {
let key = normalize(&name);
(!key.is_empty() && !path.trim().is_empty()).then_some((key, path))
})
.collect()
}
/// Register one artist row and return a previously cached/custom portrait.
/// `has_fallback` means the catalog already carries an album/server image, so
/// the row is not a remote-fetch candidate even though a cached portrait may
/// still override that fallback.
pub(crate) fn register(key: &str, name: &str, has_fallback: bool) -> Option<String> {
let profile = crate::local_state::db_path()?;
let normalized = normalize(name);
if key.is_empty() || normalized.is_empty() {
return None;
}
let mut registry = REGISTRY.lock().unwrap_or_else(|error| error.into_inner());
if registry.profile.as_ref() != Some(&profile) {
registry.images = load_cached_images(&profile);
registry.candidates.clear();
registry.profile = Some(profile.clone());
}
let cached = registry.images.get(&normalized).cloned();
registry.candidates.insert(
key.to_string(),
Candidate {
key: key.to_string(),
name: name.to_string(),
normalized,
profile,
needs_remote: cached.is_none() && !has_fallback,
},
);
cached
}
/// Queue only the missing artist keys in the mounted artwork window.
pub(crate) fn request_visible(keys: &[String]) {
if crate::offline_fwd::exclude_network_folders_now() {
return;
}
ensure_worker();
let Some(queue) = QUEUE.get() else {
return;
};
let candidates = {
let registry = REGISTRY.lock().unwrap_or_else(|error| error.into_inner());
keys.iter()
.filter_map(|key| registry.candidates.get(key))
.filter(|candidate| candidate.needs_remote)
.filter(|candidate| candidate.normalized != "various artists")
.cloned()
.collect::<Vec<_>>()
};
for candidate in candidates {
let identity = (candidate.profile.clone(), candidate.normalized.clone());
let inserted = ATTEMPTED
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(identity);
if inserted {
let _ = queue.send(candidate);
}
}
}
fn ensure_worker() {
if QUEUE.get().is_some() {
return;
}
let (tx, rx) = mpsc::unbounded_channel();
if QUEUE.set(tx).is_ok() {
crate::spawn(worker(rx));
}
}
async fn worker(mut rx: mpsc::UnboundedReceiver<Candidate>) {
let mut first_remote = true;
while let Some(candidate) = rx.recv().await {
// A user/profile switch makes the queued row stale.
if crate::local_state::db_path().as_ref() != Some(&candidate.profile) {
continue;
}
if crate::offline_fwd::exclude_network_folders_now() {
ATTEMPTED
.lock()
.unwrap_or_else(|error| error.into_inner())
.remove(&(candidate.profile.clone(), candidate.normalized.clone()));
continue;
}
let stored_candidate = candidate.clone();
let stored = tokio::task::spawn_blocking(move || read_stored(&stored_candidate))
.await
.unwrap_or_default();
if let Some(path) = stored.preferred_path {
publish(&candidate, path).await;
continue;
}
if stored.fresh {
continue;
}
if REMOTE_ATTEMPTS.fetch_add(1, Ordering::AcqRel) >= MAX_REMOTE_ARTISTS_PER_PROCESS {
continue;
}
if !first_remote {
tokio::time::sleep(BETWEEN_ARTISTS).await;
}
first_remote = false;
let resolution = resolve_remote(&candidate.name).await;
let persisted_candidate = candidate.clone();
let persisted_resolution = resolution.clone();
let preferred = tokio::task::spawn_blocking(move || {
persist(&persisted_candidate, persisted_resolution.as_ref())
})
.await
.ok()
.flatten();
if let Some(path) = preferred.or_else(|| resolution.map(|value| value.url)) {
publish(&candidate, path).await;
}
}
}
fn open_profile(profile: &Path) -> Option<LibraryDatabase> {
if let Some(parent) = profile.parent() {
std::fs::create_dir_all(parent).ok()?;
}
LibraryDatabase::open(profile).ok()
}
fn read_stored(candidate: &Candidate) -> StoredResolution {
let Some(db) = open_profile(&candidate.profile) else {
return StoredResolution::default();
};
let info = db.get_artist_image(&candidate.name).ok().flatten();
let preferred_path = info.as_ref().and_then(|value| {
value
.custom_image_path
.clone()
.or_else(|| value.image_url.clone())
.filter(|path| !path.trim().is_empty())
});
let fresh = preferred_path.is_some()
|| db
.artist_image_resolution_is_fresh(&candidate.name, NEGATIVE_TTL_SECS)
.unwrap_or(false);
StoredResolution {
preferred_path,
fresh,
}
}
fn persist(candidate: &Candidate, resolution: Option<&Resolution>) -> Option<String> {
let db = open_profile(&candidate.profile)?;
match resolution {
Some(value) => db
.cache_artist_image_with_canonical(
&candidate.name,
Some(&value.url),
value.source,
None,
value.canonical_name.as_deref(),
)
.ok()?,
None => db
.cache_artist_image_with_canonical(&candidate.name, None, "miss", None, None)
.ok()?,
}
// Re-read because a custom image may have been selected while the remote
// lookup was in flight; the upsert preserves it and it must still win.
let info = db.get_artist_image(&candidate.name).ok().flatten()?;
info.custom_image_path
.or(info.image_url)
.filter(|path| !path.trim().is_empty())
}
async fn publish(candidate: &Candidate, path: String) {
if crate::local_state::db_path().as_ref() != Some(&candidate.profile) {
return;
}
let keys = {
let mut registry = REGISTRY.lock().unwrap_or_else(|error| error.into_inner());
if registry.profile.as_ref() != Some(&candidate.profile) {
return;
}
registry
.images
.insert(candidate.normalized.clone(), path.clone());
let keys = registry
.candidates
.values_mut()
.filter(|entry| entry.normalized == candidate.normalized)
.map(|entry| {
entry.needs_remote = false;
entry.key.clone()
})
.collect::<Vec<_>>();
keys
};
if keys.is_empty() {
return;
}
crate::local_state::with_art(|art| {
for key in &keys {
art.insert(key.clone(), (SourceId::LOCAL, path.clone()));
}
});
let resolve_keys = keys.clone();
let Some(window) = tokio::task::spawn_blocking(move || {
crate::local_artwork::resolve_window_blocking(resolve_keys)
})
.await
.ok() else {
return;
};
crate::local_bridge_ops::emit_artwork(window.hits);
let cold =
crate::local_artwork::stream_cold(window.cold, crate::local_bridge_ops::emit_artwork_one);
let remote = async {
let fetched = crate::local_artwork::fetch_plex_misses(window.plex_misses).await;
crate::local_bridge_ops::emit_artwork(fetched);
};
tokio::join!(cold, remote);
}
async fn resolve_remote(name: &str) -> Option<Resolution> {
let original_key = normalize(name);
let app = crate::app();
if let Ok(page) = app.core().search_artists(name, 5, 0, None).await {
if let Some(value) = page
.items
.into_iter()
.find(|artist| normalize(&artist.name) == original_key)
{
if let Some(url) = value.image.as_ref().and_then(|image| image.for_px(150)) {
if useful_url(url) {
return Some(Resolution {
url: url.clone(),
source: "qobuz",
canonical_name: Some(value.name),
});
}
}
}
}
let mut canonical_name = name.to_string();
let mut canonical_key = original_key.clone();
let mut mbid = None;
if let Ok(Ok(lastfm)) = tokio::time::timeout(
Duration::from_secs(10),
qbz_integrations::LastFmClient::new().get_artist_info(name),
)
.await
{
let lastfm_key = normalize(&lastfm.name);
if lastfm_key == original_key {
canonical_name = lastfm.name.clone();
canonical_key = lastfm_key;
mbid = lastfm.mbid.clone();
if let Some(url) = lastfm.image.filter(|url| useful_url(url)) {
return Some(Resolution {
url,
source: "lastfm",
canonical_name: Some(lastfm.name),
});
}
}
}
if mbid.is_none() && app.core().musicbrainz_is_enabled().await {
if let Ok(Some(resolved)) = app.core().musicbrainz_resolve_artist(name).await {
if matches!(
resolved.confidence,
MatchConfidence::Exact | MatchConfidence::High | MatchConfidence::Medium
) && normalize(&resolved.name) == original_key
{
canonical_key = normalize(&resolved.name);
canonical_name = resolved.name;
mbid = Some(resolved.mbid);
}
}
}
if canonical_name != name {
if let Ok(page) = app.core().search_artists(&canonical_name, 5, 0, None).await {
if let Some(value) = page
.items
.into_iter()
.find(|artist| normalize(&artist.name) == canonical_key)
{
if let Some(url) = value.image.as_ref().and_then(|image| image.for_px(150)) {
if useful_url(url) {
return Some(Resolution {
url: url.clone(),
source: "qobuz",
canonical_name: Some(value.name),
});
}
}
}
}
}
if let Ok(found) = qbz_integrations::DiscogsClient::new()
.search_artist(&canonical_name)
.await
{
if let Some(value) = found.results.into_iter().find(|value| {
value.result_type == "artist" && normalize_discogs_artist(&value.title) == canonical_key
}) {
if let Some(url) = value
.cover_image
.or(value.thumb)
.filter(|url| useful_url(url))
{
return Some(Resolution {
url,
source: "discogs",
canonical_name: Some(canonical_name),
});
}
}
}
let _ = mbid; // retained as identity evidence; no raster portrait endpoint.
None
}
fn useful_url(url: &str) -> bool {
let url = url.trim();
!url.is_empty() && !url.contains("spacer.gif") && !url.contains(LASTFM_PLACEHOLDER_HASH)
}
fn normalize_discogs_artist(value: &str) -> String {
let value = value.trim();
let without_suffix = value
.strip_suffix(')')
.and_then(|prefix| prefix.rsplit_once(" ("))
.filter(|(_, suffix)| suffix.chars().all(|ch| ch.is_ascii_digit()))
.map(|(name, _)| name)
.unwrap_or(value);
normalize(without_suffix)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discogs_numeric_disambiguation_is_identity_not_a_name_change() {
assert_eq!(normalize_discogs_artist("Genesis (2)"), "genesis");
assert_eq!(normalize_discogs_artist("!!!"), "");
assert_eq!(normalize_discogs_artist("blink-182"), "blink 182");
}
#[test]
fn provider_placeholders_are_never_persisted_as_portraits() {
assert!(!useful_url(
"https://lastfm/2a96cbd8b46e442fc41c2b86b821562f.png"
));
assert!(!useful_url("https://discogs/spacer.gif"));
assert!(useful_url("https://static.qobuz.com/portrait.jpg"));
}
}
+29 -17
View File
@@ -12,12 +12,12 @@ use qbz_local_catalog::{
ActiveCatalog, AlbumCursor, AlbumRecord, ArtistCursor, ArtistRecord, BootstrapLayout,
QueryDescriptor, SourceKey, SourceKind,
};
use qbz_source::SourceId;
use serde::Serialize;
use crate::local_bridge::ui;
use crate::local_rows::{
badge_source, badge_source_raw, detail_of, total_duration, AlbumRow, ArtistRow,
badge_source, badge_source_raw, catalog_album_sources, detail_of, total_duration, AlbumRow,
ArtistRow,
};
const PAGE_ENTRIES: usize = 100;
@@ -279,9 +279,14 @@ pub(crate) fn requested() -> bool {
if SESSION_FAILED.load(Ordering::Acquire) {
return false;
}
std::env::var("QBZ_LOCAL_CATALOG_ARTISTS")
!std::env::var("QBZ_LOCAL_CATALOG_ARTISTS")
.ok()
.is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"))
.is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
)
})
}
pub(crate) fn reset(search: String, sort: String, filter_json: String) -> bool {
@@ -1318,12 +1323,21 @@ fn finish_detail_anchors(
fn map_artist(record: &ArtistRecord) -> NativeArtist {
let art_key = format!("catalog-artist:{}", record.artist_key);
if !record.artwork_token.is_empty() {
if let Some(source) = SourceId::from_word(&record.artwork_source) {
crate::local_state::with_art(|art| {
art.insert(art_key.clone(), (source, record.artwork_token.clone()));
});
}
let cached = crate::local_artist_images_qt::register(
&art_key,
&record.display_name,
!record.artwork_token.is_empty(),
);
let reference = cached
.as_deref()
.and_then(|path| crate::local_rows::art_token(Some("local"), path))
.or_else(|| {
crate::local_rows::art_token(Some(&record.artwork_source), &record.artwork_token)
});
if let Some(reference) = reference {
crate::local_state::with_art(|art| {
art.insert(art_key.clone(), reference);
});
}
NativeArtist {
row: ArtistRow {
@@ -1347,9 +1361,11 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
let id = album_action_id(record);
let art_key = format!("catalog-album:{}", record.edition_id);
if !record.artwork_token.is_empty() {
if let Some(source) = SourceId::from_word(&record.artwork_source) {
if let Some(reference) =
crate::local_rows::art_token(Some(&record.artwork_source), &record.artwork_token)
{
crate::local_state::with_art(|art| {
art.insert(art_key.clone(), (source, record.artwork_token.clone()));
art.insert(art_key.clone(), reference);
});
}
}
@@ -1366,11 +1382,7 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
);
let source = badge_source(Some(source_word(record)));
let source_raw = badge_source_raw(Some(source_word(record)));
let sources = vec![if source_raw.is_empty() {
source.clone()
} else {
source_raw.clone()
}];
let sources = catalog_album_sources(record);
let favoriteable = crate::local_rows::album_favorite_source(&sources).is_some();
NativeAlbum {
row: AlbumRow {
+2 -10
View File
@@ -73,7 +73,7 @@ use std::sync::{Arc, LazyLock, Mutex, RwLock};
use qbz_source::{ArtRef, ArtSize};
use crate::artwork_qt;
use crate::local_state::state;
use crate::local_state::artwork_sources;
/// Memo ceiling, mirroring `artwork_qt::MEMO_CAP`: an accelerator, cleared
/// wholesale rather than carrying LRU bookkeeping.
@@ -129,15 +129,7 @@ pub struct ArtworkWindow {
/// Keys with no cover are dropped, so the QML map only ever grows with real
/// hits.
pub fn resolve_window_blocking(keys: Vec<String>) -> ArtworkWindow {
let sources: Vec<(String, qbz_source::SourceId, String)> = state(|s| {
keys.iter()
.filter_map(|k| {
s.art_index
.get(k)
.map(|(src, tok)| (k.clone(), *src, tok.clone()))
})
.collect()
});
let sources = artwork_sources(&keys);
let mut hits = Vec::with_capacity(sources.len());
let mut plex_misses = Vec::new();
let mut cold = Vec::new();
+70 -47
View File
@@ -48,9 +48,17 @@ pub mod qbz_local {
#[qproperty(bool, local_available)]
/// {albums, artists, folders, tracks, plexTracks} — the tab badges.
#[qproperty(QString, local_counts_json)]
/// Derived-catalog build/reconciliation progress. JSON:
/// `{active,phase,source,sourceInstance,sourceDone,sourceTotal,
/// overallDone,overallTotal,sourceIndex,sourceCount}`.
/// Counters only: observing this never materializes paged rows.
#[qproperty(QString, local_catalog_progress_json)]
/// Album identity: "folder" | "metadata" (persisted; shared with
/// the Slint frontend's locallibrary_ui.json).
#[qproperty(QString, local_album_mode)]
/// Library Explorer columns: "genre" | "year" | "both". Persisted
/// in locallibrary_ui.json and seeded before the view mounts.
#[qproperty(QString, local_explorer_columns)]
// --- Albums tab ---------------------------------------------------
#[qproperty(bool, local_albums_loading)]
#[qproperty(QString, local_albums_error)]
@@ -108,6 +116,11 @@ pub mod qbz_local {
/// Bounded A-Z jump metadata (`[{letter,index}]`), never track rows.
#[qproperty(QString, local_tracks_native_jumps_json)]
#[qproperty(QString, local_tracks_native_error)]
// --- Physical media info modal -----------------------------------
/// Local Library media facts, distinct from Qobuz record info.
#[qproperty(bool, local_media_info_open)]
#[qproperty(bool, local_media_info_loading)]
#[qproperty(QString, local_media_info_json)]
// --- Local album detail (the album pane) ---------------------------
#[qproperty(bool, local_album_loading)]
/// {album:{...}, tracks:[...]} — "" while nothing is open.
@@ -254,6 +267,10 @@ pub mod qbz_local {
#[qinvokable]
fn set_album_mode(self: Pin<&mut QbzLocal>, mode: QString);
/// Persist the active Library Explorer column arrangement.
#[qinvokable]
fn set_explorer_columns(self: Pin<&mut QbzLocal>, mode: QString);
// --- Albums tab (native paged model) ------------------------------
/// Reset the immutable SQL descriptor and publish its first page.
#[qinvokable]
@@ -358,6 +375,10 @@ pub mod qbz_local {
ids_json: QString,
action: QString,
);
#[qinvokable]
fn close_media_info(self: Pin<&mut QbzLocal>);
#[qinvokable]
fn copy_media_info(self: Pin<&mut QbzLocal>, value: QString);
/// Row body: select a folder and load its detail pane.
#[qinvokable]
fn select_folder(self: Pin<&mut QbzLocal>, path: QString);
@@ -370,11 +391,7 @@ pub mod qbz_local {
/// Open a logical album while preserving the Local Library funnel at
/// the click site; filtered-out physical copies stay inaccessible.
#[qinvokable]
fn open_album_filtered(
self: Pin<&mut QbzLocal>,
id: QString,
filter_json: QString,
);
fn open_album_filtered(self: Pin<&mut QbzLocal>, id: QString, filter_json: QString);
/// Close the album pane (back to the grid).
#[qinvokable]
fn close_album(self: Pin<&mut QbzLocal>);
@@ -404,11 +421,7 @@ pub mod qbz_local {
/// navigating away. Results are id-keyed so concurrent visible albums
/// may finish in any order without publishing over each other.
#[qinvokable]
fn genre_album_tracks(
self: Pin<&mut QbzLocal>,
id: QString,
filter_json: QString,
);
fn genre_album_tracks(self: Pin<&mut QbzLocal>, id: QString, filter_json: QString);
/// Genres details version picker: switch one expanded album without
/// retargeting the routed AlbumView or re-querying the database.
#[qinvokable]
@@ -424,11 +437,7 @@ pub mod qbz_local {
);
/// Play/enqueue the currently selected physical copy in AlbumView.
#[qinvokable]
fn album_selected_action(
self: Pin<&mut QbzLocal>,
action: QString,
track_id: QString,
);
fn album_selected_action(self: Pin<&mut QbzLocal>, action: QString, track_id: QString);
/// Album header pencil — LOGGED SEAM (no tag-editor modal yet).
#[qinvokable]
fn album_edit_tags(self: Pin<&mut QbzLocal>, id: QString);
@@ -550,7 +559,7 @@ pub mod qbz_local {
filter_json: QString,
mode: QString,
);
/// Heart a genuine local/Plex logical album using the display
/// Heart a genuine local/media-server logical album using the display
/// snapshot already rendered by the Local Library card.
#[qinvokable]
fn album_toggle_favorite(
@@ -665,11 +674,7 @@ pub mod qbz_local {
/// off the Qt thread; key/path return with the result as generation
/// guards for QML.
#[qinvokable]
fn album_header_atmosphere(
self: Pin<&mut QbzLocal>,
key: QString,
path: QString,
);
fn album_header_atmosphere(self: Pin<&mut QbzLocal>, key: QString, path: QString);
/// (key, "file://…") — id-keyed so a cover can never land on the
/// wrong row.
#[qsignal]
@@ -714,7 +719,9 @@ use qbz_local::QbzLocal;
pub struct QbzLocalRust {
local_available: bool,
local_counts_json: QString,
local_catalog_progress_json: QString,
local_album_mode: QString,
local_explorer_columns: QString,
local_albums_loading: bool,
local_albums_error: QString,
local_albums_json: QString,
@@ -750,6 +757,9 @@ pub struct QbzLocalRust {
local_tracks_native_selected_count: i64,
local_tracks_native_jumps_json: QString,
local_tracks_native_error: QString,
local_media_info_open: bool,
local_media_info_loading: bool,
local_media_info_json: QString,
local_album_loading: bool,
local_album_json: QString,
local_track_artwork: bool,
@@ -788,7 +798,9 @@ impl Default for QbzLocalRust {
Self {
local_available: false,
local_counts_json: QString::from("{}"),
local_catalog_progress_json: QString::from("{\"active\":false}"),
local_album_mode: QString::from("folder"),
local_explorer_columns: QString::from("genre"),
local_albums_loading: false,
local_albums_error: QString::default(),
local_albums_json: QString::from("[]"),
@@ -824,6 +836,9 @@ impl Default for QbzLocalRust {
local_tracks_native_selected_count: 0,
local_tracks_native_jumps_json: QString::from("[]"),
local_tracks_native_error: QString::default(),
local_media_info_open: false,
local_media_info_loading: false,
local_media_info_json: QString::from("{}"),
local_album_loading: false,
local_album_json: QString::from(""),
local_track_artwork: false,
@@ -926,6 +941,7 @@ impl qbz_local::QbzLocal {
// read and preserved on disk but never reached the UI, so the Tracks
// tab silently reset to "No grouping" on every launch.
let mode = lib::album_mode();
let explorer_columns = lib::explorer_columns();
let sort = lib::tracks_sort();
let group = lib::tracks_group();
let tracks_filter = lib::tracks_filter();
@@ -933,6 +949,8 @@ impl qbz_local::QbzLocal {
ui(move |mut b| {
b.as_mut()
.set_local_album_mode(QString::from(mode.as_str()));
b.as_mut()
.set_local_explorer_columns(QString::from(explorer_columns.as_str()));
b.as_mut()
.set_local_tracks_sort(QString::from(sort.as_str()));
b.as_mut()
@@ -974,6 +992,16 @@ impl qbz_local::QbzLocal {
invalidate_artists();
}
pub fn set_explorer_columns(self: Pin<&mut Self>, mode: QString) {
lib::set_explorer_columns(&mode.to_string());
let published = lib::explorer_columns();
ui(move |mut bridge| {
bridge
.as_mut()
.set_local_explorer_columns(QString::from(published.as_str()));
});
}
pub fn albums_native_reset(
self: Pin<&mut Self>,
search: QString,
@@ -1160,11 +1188,7 @@ impl qbz_local::QbzLocal {
open_album_by_id(id.to_string());
}
pub fn open_album_filtered(
self: Pin<&mut Self>,
id: QString,
filter_json: QString,
) {
pub fn open_album_filtered(self: Pin<&mut Self>, id: QString, filter_json: QString) {
open_album_by_id_filtered(id.to_string(), filter_json.to_string());
}
@@ -1249,11 +1273,7 @@ impl qbz_local::QbzLocal {
filter_json: QString,
mode: QString,
) {
let (id, filter_json, mode) = (
id.to_string(),
filter_json.to_string(),
mode.to_string(),
);
let (id, filter_json, mode) = (id.to_string(), filter_json.to_string(), mode.to_string());
let runtime = crate::app();
crate::spawn(async move {
lib::enqueue_album_filtered(&runtime, id, filter_json, mode).await;
@@ -1496,6 +1516,9 @@ impl qbz_local::QbzLocal {
if keys.is_empty() {
return;
}
// Artist portrait enrichment is driven by this same mounted window:
// never issue provider requests for every row in the catalog.
crate::local_artist_images_qt::request_visible(&keys);
crate::spawn(async move {
// Phase 1 (cheap: memos + one stat per key, no decode) — emit
// everything already on disk right away.
@@ -1525,11 +1548,7 @@ impl qbz_local::QbzLocal {
});
}
pub fn album_header_atmosphere(
self: Pin<&mut Self>,
key: QString,
path: QString,
) {
pub fn album_header_atmosphere(self: Pin<&mut Self>, key: QString, path: QString) {
let key = key.to_string();
let path = path.to_string();
if key.is_empty() || path.is_empty() {
@@ -1587,6 +1606,14 @@ impl qbz_local::QbzLocal {
crate::local_bulk::bulk_action(scope.to_string(), ids_json.to_string(), action.to_string());
}
pub fn close_media_info(self: Pin<&mut Self>) {
crate::local_media_info_qt::close();
}
pub fn copy_media_info(self: Pin<&mut Self>, value: QString) {
crate::local_media_info_qt::copy(value.to_string());
}
// --- Local album actions -------------------------------------------------
pub fn clear_pending_artist(self: Pin<&mut Self>) {
@@ -1613,11 +1640,7 @@ impl qbz_local::QbzLocal {
QString::from(crate::local_albums::artist_album_ids(&artist.to_string()).as_str())
}
pub fn genre_album_tracks(
_self: Pin<&mut Self>,
id: QString,
filter_json: QString,
) {
pub fn genre_album_tracks(_self: Pin<&mut Self>, id: QString, filter_json: QString) {
let started = std::time::Instant::now();
let id = id.to_string();
let filter_json = filter_json.to_string();
@@ -1778,7 +1801,11 @@ impl qbz_local::QbzLocal {
id,
result,
filter_json,
if raw_cache_hit { "filter-cache" } else { "query" },
if raw_cache_hit {
"filter-cache"
} else {
"query"
},
started,
);
}
@@ -1872,11 +1899,7 @@ impl qbz_local::QbzLocal {
);
}
pub fn album_selected_action(
self: Pin<&mut Self>,
action: QString,
track_id: QString,
) {
pub fn album_selected_action(self: Pin<&mut Self>, action: QString, track_id: QString) {
crate::local_album_actions::selected_album_action(
action.to_string(),
track_id.to_string().parse::<i64>().ok(),
+21
View File
@@ -131,6 +131,9 @@ pub fn bulk_action(scope: String, ids_json: String, action: String) {
log::debug!("[qbz-qt] local bulk {action}: empty {scope} selection, ignored");
return;
}
if matches!(action.as_str(), "track-info" | "album-info") {
crate::local_media_info_qt::begin();
}
crate::spawn(async move {
let rows = tokio::task::spawn_blocking(move || resolve_blocking(&scope, &ids))
.await
@@ -190,6 +193,24 @@ pub(crate) fn resolve_album_ids_blocking(ids: &[String]) -> Vec<LocalTrack> {
/// and keeps it while a picker is still open.
pub(crate) async fn apply(rows: Vec<LocalTrack>, action: &str) -> bool {
match action {
"track-info" => {
if let Some(track) = rows.into_iter().next() {
crate::local_media_info_qt::open_track(track);
} else {
crate::local_media_info_qt::open_empty("track");
}
false
}
"album-info" => {
crate::local_media_info_qt::open_album(rows);
false
}
"edit-metadata" => {
if let Some(track) = rows.into_iter().next() {
crate::tag_editor_qt::open_track(track);
}
false
}
"queue" | "play-next" | "play-later" => {
if rows.is_empty() {
return false;
+80 -2
View File
@@ -9,9 +9,72 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use qbz_local_catalog::{
ActiveCatalog, BootstrapLayout, BootstrapOutcome, CatalogError, ProjectionOutcome,
ActiveCatalog, BootstrapLayout, BootstrapOutcome, BootstrapProgress, CatalogError,
ProjectionOutcome, ProjectionProgress,
};
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct CatalogProgressDoc<'a> {
active: bool,
phase: &'a str,
source: &'a str,
source_instance: &'a str,
source_done: u64,
source_total: u64,
overall_done: u64,
overall_total: u64,
source_index: usize,
source_count: usize,
}
fn publish_progress(doc: CatalogProgressDoc<'_>) {
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{\"active\":false}".to_string());
crate::local_bridge::ui(move |mut bridge| {
bridge
.as_mut()
.set_local_catalog_progress_json(cxx_qt_lib::QString::from(json.as_str()));
});
}
fn publish_bootstrap_progress(progress: &BootstrapProgress) {
publish_progress(CatalogProgressDoc {
active: true,
phase: "bootstrap",
source: progress.source.source.as_str(),
source_instance: &progress.source.source_instance,
source_done: progress.committed_rows,
source_total: progress.source_rows_total,
overall_done: progress.overall_rows_written,
overall_total: progress.overall_rows_total,
source_index: progress.source_index,
source_count: progress.source_count,
});
}
fn publish_projection_progress(progress: &ProjectionProgress) {
publish_progress(CatalogProgressDoc {
active: true,
phase: "reconcile",
source: progress.source.source.as_str(),
source_instance: &progress.source.source_instance,
source_done: progress.rows_written,
source_total: progress.source_rows_total,
overall_done: progress.overall_rows_written,
overall_total: progress.overall_rows_total,
source_index: progress.source_index,
source_count: progress.source_count,
});
}
fn clear_progress() {
crate::local_bridge::ui(|mut bridge| {
bridge
.as_mut()
.set_local_catalog_progress_json(cxx_qt_lib::QString::from("{\"active\":false}"));
});
}
enum RefreshOutcome {
Bootstrap(BootstrapOutcome),
Projection(ProjectionOutcome),
@@ -63,6 +126,7 @@ pub(crate) fn start() {
&locations,
&CANCELLED,
|progress| {
publish_bootstrap_progress(progress);
let source_changed = last_source.as_ref() != Some(&progress.source);
if source_changed
|| progress.source_complete
@@ -90,6 +154,7 @@ pub(crate) fn start() {
&locations,
&CANCELLED,
|progress| {
publish_projection_progress(progress);
let source_changed =
last_projection_source.as_ref() != Some(&progress.source);
if source_changed
@@ -160,6 +225,7 @@ pub(crate) fn start() {
Ok(Err(error)) => log::warn!("[local-catalog] fallback=bootstrap-error error={error}"),
Err(error) => log::warn!("[local-catalog] fallback=worker-join error={error}"),
}
clear_progress();
worker_finished();
});
}
@@ -188,10 +254,20 @@ pub(crate) fn request_catch_up() {
BootstrapLayout::new(&locations.catalog_dir).open_active(),
ActiveCatalog::Ready { .. }
) {
let mut last_progress = std::time::Instant::now()
.checked_sub(Duration::from_secs(1))
.unwrap_or_else(std::time::Instant::now);
return qbz_local_catalog::bootstrap_legacy_caches_at_with_progress(
&locations,
&CANCELLED,
|_| {},
|progress| {
if progress.source_complete
|| last_progress.elapsed() >= Duration::from_millis(100)
{
publish_bootstrap_progress(progress);
last_progress = std::time::Instant::now();
}
},
)
.map(RefreshOutcome::Bootstrap);
}
@@ -201,6 +277,7 @@ pub(crate) fn request_catch_up() {
&locations,
&CANCELLED,
|progress| {
publish_projection_progress(progress);
let source_changed = last_source.as_ref() != Some(&progress.source);
if source_changed
|| progress.source_complete
@@ -274,6 +351,7 @@ pub(crate) fn request_catch_up() {
Ok(Err(error)) => log::warn!("[local-catalog] fallback=catch-up-error error={error}"),
Err(error) => log::warn!("[local-catalog] fallback=catch-up-join error={error}"),
}
clear_progress();
worker_finished();
});
}
+3 -3
View File
@@ -50,9 +50,9 @@ pub use crate::local_rows::to_json;
// --- state / prefs ---------------------------------------------------------
pub use crate::local_state::{
album_mode, begin_tracks_load, counts, has_library, set_album_mode, set_tracks_filter,
set_tracks_group, set_tracks_query, set_tracks_sort, state, tracks_filter, tracks_generation,
tracks_group, tracks_has_more, tracks_sort,
album_mode, begin_tracks_load, counts, explorer_columns, has_library, set_album_mode,
set_explorer_columns, set_tracks_filter, set_tracks_group, set_tracks_query, set_tracks_sort,
state, tracks_filter, tracks_generation, tracks_group, tracks_has_more, tracks_sort,
};
// --- queries ---------------------------------------------------------------
+393
View File
@@ -0,0 +1,393 @@
//! Physical-media information for Local Library tracks and logical albums.
//!
//! This is deliberately separate from the Qobuz Track/Album Info modals:
//! those describe the catalog record (credits, label, review), while this
//! document answers where the playable media comes from, how large it is and
//! which local path or server-native identity owns it.
use std::collections::{BTreeSet, HashSet};
use std::path::Path;
use cxx_qt_lib::QString;
use qbz_app::settings::media_servers::MediaServerKind;
use qbz_library::LocalTrack;
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct MediaLocation {
kind: &'static str,
value: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MediaInfoDoc {
kind: &'static str,
title: String,
subtitle: String,
source_kinds: Vec<&'static str>,
server: String,
track_count: usize,
duration: String,
formats: String,
quality: String,
channels: String,
file_size: String,
file_size_bytes: u64,
locations: Vec<MediaLocation>,
error: String,
}
fn remote_source(track: &LocalTrack) -> &'static str {
let source =
crate::remote_metadata_qt::canonical_source(track.source.as_deref().unwrap_or_default());
if !source.is_empty() {
return match source {
"plex" => "plex",
"jellyfin" => "jellyfin",
"subsonic" => "subsonic",
_ => "",
};
}
track
.album_group_key
.split_once(':')
.map(|(prefix, _)| crate::remote_metadata_qt::canonical_source(prefix))
.map(|source| match source {
"plex" => "plex",
"jellyfin" => "jellyfin",
"subsonic" => "subsonic",
_ => "",
})
.unwrap_or_default()
}
fn source_kind(track: &LocalTrack) -> &'static str {
let remote = remote_source(track);
if !remote.is_empty() {
return remote;
}
match track.source.as_deref().unwrap_or_default() {
"qobuz_download" | "qobuz_purchase" => "offline",
_ if track.is_network_mount => "network",
_ => "local",
}
}
fn server_label(kind: &str) -> String {
match kind {
"plex" => crate::local_plex::settings().base_url,
"jellyfin" => {
let settings = crate::media_servers_qt::get(MediaServerKind::Jellyfin);
if settings.server_name.trim().is_empty() {
settings.base_url
} else if settings.base_url.trim().is_empty() {
settings.server_name
} else {
format!("{} · {}", settings.server_name, settings.base_url)
}
}
"subsonic" => {
let settings = crate::media_servers_qt::get(MediaServerKind::Subsonic);
if settings.server_name.trim().is_empty() {
settings.base_url
} else if settings.base_url.trim().is_empty() {
settings.server_name
} else {
format!("{} · {}", settings.server_name, settings.base_url)
}
}
_ => String::new(),
}
}
fn human_bytes(bytes: u64) -> String {
if bytes == 0 {
return String::new();
}
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.2} {}", UNITS[unit])
}
}
fn physical_size(track: &LocalTrack) -> u64 {
if track.file_size_bytes > 0 {
return track.file_size_bytes;
}
if !remote_source(track).is_empty() {
return 0;
}
std::fs::metadata(&track.file_path)
.map(|metadata| metadata.len())
.unwrap_or(0)
}
fn remote_album_id(track: &LocalTrack) -> String {
let source = remote_source(track);
if source == "plex" {
return track
.album_group_key
.strip_prefix("plex:album:")
.or_else(|| track.album_group_key.strip_prefix("plex:"))
.unwrap_or(&track.album_group_key)
.to_string();
}
track
.album_group_key
.strip_prefix(&format!("{source}:"))
.unwrap_or(&track.album_group_key)
.to_string()
}
fn locations_for_track(track: &LocalTrack) -> Vec<MediaLocation> {
if !remote_source(track).is_empty() {
return vec![
MediaLocation {
kind: "item",
value: track.file_path.clone(),
},
MediaLocation {
kind: "album",
value: remote_album_id(track),
},
];
}
let mut locations = vec![MediaLocation {
kind: "file",
value: track.file_path.clone(),
}];
if let Some(parent) = Path::new(&track.file_path).parent() {
locations.push(MediaLocation {
kind: "folder",
value: parent.to_string_lossy().into_owned(),
});
}
locations
}
fn aggregate_doc(kind: &'static str, tracks: &[LocalTrack]) -> MediaInfoDoc {
let Some(first) = tracks.first() else {
return MediaInfoDoc {
kind,
title: String::new(),
subtitle: String::new(),
source_kinds: Vec::new(),
server: String::new(),
track_count: 0,
duration: String::new(),
formats: String::new(),
quality: String::new(),
channels: String::new(),
file_size: String::new(),
file_size_bytes: 0,
locations: Vec::new(),
error: qbz_i18n::t("The media item is no longer available."),
};
};
let mut source_kinds = BTreeSet::new();
let mut formats = BTreeSet::new();
let mut quality = BTreeSet::new();
let mut channels = BTreeSet::new();
let mut servers = BTreeSet::new();
let mut seen_files = HashSet::new();
let mut file_size_bytes = 0u64;
let mut duration_secs = 0u64;
let mut locations = Vec::new();
let mut seen_locations = HashSet::new();
for track in tracks {
let source = source_kind(track);
source_kinds.insert(source);
let server = server_label(source);
if !server.trim().is_empty() {
servers.insert(server);
}
formats.insert(track.format.to_string());
let detail =
crate::local_rows::detail_of(&track.format, track.bit_depth, track.sample_rate);
if !detail.trim().is_empty() {
quality.insert(detail);
}
if track.channels > 0 {
channels.insert(track.channels.to_string());
}
duration_secs = duration_secs.saturating_add(track.duration_secs);
// CUE rows can share one physical file. Count that file once while
// retaining every logical track in the duration/track count.
let size_key = if remote_source(track).is_empty() {
track.file_path.clone()
} else {
format!("{}:{}", source, track.file_path)
};
if seen_files.insert(size_key) {
file_size_bytes = file_size_bytes.saturating_add(physical_size(track));
}
let candidates = if kind == "track" {
locations_for_track(track)
} else if !remote_source(track).is_empty() {
vec![MediaLocation {
kind: "album",
value: remote_album_id(track),
}]
} else {
Path::new(&track.file_path)
.parent()
.map(|directory| {
vec![MediaLocation {
kind: "folder",
value: directory.to_string_lossy().into_owned(),
}]
})
.unwrap_or_default()
};
for location in candidates {
if seen_locations.insert((location.kind, location.value.clone())) {
locations.push(location);
}
}
}
let title = if kind == "track" {
first.title.clone()
} else if first.album_group_title.trim().is_empty() {
first.album.clone()
} else {
first.album_group_title.clone()
};
let subtitle = if kind == "track" {
[first.artist.as_str(), first.album_group_title.as_str()]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" — ")
} else {
first
.album_artist
.as_deref()
.filter(|artist| !artist.trim().is_empty())
.unwrap_or(&first.artist)
.to_string()
};
MediaInfoDoc {
kind,
title,
subtitle,
source_kinds: source_kinds.into_iter().collect(),
server: servers.into_iter().collect::<Vec<_>>().join("\n"),
track_count: tracks.len(),
duration: if kind == "track" {
crate::local_rows::mmss(duration_secs)
} else {
crate::local_rows::total_duration(duration_secs)
},
formats: formats.into_iter().collect::<Vec<_>>().join(", "),
quality: quality.into_iter().collect::<Vec<_>>().join(", "),
channels: channels.into_iter().collect::<Vec<_>>().join(", "),
file_size: human_bytes(file_size_bytes),
file_size_bytes,
locations,
error: String::new(),
}
}
fn publish(doc: MediaInfoDoc) {
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
crate::local_bridge::ui(move |mut bridge| {
bridge
.as_mut()
.set_local_media_info_json(QString::from(json.as_str()));
bridge.as_mut().set_local_media_info_loading(false);
bridge.as_mut().set_local_media_info_open(true);
});
}
pub fn begin() {
crate::local_bridge::ui(|mut bridge| {
bridge
.as_mut()
.set_local_media_info_json(QString::from("{}"));
bridge.as_mut().set_local_media_info_loading(true);
bridge.as_mut().set_local_media_info_open(true);
});
}
pub fn open_track(track: LocalTrack) {
publish(aggregate_doc("track", &[track]));
}
pub fn open_album(tracks: Vec<LocalTrack>) {
publish(aggregate_doc("album", &tracks));
}
pub fn open_empty(kind: &'static str) {
publish(aggregate_doc(kind, &[]));
}
pub fn close() {
crate::local_bridge::ui(|mut bridge| {
bridge.as_mut().set_local_media_info_open(false);
bridge.as_mut().set_local_media_info_loading(false);
});
}
pub fn copy(value: String) {
if value.trim().is_empty() {
return;
}
crate::share_qt::copy_to_clipboard(value);
crate::toast_qt::success(qbz_i18n::t("Copied!"));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remote_namespace_never_becomes_a_filesystem_location() {
let track = LocalTrack {
title: "Remote".into(),
file_path: "item-7".into(),
album_group_key: "jellyfin:album-3".into(),
source: None,
..LocalTrack::default()
};
let doc = aggregate_doc("track", &[track]);
assert_eq!(doc.source_kinds, ["jellyfin"]);
assert_eq!(doc.locations[0].kind, "item");
assert_eq!(doc.locations[0].value, "item-7");
assert_eq!(doc.locations[1].value, "album-3");
}
#[test]
fn cue_rows_count_one_physical_file_size() {
let first = LocalTrack {
id: 1,
file_path: "/music/image.flac".into(),
album_group_key: "/music".into(),
file_size_bytes: 1_048_576,
cue_start_secs: Some(0.0),
..LocalTrack::default()
};
let mut second = first.clone();
second.id = 2;
second.cue_start_secs = Some(180.0);
let doc = aggregate_doc("album", &[first, second]);
assert_eq!(doc.track_count, 2);
assert_eq!(doc.file_size_bytes, 1_048_576);
assert_eq!(doc.file_size, "1.00 MiB");
}
}
+99 -2
View File
@@ -121,6 +121,10 @@ pub fn init_for_user(base_dir: &std::path::Path) {
return;
}
*BOUND.lock().unwrap() = UserDataPaths::load_last_user_id();
// Repair only the rare Plex rows whose flat track response omitted art.
// It is background/LAN-only and the derived catalog receives one coalesced
// catch-up when new covers land.
start_album_artwork_repair();
}
/// Forget the binding (logout) so the next read re-binds to the new user.
@@ -335,7 +339,13 @@ fn parse_audio_format(s: &str) -> qbz_library::AudioFormat {
/// thumb path (tokenized at fetch time), `source` is `"plex"`, and the id is
/// namespaced so it cannot collide with a local row.
pub fn map_cached_to_local_track(t: qbz_plex::PlexCachedTrack) -> LocalTrack {
LocalTrack {
let native_album_id = t
.parent_rating_key
.clone()
.filter(|key| !key.is_empty())
.unwrap_or_else(|| t.album_key.clone());
let source_instance = crate::remote_metadata_qt::active_source_instance("plex");
let mut track = LocalTrack {
id: (PLEX_TRACK_ID_FLOOR | (t.id & (PLEX_TRACK_ID_FLOOR - 1))) as i64,
file_path: t.rating_key,
title: t.title,
@@ -361,7 +371,9 @@ pub fn map_cached_to_local_track(t: qbz_plex::PlexCachedTrack) -> LocalTrack {
collection_artwork_path: t.collection_artwork_path,
source: Some("plex".to_string()),
..Default::default()
}
};
crate::remote_metadata_qt::apply(&mut track, "plex", &source_instance, &native_album_id);
track
}
/// The full Plex track set matching `query`, in the `LocalTrack` shape.
@@ -543,6 +555,7 @@ pub fn disconnect() {
static SYNCING: AtomicBool = AtomicBool::new(false);
static SYNC_CANCEL: AtomicBool = AtomicBool::new(false);
static ALBUM_ARTWORK_REPAIRING: AtomicBool = AtomicBool::new(false);
const SYNC_PAGE_SIZE: u64 = 250;
pub fn is_syncing() -> bool {
@@ -569,9 +582,93 @@ pub async fn sync_now() -> Result<usize, String> {
SYNC_CANCEL.store(false, Ordering::Release);
let result = sync_inner().await;
SYNCING.store(false, Ordering::SeqCst);
if result.is_ok() {
start_album_artwork_repair();
}
result
}
/// Hydrate album art only for parent ids whose Track rows omit both `thumb`
/// and `parentThumb`. Plex's own album metadata is the authority; in
/// particular, inherited artwork can point at another rating key, so a URL
/// synthesized from `parentRatingKey` is not safe.
fn start_album_artwork_repair() {
if SYNCING.load(Ordering::Acquire) || ALBUM_ARTWORK_REPAIRING.swap(true, Ordering::AcqRel) {
return;
}
let cfg = settings();
if !cfg.enabled
|| !is_local_address(&cfg.base_url)
|| resolve_base_url(&cfg.base_url).is_empty()
|| cfg.token.trim().is_empty()
{
ALBUM_ARTWORK_REPAIRING.store(false, Ordering::Release);
return;
}
let server_id = (!cfg.machine_id.trim().is_empty()).then_some(cfg.machine_id.clone());
crate::spawn(async move {
let candidate_server = server_id.clone();
let candidates = tokio::task::spawn_blocking(move || {
qbz_plex::plex_cache_album_artwork_candidates(candidate_server)
})
.await;
let candidates = match candidates {
Ok(Ok(candidates)) => candidates,
Ok(Err(error)) => {
log::warn!("[plex-art] candidate query failed: {error}");
ALBUM_ARTWORK_REPAIRING.store(false, Ordering::Release);
return;
}
Err(error) => {
log::warn!("[plex-art] candidate worker failed: {error}");
ALBUM_ARTWORK_REPAIRING.store(false, Ordering::Release);
return;
}
};
if candidates.is_empty() {
ALBUM_ARTWORK_REPAIRING.store(false, Ordering::Release);
return;
}
let mut resolved = Vec::with_capacity(candidates.len());
let mut fetch_failures = 0usize;
for parent_rating_key in candidates {
match qbz_plex::plex_get_album_artwork_path(
cfg.base_url.clone(),
cfg.token.clone(),
parent_rating_key.clone(),
)
.await
{
Ok(path) => resolved.push((parent_rating_key, path)),
Err(error) => {
fetch_failures = fetch_failures.saturating_add(1);
log::debug!("[plex-art] album metadata skipped: {error}");
}
}
}
let covers = resolved.iter().filter(|(_, path)| path.is_some()).count();
let save_server = server_id.clone();
let saved = tokio::task::spawn_blocking(move || {
qbz_plex::plex_cache_save_album_artwork(save_server, resolved)
})
.await;
match saved {
Ok(Ok(checked)) => {
log::info!(
"[plex-art] metadata checked={checked} covers={covers} failures={fetch_failures}"
);
if covers > 0 {
crate::local_catalog_qt::request_catch_up();
}
}
Ok(Err(error)) => log::warn!("[plex-art] cache update failed: {error}"),
Err(error) => log::warn!("[plex-art] cache worker failed: {error}"),
}
ALBUM_ARTWORK_REPAIRING.store(false, Ordering::Release);
});
}
async fn interrupt_section(section_key: String, generation: u64, restart: bool) {
let _ = tokio::task::spawn_blocking(move || {
if restart {
+63 -17
View File
@@ -23,6 +23,7 @@
use std::collections::HashMap;
use qbz_library::{AudioFormat, LocalAlbum, LocalTrack};
use qbz_local_catalog::AlbumRecord;
use qbz_source::SourceId;
use serde::Serialize;
@@ -74,9 +75,9 @@ pub struct AlbumRow {
/// Heart state from the shared LocalFavoritesService.
#[serde(rename = "isFavorite")]
pub is_favorite: bool,
/// True only when the logical album includes a genuine local or Plex
/// copy. Jellyfin/Subsonic and Qobuz-offline-only rows are intentionally
/// outside the existing local-favorites database contract.
/// True when the logical album belongs to the Local Library favorite
/// domain: a genuine local copy or a configured media-server copy.
/// Qobuz-offline-only rows stay in the catalog-favorite domain.
pub favoriteable: bool,
}
@@ -462,6 +463,9 @@ pub fn art_token(source_word: Option<&str>, token: &str) -> Option<(SourceId, St
if token.is_empty() {
return None;
}
if let Some(path) = crate::remote_metadata_qt::local_art_path(token) {
return Some((SourceId::LOCAL, path.to_string()));
}
// No word at all is the LOCAL case in practice (`local_tracks.source` is
// empty for a plain scanned file, §3.1's vocabulary table).
let id = SourceId::from_word(source_word.unwrap_or("")).unwrap_or(SourceId::LOCAL);
@@ -550,8 +554,8 @@ pub fn map_album_with_artists(
}
/// Resolve the source word accepted by `local_favorites.db`. Prefer a real
/// local copy when a logical album spans both local and Plex; both are valid,
/// but the local snapshot remains usable if the server is later disconnected.
/// local copy when a logical album spans several sources; otherwise retain a
/// server source so remote-only albums get the same heart affordance.
pub fn album_favorite_source(sources: &[String]) -> Option<&'static str> {
if sources
.iter()
@@ -563,22 +567,61 @@ pub fn album_favorite_source(sources: &[String]) -> Option<&'static str> {
.any(|source| source.eq_ignore_ascii_case("plex"))
{
Some("plex")
} else if sources
.iter()
.any(|source| source.eq_ignore_ascii_case("jellyfin"))
{
Some("jellyfin")
} else if sources.iter().any(|source| {
matches!(
source.to_ascii_lowercase().as_str(),
"subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga"
)
}) {
Some("subsonic")
} else {
None
}
}
/// Normalize every physical source carried by a native catalog album into
/// the exact words consumed by QML. `AlbumRecord::source` is intentionally a
/// single routing choice; using only it made a mixed Plex/server card lose
/// the Plex favorite affordance depending on which copy won materialization.
pub fn catalog_album_sources(record: &AlbumRecord) -> Vec<String> {
let fallback;
let words: &[String] = if record.source_words.is_empty() {
fallback = vec![if record.source_raw.trim().is_empty() {
record.source.as_str().to_string()
} else {
record.source_raw.clone()
}];
&fallback
} else {
&record.source_words
};
let mut normalized = Vec::with_capacity(words.len());
for word in words {
let raw = badge_source_raw(Some(word));
let value = if raw.is_empty() {
badge_source(Some(word))
} else {
raw
};
if !normalized.iter().any(|known| known == &value) {
normalized.push(value);
}
}
normalized
}
pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, (SourceId, String)>) -> TrackRow {
let key = track_key(t.id);
if let Some(p) = t
.artwork_path
.as_ref()
.filter(|p| !p.is_empty())
.or_else(|| {
t.collection_artwork_path
.as_ref()
.filter(|p| !p.is_empty())
})
.or_else(|| t.collection_artwork_path.as_ref().filter(|p| !p.is_empty()))
{
if let Some(tok) = art_token(t.source.as_deref(), p) {
art.insert(key.clone(), tok);
@@ -636,14 +679,17 @@ mod tests {
}
#[test]
fn album_favorite_refuses_remote_and_offline_only() {
fn album_favorite_accepts_media_servers_but_refuses_offline_only() {
assert_eq!(
album_favorite_source(&sources(&[
"jellyfin",
"subsonic",
"qobuz_purchase",
"offline",
])),
album_favorite_source(&sources(&["jellyfin"])),
Some("jellyfin")
);
assert_eq!(
album_favorite_source(&sources(&["navidrome"])),
Some("subsonic")
);
assert_eq!(
album_favorite_source(&sources(&["qobuz_purchase", "offline"])),
None
);
}
+185 -11
View File
@@ -174,6 +174,9 @@ pub struct LocalState {
pub art_index: HashMap<String, (SourceId, String)>,
/// Album identity ("folder" | "metadata") — persisted (locallibrary_ui).
pub album_mode: String,
/// Library Explorer leading columns ("genre" | "year" | "both") —
/// persisted beside the other Local Library presentation choices.
pub explorer_columns: String,
pub counts: LocalCounts,
/// Synthetic logical album id -> strongly-associated authoritative album
/// ids. Rebuilt with each Albums query; never persisted and therefore
@@ -192,9 +195,28 @@ pub struct LocalState {
}
static LOCAL: Mutex<Option<LocalState>> = Mutex::new(None);
// Serializes the take/map/restore artwork transaction without retaining the
// much broader LocalState lock while a large album/artist page is mapped.
// Every production read/write of `art_index` goes through this gate.
static ART_INDEX_GATE: Mutex<()> = Mutex::new(());
struct ArtIndexTransaction {
art: Option<HashMap<String, (SourceId, String)>>,
}
impl Drop for ArtIndexTransaction {
fn drop(&mut self) {
if let Some(art) = self.art.take() {
state(|state| state.art_index = art);
}
}
}
pub fn state<R>(f: impl FnOnce(&mut LocalState) -> R) -> R {
let mut guard = LOCAL.lock().unwrap();
// A failed assertion in one Rust test must not make every subsequent
// Local Library operation panic. Production closures are deliberately
// panic-free, but recovering the owned state is safer than abandoning it.
let mut guard = LOCAL.lock().unwrap_or_else(|error| error.into_inner());
let s = guard.get_or_insert_with(|| {
let prefs = read_prefs();
LocalState {
@@ -202,23 +224,58 @@ pub fn state<R>(f: impl FnOnce(&mut LocalState) -> R) -> R {
tracks_sort: prefs.tracks_sort,
tracks_group: prefs.tracks_group,
tracks_filter: prefs.tracks_filter,
explorer_columns: normalize_explorer_columns(&prefs.explorer_columns).to_string(),
..LocalState::default()
}
});
f(s)
}
/// Take the art index out, run `f` with it, put it back — the shape every
/// loader uses so mapping can register covers without a second lock.
/// Mutate the artwork index as one serialized transaction.
///
/// Mapping runs on several native paging workers at once (Artists alone has
/// an artist rail and an album grid). Taking the map out and restoring it in
/// two separate critical sections lets two workers both observe an empty map;
/// whichever one restores last then discards every artwork reference the
/// other worker registered. The dedicated gate makes that transaction atomic
/// while releasing the broader LocalState lock during potentially large pure
/// mapping work, so artwork correctness does not stall unrelated UI reads.
pub fn with_art<R>(f: impl FnOnce(&mut HashMap<String, (SourceId, String)>) -> R) -> R {
let mut art = state(|s| std::mem::take(&mut s.art_index));
let out = f(&mut art);
state(|s| s.art_index = art);
out
let _gate = ART_INDEX_GATE
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut transaction = ArtIndexTransaction {
art: Some(state(|state| std::mem::take(&mut state.art_index))),
};
// `ArtIndexTransaction::drop` restores the map on both the normal and
// panic paths, before `_gate` is released.
f(transaction.art.as_mut().expect("art transaction is active"))
}
/// Snapshot the requested artwork references while respecting an in-flight
/// mapping transaction. Readers must never observe the deliberate empty map
/// between `take` and restore.
pub fn artwork_sources(keys: &[String]) -> Vec<(String, SourceId, String)> {
let _gate = ART_INDEX_GATE
.lock()
.unwrap_or_else(|error| error.into_inner());
state(|state| {
keys.iter()
.filter_map(|key| {
state
.art_index
.get(key)
.map(|(source, token)| (key.clone(), *source, token.clone()))
})
.collect()
})
}
/// Drop every cached document (logout / user switch).
pub fn reset() {
let _gate = ART_INDEX_GATE
.lock()
.unwrap_or_else(|error| error.into_inner());
*LOCAL.lock().unwrap() = None;
}
@@ -237,6 +294,8 @@ pub struct Prefs {
pub tracks_filter: String,
#[serde(default = "d_folder")]
pub albums_id_mode: String,
#[serde(default = "d_genre")]
pub explorer_columns: String,
#[serde(default)]
pub ephemeral_folder: Option<String>,
}
@@ -250,6 +309,17 @@ fn d_default() -> String {
fn d_folder() -> String {
"folder".into()
}
fn d_genre() -> String {
"genre".into()
}
fn normalize_explorer_columns(value: &str) -> &'static str {
match value {
"year" => "year",
"both" => "both",
_ => "genre",
}
}
impl Default for Prefs {
fn default() -> Self {
@@ -258,6 +328,7 @@ impl Default for Prefs {
tracks_sort: d_default(),
tracks_filter: String::new(),
albums_id_mode: d_folder(),
explorer_columns: d_genre(),
ephemeral_folder: None,
}
}
@@ -277,7 +348,7 @@ pub fn read_prefs() -> Prefs {
}
}
/// Merge this struct's four keys into the on-disk document and publish it
/// Merge this struct's owned keys into the on-disk document and publish it
/// ATOMICALLY (`settings_qt::write_json_object_atomic`).
///
/// Two changes from the old whole-struct `std::fs::write`, both because this
@@ -368,6 +439,16 @@ pub fn album_mode() -> String {
state(|s| s.album_mode.clone())
}
pub fn set_explorer_columns(mode: &str) {
let mode = normalize_explorer_columns(mode);
state(|state| state.explorer_columns = mode.to_string());
update_prefs(|prefs| prefs.explorer_columns = mode.to_string());
}
pub fn explorer_columns() -> String {
state(|state| state.explorer_columns.clone())
}
pub fn set_tracks_sort(sort: &str) {
state(|s| s.tracks_sort = sort.to_string());
update_prefs(|p| p.tracks_sort = sort.to_string());
@@ -454,6 +535,9 @@ pub fn commit_tracks_page(
consumed: TrackSourceOffsets,
has_more: bool,
) -> Option<Vec<TrackRow>> {
let _gate = ART_INDEX_GATE
.lock()
.unwrap_or_else(|error| error.into_inner());
state(|s| {
if !tracks_request_is_current(s.tracks_generation, s.tracks_offsets, request) {
return None;
@@ -504,11 +588,62 @@ fn library_sources_available(
mod phase_a_tests {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{mpsc, Arc};
use std::time::Duration;
use qbz_source::SourceId;
use super::{
library_sources_available, tracks_request_is_current, TrackSourceOffsets, TracksLoadRequest,
library_sources_available, normalize_explorer_columns, tracks_request_is_current,
with_art, Prefs, TrackSourceOffsets, TracksLoadRequest,
};
#[test]
fn artwork_index_mutations_are_one_atomic_critical_section() {
let first_key = "test:atomic-art:first".to_string();
let second_key = "test:atomic-art:second".to_string();
with_art(|art| {
art.remove(&first_key);
art.remove(&second_key);
});
let (first_entered_tx, first_entered_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let first = first_key.clone();
let first_worker = std::thread::spawn(move || {
with_art(|art| {
first_entered_tx.send(()).unwrap();
release_first_rx.recv().unwrap();
art.insert(first, (SourceId::LOCAL, "first".to_string()));
});
});
first_entered_rx.recv().unwrap();
let (second_entered_tx, second_entered_rx) = mpsc::channel();
let second = second_key.clone();
let second_worker = std::thread::spawn(move || {
with_art(|art| {
second_entered_tx.send(()).unwrap();
art.insert(second, (SourceId::LOCAL, "second".to_string()));
});
});
assert!(matches!(
second_entered_rx.recv_timeout(Duration::from_millis(100)),
Err(mpsc::RecvTimeoutError::Timeout)
));
release_first_tx.send(()).unwrap();
first_worker.join().unwrap();
second_entered_rx.recv().unwrap();
second_worker.join().unwrap();
with_art(|art| {
assert_eq!(art.get(&first_key).unwrap().1, "first");
assert_eq!(art.get(&second_key).unwrap().1, "second");
art.remove(&first_key);
art.remove(&second_key);
});
}
#[test]
fn remote_only_installation_has_a_library() {
assert!(library_sources_available(true, false, 0, 0));
@@ -650,7 +785,9 @@ mod phase_a_tests {
assert!(details.contains("interval: 16"));
assert!(!details.contains("Qt.callLater"));
assert!(column.contains("interval: root.debounceMs"));
assert!(tab.contains("debounceMs: facetIndex === 2 ? 140 : 90"));
assert!(tab.contains("root.facetKind(facetIndex) === \"album\" ? 140 : 90"));
assert!(tab.contains("[\"genre\", \"year\", \"artist\", \"album\"]"));
assert!(tab.contains("root.view.selectedGenreYears = ({})"));
assert!(details.contains("maxConcurrentDetailRequests: 2"));
assert!(details.contains("maxConcurrentDetailRequests - currentPendingCount()"));
assert!(details.contains("property var detailFilters: ({})"));
@@ -667,7 +804,8 @@ mod phase_a_tests {
.nth(1)
.and_then(|tail| tail.split("crate::spawn").next())
.expect("genre album cache-hit path");
assert!(cache_path.contains("queue_genre_album_ready(id, json, filter_json, \"cache-hit\", started)"));
assert!(cache_path
.contains("queue_genre_album_ready(id, json, filter_json, \"cache-hit\", started)"));
assert!(!cache_path.contains("local_genre_album_ready"));
assert!(
details.contains("id: discMenuLoader\n active: false")
@@ -704,4 +842,40 @@ mod phase_a_tests {
assert!(column.contains("radius: theme.radiusSm"));
assert!(column.contains("clip: true"));
}
#[test]
fn track_group_indices_are_complete_and_do_not_depend_on_delegates() {
let local = include_str!("../qml/views/local/LocalTracksTab.qml");
let library = include_str!("../qml/views/LibraryView.qml");
let strip = include_str!("../qml/controls/QbzAlphaStrip.qml");
assert!(local.contains("visible: root.view.tracksGroup !== \"off\""));
assert!(local.contains("completeAlphabet: true"));
assert!(library.contains("activeTab === \"tracks\" && tracksGroup !== \"off\""));
assert!(library.contains("completeAlphabet: true"));
assert!(strip.contains("model: root.completeAlphabet ? 27"));
assert!(strip.contains("\"#ABCDEFGHIJKLMNOPQRSTUVWXYZ\".charAt(position)"));
assert!(strip.contains("enabled: cell.entry.index >= 0"));
}
#[test]
fn local_header_scan_progress_uses_bounded_counters_only() {
let chrome = include_str!("../qml/views/local/LocalChrome.qml");
let progress = include_str!("../qml/views/local/LocalScanProgress.qml");
assert!(chrome.contains("LocalScanProgress"));
assert!(progress.contains("localScan.sourceProcessed"));
assert!(progress.contains("catalog.overallDone"));
assert!(!progress.contains("QbzLocalTracks"));
assert!(!progress.contains("ListView"));
assert!(!progress.contains("Repeater"));
}
#[test]
fn explorer_column_preference_is_persisted_and_normalized() {
assert_eq!(normalize_explorer_columns("genre"), "genre");
assert_eq!(normalize_explorer_columns("year"), "year");
assert_eq!(normalize_explorer_columns("both"), "both");
assert_eq!(normalize_explorer_columns("future-value"), "genre");
let json = serde_json::to_value(Prefs::default()).expect("serializes prefs");
assert_eq!(json["explorer_columns"], "genre");
}
}
+90 -18
View File
@@ -209,9 +209,21 @@ pub(crate) fn requested() -> bool {
if SESSION_FAILED.load(Ordering::Acquire) {
return false;
}
std::env::var("QBZ_LOCAL_CATALOG_TRACKS")
// The rollout switch used to be opt-in while the native model was being
// proved. Keeping that default after the model shipped silently routes a
// normal launch through the legacy accumulated-page document: the A-Z
// rail can then only know about letters in the current 500-row prefix.
// Native is the production path now. An explicit false value remains a
// diagnostic escape hatch, and SESSION_FAILED still provides the
// per-session automatic rollback after a real model/catalog failure.
!std::env::var("QBZ_LOCAL_CATALOG_TRACKS")
.ok()
.is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"))
.is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
)
})
}
/// Start a new immutable native query. Returns `false` when the runtime flag
@@ -379,6 +391,9 @@ pub(crate) fn row_action(index: i64, action: String) {
let Some((generation, record)) = resident_record(index) else {
return;
};
if action == "track-info" {
crate::local_media_info_qt::begin();
}
crate::spawn(async move {
let rows = tokio::task::spawn_blocking(move || resolve_records_blocking(&[record]))
.await
@@ -720,10 +735,11 @@ fn activate_query(opened: OpenedQuery) {
);
return;
}
let name_group = opened.descriptor.group() == TrackGroup::Name;
let group = group_word(opened.descriptor.group()).to_string();
let initial_jumps = jumps_json(if name_group {
name_jumps(&opened.page.records, 0)
let track_group = opened.descriptor.group();
let grouped = track_group != TrackGroup::Off;
let group = group_word(track_group).to_string();
let initial_jumps = jumps_json(if grouped {
group_jumps(&opened.page.records, 0, track_group)
} else {
Vec::new()
});
@@ -783,7 +799,12 @@ fn activate_query(opened: OpenedQuery) {
wire.len()
);
});
if opened.total > ANCHOR_STRIDE_ROWS as u64 || (name_group && opened.total > PAGE_ROWS as u64) {
// Group navigation is metadata of the immutable query, not of the pages
// the ListView happened to make resident. Build it in the same bounded
// keyset walk as the sparse anchors whenever page zero is not the whole
// result. This is what keeps the complete # A-Z rail independent from
// delegate creation and scrolling.
if opened.total > ANCHOR_STRIDE_ROWS as u64 || (grouped && opened.total > PAGE_ROWS as u64) {
build_anchors(generation);
}
}
@@ -1016,7 +1037,7 @@ fn build_anchors(generation: u64) {
let mut row = 0usize;
let mut anchors = Vec::new();
let mut jumps = Vec::new();
let mut previous_letter = String::new();
let mut seen_letters = HashSet::new();
loop {
if QUERY_GENERATION.load(Ordering::Acquire) != generation {
return Err("superseded");
@@ -1024,10 +1045,9 @@ fn build_anchors(generation: u64) {
let page = catalog
.query_tracks(&descriptor, cursor.as_ref(), 500)
.map_err(|_| "anchor-query")?;
if descriptor.group() == TrackGroup::Name {
for jump in name_jumps(&page.rows, row) {
if jump.letter != previous_letter {
previous_letter = jump.letter.clone();
if descriptor.group() != TrackGroup::Off {
for jump in group_jumps(&page.rows, row, descriptor.group()) {
if seen_letters.insert(jump.letter.clone()) {
jumps.push(jump);
}
}
@@ -1155,8 +1175,8 @@ fn map_record(record: &TrackRecord, art: &mut HashMap<String, (SourceId, String)
.as_deref()
.filter(|value| !value.is_empty())
{
if let Some(source) = SourceId::from_word(raw) {
art.insert(art_key.clone(), (source, token.to_string()));
if let Some(reference) = crate::local_rows::art_token(Some(raw), token) {
art.insert(art_key.clone(), reference);
}
}
let format = audio_format(&record.format);
@@ -1244,11 +1264,30 @@ fn record_group_label(record: &TrackRecord, group: TrackGroup) -> String {
}
}
fn name_jumps(records: &[TrackRecord], base: usize) -> Vec<NativeJump> {
fn alpha_bucket(value: &str) -> String {
let initial = qbz_local_catalog::normalize_sort_key(value)
.chars()
.next()
.unwrap_or('#')
.to_ascii_uppercase();
if initial.is_ascii_alphabetic() {
initial.to_string()
} else {
"#".to_string()
}
}
fn group_jumps(records: &[TrackRecord], base: usize, group: TrackGroup) -> Vec<NativeJump> {
let mut previous = String::new();
let mut jumps = Vec::new();
for (offset, record) in records.iter().enumerate() {
let letter = record_group_label(record, TrackGroup::Name);
let value = match group {
TrackGroup::Album => record.album.as_str(),
TrackGroup::Artist => record.artist.as_str(),
TrackGroup::Name => record.title.as_str(),
TrackGroup::Off => continue,
};
let letter = alpha_bucket(value);
if letter != previous {
previous = letter.clone();
jumps.push(NativeJump {
@@ -1457,7 +1496,7 @@ mod tests {
}
#[test]
fn name_jump_metadata_is_bounded_and_uses_global_row_indices() {
fn grouped_jump_metadata_is_bounded_and_uses_global_row_indices() {
let records = ["Alpha", "Another", "Beta", "Zulu"]
.into_iter()
.enumerate()
@@ -1472,7 +1511,7 @@ mod tests {
10,
)
.unwrap();
let jumps = name_jumps(&page.rows, 10_000);
let jumps = group_jumps(&page.rows, 10_000, TrackGroup::Name);
assert_eq!(jumps.len(), 3);
assert_eq!(jumps[0].letter, "A");
assert_eq!(jumps[0].index, 10_000);
@@ -1480,6 +1519,39 @@ mod tests {
assert_eq!(jumps[2].index, 10_003);
}
#[test]
fn grouped_jumps_use_the_selected_field_and_fold_numbers_and_symbols() {
let mut records = vec![
metric_track(1, SourceKind::Local, "Zulu"),
metric_track(2, SourceKind::Local, "Alpha"),
metric_track(3, SourceKind::Local, "Beta"),
];
records[0].album = "1999".to_string();
records[1].album = "Abbey Road".to_string();
records[2].album = "Blue".to_string();
let mut catalog = Catalog::open_in_memory(1).unwrap();
catalog.upsert_tracks(&records).unwrap();
let page = catalog
.query_tracks(
&QueryDescriptor::tracks().with_group(TrackGroup::Album),
None,
10,
)
.unwrap();
let jumps = group_jumps(&page.rows, 50, TrackGroup::Album);
assert_eq!(
jumps
.iter()
.map(|jump| jump.letter.as_str())
.collect::<Vec<_>>(),
vec!["#", "A", "B"]
);
assert_eq!(
jumps.iter().map(|jump| jump.index).collect::<Vec<_>>(),
vec![50, 51, 52]
);
}
#[test]
fn artist_group_headers_follow_track_artist_across_pages() {
let mut catalog = Catalog::open_in_memory(1).unwrap();
+10 -3
View File
@@ -148,6 +148,10 @@ mod source_wiring;
// Jellyfin / Subsonic: the per-user settings store plus the two gates the
// Local Library union reads. The credential glue itself is in `source_wiring`.
mod media_servers_qt;
// Profile-scoped metadata overlays for Plex/Jellyfin/Subsonic. Physical files
// keep using `.qbz.json`; this module is the read-through cache for servers
// that have no local directory to put that document beside.
mod remote_metadata_qt;
// The library sweep. Lives in the frontend because it needs the tokio runtime
// and a channel to the progress UI; it writes through the cache handle the
// SOURCE owns, so sweep and reads can never point at different users.
@@ -191,6 +195,7 @@ mod library_db_qt;
mod library_prefs;
mod library_qt;
mod library_sidepanel;
mod local_artist_images_qt;
mod local_artist_match;
mod local_catalog_qt;
mod local_filter;
@@ -270,6 +275,7 @@ mod disc_identity;
mod disc_meta_bridge;
mod disc_meta_qt;
mod label_qt;
mod local_media_info_qt;
mod nav_qt;
mod now_playing;
mod offline_fwd;
@@ -3194,8 +3200,7 @@ fn apply_renderer_preference() {
// backend must be undone even when this one is being overridden by env.
let reverted = renderer_qt::revert_if_previous_launch_died();
if qt_backend_env_overrides("QSG_RHI_BACKEND") || qt_backend_env_overrides("QT_QUICK_BACKEND")
{
if qt_backend_env_overrides("QSG_RHI_BACKEND") || qt_backend_env_overrides("QT_QUICK_BACKEND") {
log::info!("[renderer] explicit Qt backend env present; leaving the choice to it");
return;
}
@@ -3232,7 +3237,9 @@ fn apply_renderer_preference() {
// d3d11 is Qt's healthy default here; desktop GL / ANGLE is a
// downgrade, not the GPU tier this pref means. Same remap as
// macOS: honour the intent (GPU path), not the literal backend.
log::info!("[renderer] '{choice}' on Windows -> d3d11 default (the Slint GL remap)");
log::info!(
"[renderer] '{choice}' on Windows -> d3d11 default (the Slint GL remap)"
);
false
} else {
std::env::set_var("QSG_RHI_BACKEND", "opengl");
+11 -2
View File
@@ -341,7 +341,14 @@ pub fn purge_cache(kind: MediaServerKind) {
/// one the grid's SQL union publishes, so "go to album" from a track row
/// lands on the same page the card opens.
pub fn cached_to_local_track(t: qbz_media_cache::CachedTrack) -> qbz_library::LocalTrack {
qbz_library::LocalTrack {
let source = t.source.clone();
let source_instance = if t.server_id.trim().is_empty() {
"default".to_string()
} else {
t.server_id.clone()
};
let album_id = t.album_id.clone();
let mut track = qbz_library::LocalTrack {
id: t.id,
file_path: t.item_id,
title: t.title,
@@ -361,7 +368,9 @@ pub fn cached_to_local_track(t: qbz_media_cache::CachedTrack) -> qbz_library::Lo
collection_artwork_path: t.collection_artwork_token,
source: Some(t.source),
..Default::default()
}
};
crate::remote_metadata_qt::apply(&mut track, &source, &source_instance, &album_id);
track
}
/// Container/codec name -> the enum the quality badge derives from.
+418 -11
View File
@@ -16,10 +16,12 @@
//! flip its glyph live; views also seed from `offline_qt::is_cached` at
//! document build time.
use std::sync::Arc;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use qbz_offline_cache::OfflineCacheState;
use qbz_offline_cache::{CacheEvent, CacheEventSink, TrackCacheInfo};
use qbz_offline_cache::{OfflineCacheState, OfflineCacheStatus};
use crate::offline_qt;
use crate::shell_bridge;
@@ -133,6 +135,254 @@ fn spawn_download(off: &Arc<OfflineCacheState>, track_id: u64) {
);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CollectionCacheMode {
All,
Missing,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CollectionKind {
Album,
Playlist,
}
impl CollectionKind {
fn as_str(self) -> &'static str {
match self {
Self::Album => "album",
Self::Playlist => "playlist",
}
}
}
#[derive(Clone)]
struct CacheCollection {
kind: CollectionKind,
key: String,
title: String,
tracks: Vec<qbz_models::Track>,
album_fallback: Option<(String, String)>,
}
struct PendingCollection {
generation: u64,
collection: CacheCollection,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct CollectionChoiceDoc<'a> {
kind: &'a str,
title: &'a str,
total_tracks: usize,
cached_tracks: usize,
missing_tracks: usize,
}
static COLLECTION_REQUEST_GENERATION: AtomicU64 = AtomicU64::new(0);
static PENDING_COLLECTION: OnceLock<tokio::sync::Mutex<Option<PendingCollection>>> =
OnceLock::new();
fn pending_collection() -> &'static tokio::sync::Mutex<Option<PendingCollection>> {
PENDING_COLLECTION.get_or_init(|| tokio::sync::Mutex::new(None))
}
fn publish_collection_ui(loading: bool, key: String, open: bool, json: String) {
crate::offline_manager_bridge::ui(move |mut bridge| {
bridge.as_mut().set_collection_preflight_loading(loading);
bridge
.as_mut()
.set_collection_preflight_key(QString::from(key.as_str()));
bridge.as_mut().set_collection_choice_open(open);
bridge
.as_mut()
.set_collection_choice_json(QString::from(json.as_str()));
});
}
fn begin_collection_request(key: &str) -> u64 {
let generation = COLLECTION_REQUEST_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
publish_collection_ui(true, key.to_string(), false, "{}".to_string());
generation
}
fn finish_collection_request(generation: u64) {
if COLLECTION_REQUEST_GENERATION.load(Ordering::Acquire) == generation {
publish_collection_ui(false, String::new(), false, "{}".to_string());
}
}
fn fail_collection_request(generation: u64, message: &'static str) {
if COLLECTION_REQUEST_GENERATION.load(Ordering::Acquire) == generation {
finish_collection_request(generation);
crate::toast_qt::error(qbz_i18n::t(message));
}
}
fn unique_tracks(tracks: Vec<qbz_models::Track>) -> Vec<qbz_models::Track> {
let mut seen = HashSet::with_capacity(tracks.len());
tracks
.into_iter()
.filter(|track| track.id != 0 && seen.insert(track.id))
.collect()
}
fn should_queue_collection_track(
status: Option<OfflineCacheStatus>,
mode: CollectionCacheMode,
) -> bool {
match (mode, status) {
// Never duplicate an active job. It already satisfies either choice.
(_, Some(OfflineCacheStatus::Queued | OfflineCacheStatus::Downloading)) => false,
(CollectionCacheMode::All, _) => true,
(CollectionCacheMode::Missing, None | Some(OfflineCacheStatus::Failed)) => true,
(CollectionCacheMode::Missing, Some(OfflineCacheStatus::Ready)) => false,
}
}
async fn cached_statuses(
off: &Arc<OfflineCacheState>,
) -> Result<HashMap<u64, OfflineCacheStatus>, String> {
let guard = off.db.lock().await;
let db = guard
.as_ref()
.ok_or_else(|| "offline cache database is not open".to_string())?;
Ok(db
.get_all_tracks()?
.into_iter()
.map(|track| (track.track_id, track.status))
.collect())
}
async fn execute_collection_cache(collection: CacheCollection, mode: CollectionCacheMode) {
let Some(off) = offline_qt::get().await else {
crate::toast_qt::error(qbz_i18n::t("Log in to cache tracks offline"));
return;
};
let statuses = match cached_statuses(&off).await {
Ok(statuses) => statuses,
Err(error) => {
log::warn!("[qbz-qt] collection cache status read failed: {error}");
crate::toast_qt::error(qbz_i18n::t("Offline caching failed"));
return;
}
};
let targets: Vec<&qbz_models::Track> = collection
.tracks
.iter()
.filter(|track| should_queue_collection_track(statuses.get(&track.id).copied(), mode))
.collect();
if targets.is_empty() {
crate::toast_qt::success(qbz_i18n::t("Everything is already available offline"));
return;
}
let prepared: Vec<(TrackCacheInfo, String)> = targets
.iter()
.map(|track| {
let id = track.id;
(
track_cache_info(track, collection.album_fallback.as_ref()),
off.track_file_path(id, "flac")
.to_string_lossy()
.to_string(),
)
})
.collect();
{
let limit = *off.limit_bytes.lock().await;
let guard = off.db.lock().await;
let Some(db) = guard.as_ref() else {
return;
};
let root = std::path::PathBuf::from(off.get_cache_path());
if let Err(error) = qbz_offline_cache::maintenance::check_cache_limit(db, &root, limit) {
log::warn!("[qbz-qt] collection cache limit reached: {error}");
crate::toast_qt::error(qbz_i18n::t(
"Offline cache is full — free space or raise the limit",
));
return;
}
let rows: Vec<(&TrackCacheInfo, String)> = prepared
.iter()
.map(|(info, path)| (info, path.clone()))
.collect();
if let Err(error) = db.insert_tracks_batch(&rows) {
log::error!("[qbz-qt] collection cache insert failed: {error}");
crate::toast_qt::error(qbz_i18n::t("Offline caching failed"));
return;
}
}
for (info, _) in &prepared {
offline_qt::mark_cached(info.track_id, false);
push_status(info.track_id, 1, 0.0);
spawn_download(&off, info.track_id);
}
let count = prepared.len();
crate::toast_qt::success(qbz_i18n::tf(
"Caching {} track offline…",
"Caching {} tracks offline…",
count as i64,
&[&count.to_string()],
));
crate::offline_manager_qt::refresh_if_open().await;
}
async fn preflight_collection(generation: u64, collection: CacheCollection) {
if COLLECTION_REQUEST_GENERATION.load(Ordering::Acquire) != generation {
return;
}
let Some(off) = offline_qt::get().await else {
fail_collection_request(generation, "Log in to cache tracks offline");
return;
};
let statuses = match cached_statuses(&off).await {
Ok(statuses) => statuses,
Err(error) => {
log::warn!("[qbz-qt] collection preflight failed: {error}");
fail_collection_request(generation, "Offline caching failed");
return;
}
};
let cached_tracks = collection
.tracks
.iter()
.filter(|track| statuses.get(&track.id) == Some(&OfflineCacheStatus::Ready))
.count();
// No ready copy exists: the requested fast path queues the collection
// immediately. Failed rows are repaired and in-flight rows are left alone.
if cached_tracks == 0 {
finish_collection_request(generation);
execute_collection_cache(collection, CollectionCacheMode::All).await;
return;
}
let doc = CollectionChoiceDoc {
kind: collection.kind.as_str(),
title: &collection.title,
total_tracks: collection.tracks.len(),
cached_tracks,
missing_tracks: collection.tracks.len().saturating_sub(cached_tracks),
};
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
{
let mut pending = pending_collection().lock().await;
if COLLECTION_REQUEST_GENERATION.load(Ordering::Acquire) != generation {
return;
}
*pending = Some(PendingCollection {
generation,
collection,
});
}
publish_collection_ui(false, String::new(), true, json);
}
/// Cache a single track for offline playback (Slint `cache_track`).
pub fn cache_track(id: u64) {
crate::spawn(async move {
@@ -244,28 +494,131 @@ pub fn cache_tracks_with_album(
});
}
/// Cache a whole album for offline playback (Slint `cache_album`): fetch its
/// tracks, then batch them.
/// Cache a whole album for offline playback. Every whole-collection entry
/// point first checks the ready cache rows. A wholly new album takes the fast
/// path; a partial/existing one opens the shared all-vs-missing chooser.
pub fn cache_album(album_id: String) {
let key = format!("album:{album_id}");
let generation = begin_collection_request(&key);
crate::spawn(async move {
{
let mut pending = pending_collection().lock().await;
if COLLECTION_REQUEST_GENERATION.load(Ordering::Acquire) != generation {
return;
}
*pending = None;
}
let runtime = crate::app();
let album = match runtime.core().get_album(&album_id).await {
Ok(a) => a,
Err(e) => {
log::error!("[qbz-qt] cache: get_album {album_id} failed: {e}");
crate::toast_qt::error(qbz_i18n::t("Couldn't load that album"));
fail_collection_request(generation, "Couldn't load that album");
return;
}
};
let tracks: Vec<qbz_models::Track> = album.tracks.map(|c| c.items).unwrap_or_default();
let tracks = unique_tracks(album.tracks.map(|c| c.items).unwrap_or_default());
if tracks.is_empty() {
crate::toast_qt::error(qbz_i18n::t("This album has no playable tracks"));
fail_collection_request(generation, "This album has no playable tracks");
return;
}
// Stamp the album identity from the document we just fetched. Without
// it every row lands with a NULL album_id and the album becomes
// unremovable, unrefreshable and ungroupable — see `track_cache_info`.
cache_tracks_with_album(tracks, Some((album.id.clone(), album.title.clone())));
let album_fallback = Some((album.id.clone(), album.title.clone()));
preflight_collection(
generation,
CacheCollection {
kind: CollectionKind::Album,
key,
title: album.title,
tracks,
album_fallback,
},
)
.await;
});
}
/// Cache every Qobuz member of a playlist. The Qobuz client resolves all
/// pagination before returning, so the retained snapshot is the complete
/// playlist rather than only the visible page. Repeated track ids are queued
/// once because the offline index is keyed by track id.
pub fn cache_playlist(playlist_id: String) {
let Ok(id) = playlist_id.parse::<u64>() else {
crate::toast_qt::error(qbz_i18n::t("Couldn't load that playlist"));
return;
};
let key = format!("playlist:{id}");
let generation = begin_collection_request(&key);
crate::spawn(async move {
{
let mut pending = pending_collection().lock().await;
if COLLECTION_REQUEST_GENERATION.load(Ordering::Acquire) != generation {
return;
}
*pending = None;
}
let playlist = match crate::app().core().get_playlist(id).await {
Ok(playlist) => playlist,
Err(error) => {
log::error!("[qbz-qt] cache: get_playlist {id} failed: {error}");
fail_collection_request(generation, "Couldn't load that playlist");
return;
}
};
let tracks = unique_tracks(playlist.tracks.map(|c| c.items).unwrap_or_default());
if tracks.is_empty() {
fail_collection_request(generation, "This playlist has no playable tracks");
return;
}
preflight_collection(
generation,
CacheCollection {
kind: CollectionKind::Playlist,
key,
title: playlist.name,
tracks,
album_fallback: None,
},
)
.await;
});
}
pub fn confirm_collection_cache(mode: String) {
let mode = match mode.as_str() {
"all" => CollectionCacheMode::All,
"missing" => CollectionCacheMode::Missing,
_ => return,
};
let generation = COLLECTION_REQUEST_GENERATION.load(Ordering::Acquire);
COLLECTION_REQUEST_GENERATION.fetch_add(1, Ordering::AcqRel);
publish_collection_ui(false, String::new(), false, "{}".to_string());
crate::spawn(async move {
let collection = {
let mut pending = pending_collection().lock().await;
match pending.take() {
Some(pending) if pending.generation == generation => Some(pending.collection),
_ => None,
}
};
if let Some(collection) = collection {
log::info!(
"[qbz-qt] offline collection choice: {} mode={mode:?}",
collection.key
);
execute_collection_cache(collection, mode).await;
}
});
}
pub fn cancel_collection_cache() {
let generation = COLLECTION_REQUEST_GENERATION.load(Ordering::Acquire);
COLLECTION_REQUEST_GENERATION.fetch_add(1, Ordering::AcqRel);
publish_collection_ui(false, String::new(), false, "{}".to_string());
crate::spawn(async move {
let mut pending = pending_collection().lock().await;
if pending.as_ref().map(|p| p.generation) == Some(generation) {
*pending = None;
}
});
}
@@ -477,3 +830,57 @@ async fn remove_cached_inner(id: u64, toast: bool) {
crate::toast_qt::success(qbz_i18n::t("Removed from offline"));
}
}
#[cfg(test)]
mod collection_tests {
use super::{should_queue_collection_track, unique_tracks, CollectionCacheMode};
use qbz_offline_cache::OfflineCacheStatus;
#[test]
fn playlist_snapshot_deduplicates_cache_keys_without_reordering() {
let tracks = [4, 9, 4, 12]
.into_iter()
.map(|id| qbz_models::Track {
id,
..Default::default()
})
.collect();
let ids: Vec<u64> = unique_tracks(tracks)
.into_iter()
.map(|track| track.id)
.collect();
assert_eq!(ids, vec![4, 9, 12]);
}
#[test]
fn missing_mode_preserves_ready_and_in_flight_rows() {
assert!(!should_queue_collection_track(
Some(OfflineCacheStatus::Ready),
CollectionCacheMode::Missing
));
assert!(!should_queue_collection_track(
Some(OfflineCacheStatus::Downloading),
CollectionCacheMode::Missing
));
assert!(should_queue_collection_track(
Some(OfflineCacheStatus::Failed),
CollectionCacheMode::Missing
));
assert!(should_queue_collection_track(
None,
CollectionCacheMode::Missing
));
}
#[test]
fn all_mode_requeues_ready_but_never_duplicates_an_active_job() {
assert!(should_queue_collection_track(
Some(OfflineCacheStatus::Ready),
CollectionCacheMode::All
));
assert!(!should_queue_collection_track(
Some(OfflineCacheStatus::Queued),
CollectionCacheMode::All
));
}
}
@@ -58,6 +58,15 @@ pub mod qbz_offline_manager_bridge {
/// spinner — on the one screen whose whole job is to show you that you
/// DO have downloads.
#[qproperty(bool, manager_loading)]
/// Shared album/playlist offline-cache preflight. The key is
/// `album:<id>` / `playlist:<id>` so only the button that launched the
/// request draws a busy state. The choice document is deliberately
/// tiny; Rust retains the fetched track snapshot while the modal is
/// open instead of serialising a potentially huge playlist to QML.
#[qproperty(bool, collection_preflight_loading)]
#[qproperty(QString, collection_preflight_key)]
#[qproperty(bool, collection_choice_open)]
#[qproperty(QString, collection_choice_json)]
type QbzOffline = super::QbzOfflineRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
@@ -95,6 +104,17 @@ pub mod qbz_offline_manager_bridge {
#[qinvokable]
fn clear_all(self: Pin<&mut QbzOffline>);
// --- Album / playlist entry-point preflight -----------------------
#[qinvokable]
fn cache_album(self: Pin<&mut QbzOffline>, album_id: QString);
#[qinvokable]
fn cache_playlist(self: Pin<&mut QbzOffline>, playlist_id: QString);
/// `mode`: `all` (also repairs existing copies) | `missing`.
#[qinvokable]
fn confirm_collection_cache(self: Pin<&mut QbzOffline>, mode: QString);
#[qinvokable]
fn cancel_collection_cache(self: Pin<&mut QbzOffline>);
// --- Per-row ------------------------------------------------------
#[qinvokable]
fn remove_track(self: Pin<&mut QbzOffline>, track_id: QString);
@@ -124,6 +144,10 @@ use qbz_offline_manager_bridge::QbzOffline;
pub struct QbzOfflineRust {
manager_json: QString,
manager_loading: bool,
collection_preflight_loading: bool,
collection_preflight_key: QString,
collection_choice_open: bool,
collection_choice_json: QString,
}
impl Default for QbzOfflineRust {
@@ -131,6 +155,10 @@ impl Default for QbzOfflineRust {
Self {
manager_json: QString::from("{}"),
manager_loading: true,
collection_preflight_loading: false,
collection_preflight_key: QString::default(),
collection_choice_open: false,
collection_choice_json: QString::from("{}"),
}
}
}
@@ -195,6 +223,19 @@ impl qbz_offline_manager_bridge::QbzOffline {
crate::offline_cache_qt::clear_all();
}
pub fn cache_album(self: Pin<&mut Self>, album_id: QString) {
crate::offline_cache_qt::cache_album(album_id.to_string());
}
pub fn cache_playlist(self: Pin<&mut Self>, playlist_id: QString) {
crate::offline_cache_qt::cache_playlist(playlist_id.to_string());
}
pub fn confirm_collection_cache(self: Pin<&mut Self>, mode: QString) {
crate::offline_cache_qt::confirm_collection_cache(mode.to_string());
}
pub fn cancel_collection_cache(self: Pin<&mut Self>) {
crate::offline_cache_qt::cancel_collection_cache();
}
pub fn remove_track(self: Pin<&mut Self>, track_id: QString) {
if let Ok(id) = track_id.to_string().parse::<u64>() {
crate::offline_cache_qt::remove_cached(id);
@@ -103,6 +103,15 @@ pub mod qbz_playlist_manager_bridge {
#[qinvokable]
fn toggle_folders_collapsed(self: Pin<&mut QbzPlaylistManager>);
/// Grid/list folder-card navigation. Editing remains a separate
/// QbzFolderEdit action owned exclusively by the pencil affordance.
#[qinvokable]
fn open_folder(self: Pin<&mut QbzPlaylistManager>, folder_id: QString);
/// Return from the current grid/list folder to the manager root.
#[qinvokable]
fn close_folder(self: Pin<&mut QbzPlaylistManager>);
/// Expand / collapse one folder in the tree.
#[qinvokable]
fn toggle_tree_folder(self: Pin<&mut QbzPlaylistManager>, folder_id: QString);
@@ -216,6 +225,14 @@ impl qbz_playlist_manager_bridge::QbzPlaylistManager {
crate::playlist_manager_qt::toggle_folders_collapsed();
}
pub fn open_folder(self: Pin<&mut Self>, folder_id: QString) {
crate::playlist_manager_qt::open_folder(&folder_id.to_string());
}
pub fn close_folder(self: Pin<&mut Self>) {
crate::playlist_manager_qt::close_folder();
}
pub fn toggle_tree_folder(self: Pin<&mut Self>, folder_id: QString) {
crate::playlist_manager_qt::toggle_tree_folder(&folder_id.to_string());
}
+111 -24
View File
@@ -106,6 +106,9 @@ struct Toolbar {
view_mode: String,
folder_mode: bool,
folders_collapsed: bool,
/// Grid/list drill-down. `None` is the root beside the folder cards;
/// tree mode owns its own expand state and never reads this value.
current_folder_id: Option<String>,
}
impl Default for Toolbar {
@@ -118,6 +121,7 @@ impl Default for Toolbar {
view_mode: "grid".into(),
folder_mode: true,
folders_collapsed: false,
current_folder_id: None,
}
}
}
@@ -138,6 +142,8 @@ struct ManagerDoc {
view_mode: String,
folder_mode: bool,
folders_collapsed: bool,
current_folder_id: String,
current_folder_name: String,
can_reorder: bool,
/// Always equal to `foldersJson.length` — same source, same closure (D5).
folder_count: i32,
@@ -192,16 +198,35 @@ fn build_document() -> (String, String) {
FOLDERS_CACHE.lock().unwrap().clone()
};
let playlists = rows::visible_playlist_rows(
&data,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
tb.folder_mode,
&tb.view_mode,
offline,
);
let current_folder = (tb.folder_mode && tb.view_mode != "tree")
.then_some(tb.current_folder_id.as_deref())
.flatten()
.and_then(|id| data.folders.iter().find(|folder| folder.id == id));
let (current_folder_id, current_folder_name) = current_folder
.map(|folder| (folder.id.clone(), folder.name.clone()))
.unwrap_or_default();
let playlists = if current_folder_id.is_empty() {
rows::visible_playlist_rows(
&data,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
tb.folder_mode,
&tb.view_mode,
offline,
)
} else {
rows::visible_folder_playlist_rows(
&data,
&current_folder_id,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
offline,
)
};
let tree = if tb.folder_mode && tb.view_mode == "tree" {
ensure_tree_init(&data);
@@ -227,6 +252,8 @@ fn build_document() -> (String, String) {
view_mode: tb.view_mode.clone(),
folder_mode: tb.folder_mode,
folders_collapsed: tb.folders_collapsed,
current_folder_id,
current_folder_name,
// D9: the DIRECTION is part of the gate. The reference omits it, and
// under custom+descending its arrows renumber `position = 0..n` over
// the REVERSED order — one press silently rewrites the user's whole
@@ -462,7 +489,12 @@ pub(crate) fn set_view_mode(value: &str) {
"grid" | "list" | "tree" => value,
_ => "grid",
};
TOOLBAR.lock().unwrap().view_mode = mode.to_string();
let mut toolbar = TOOLBAR.lock().unwrap();
toolbar.view_mode = mode.to_string();
if mode == "tree" {
toolbar.current_folder_id = None;
}
drop(toolbar);
publish_document();
}
@@ -475,6 +507,9 @@ pub(crate) fn toggle_folder_mode() {
if !tb.folder_mode && tb.view_mode == "tree" {
tb.view_mode = "grid".into();
}
if !tb.folder_mode {
tb.current_folder_id = None;
}
}
publish_document();
}
@@ -493,6 +528,42 @@ pub(crate) fn toggle_folders_collapsed() {
publish_document();
}
/// Enter one folder without changing the user's grid/list presentation.
/// Invalid/stale ids are ignored rather than publishing an inescapable empty
/// drill-down after a concurrent folder deletion.
pub(crate) fn open_folder(folder_id: &str) {
let folder_id = folder_id.trim();
if folder_id.is_empty() {
return;
}
let exists = CACHE
.lock()
.unwrap_or_else(|error| error.into_inner())
.as_ref()
.is_some_and(|data| data.folders.iter().any(|folder| folder.id == folder_id));
if !exists {
return;
}
{
let mut toolbar = TOOLBAR.lock().unwrap_or_else(|error| error.into_inner());
toolbar.folder_mode = true;
if toolbar.view_mode == "tree" {
toolbar.view_mode = "grid".into();
}
toolbar.current_folder_id = Some(folder_id.to_string());
}
publish_document();
}
/// Return from a grid/list folder drill-down to the root folders + playlists.
pub(crate) fn close_folder() {
TOOLBAR
.lock()
.unwrap_or_else(|error| error.into_inner())
.current_folder_id = None;
publish_document();
}
pub(crate) fn toggle_tree_folder(folder_id: &str) {
{
let mut expanded = EXPANDED.lock().unwrap();
@@ -535,19 +606,35 @@ pub(crate) fn visible_qobuz_ids() -> Vec<u64> {
let tb = TOOLBAR.lock().unwrap().clone();
let query = tb.search.trim().to_lowercase();
let offline = crate::offline_fwd::engine().status().is_offline();
rows::visible_playlist_rows(
&data,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
tb.folder_mode,
&tb.view_mode,
offline,
)
.iter()
.filter_map(|r| r.id.parse::<u64>().ok())
.collect()
let current_folder = (tb.folder_mode && tb.view_mode != "tree")
.then_some(tb.current_folder_id.as_deref())
.flatten()
.filter(|id| data.folders.iter().any(|folder| folder.id == *id));
let rows = if let Some(folder_id) = current_folder {
rows::visible_folder_playlist_rows(
&data,
folder_id,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
offline,
)
} else {
rows::visible_playlist_rows(
&data,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
tb.folder_mode,
&tb.view_mode,
offline,
)
};
rows.iter()
.filter_map(|r| r.id.parse::<u64>().ok())
.collect()
}
// ---------------------------------------------------------------------------
@@ -506,6 +506,29 @@ pub(crate) fn visible_playlist_rows(
entries.iter().map(|e| e.item()).collect()
}
/// The filtered + sorted members of one opened folder in grid/list mode.
///
/// Build the same merged Qobuz/local set as the flat surface first, then
/// constrain it by the already-normalised folder id carried on every wire
/// row. This keeps search, visibility, offline and ordering semantics exactly
/// aligned with the root view while allowing folder cards to be genuine
/// navigation targets instead of aliases for Edit.
#[allow(clippy::too_many_arguments)]
pub(crate) fn visible_folder_playlist_rows(
data: &PmData,
folder_id: &str,
query: &str,
filter: &str,
sort: &str,
asc: bool,
offline: bool,
) -> Vec<PlaylistRow> {
visible_playlist_rows(data, query, filter, sort, asc, false, "grid", offline)
.into_iter()
.filter(|row| row.folder_id == folder_id)
.collect()
}
/// Flatten folders + their (expanded) members + the root run into the tree.
///
/// `expanded` is passed IN rather than read from a global so this stays pure:
@@ -810,6 +833,25 @@ mod tests {
assert_eq!(ids(&tree_mode).len(), 4);
}
#[test]
fn opened_folder_contains_only_its_qobuz_and_local_members() {
let mut qobuz_member = pl(1, "Qobuz member");
qobuz_member.folder_id = Some("f1".into());
let mut local_member = local("l1", "Local member");
local_member.folder_id = Some("f1".into());
let mut other = pl(2, "Other folder");
other.folder_id = Some("f2".into());
let data = PmData {
playlists: vec![qobuz_member, other, pl(3, "Root")],
locals: vec![local_member, local("l2", "Root local")],
folders: vec![folder("f1", "One"), folder("f2", "Two")],
};
let rows = visible_folder_playlist_rows(&data, "f1", "", "all", "name", true, false);
assert_eq!(ids(&rows), vec!["local:l1", "1"]);
assert!(rows.iter().all(|row| row.folder_id == "f1"));
}
#[test]
fn visibility_filter_applies_to_locals_own_hidden_flag() {
let mut hidden_q = pl(1, "HiddenQ");
+162
View File
@@ -0,0 +1,162 @@
//! Read-through cache for profile-scoped media-server metadata sidecars.
//!
//! Remote rows are mapped on paging paths where a SQLite lookup per item would
//! be a severe regression. The small sidecar database is therefore decoded
//! once per active profile and indexed by `(source, server, album)`; a save
//! replaces and reloads the snapshot atomically.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, RwLock};
use qbz_app::settings::media_servers::MediaServerKind;
use qbz_library::{AlbumTagSidecar, LocalTrack, RemoteTagSidecarStore, RemoteTagTarget};
/// A cached local file stored in a remote sidecar must retain LOCAL artwork
/// provenance. Without this marker, the artwork window hands `/cache/foo.jpg`
/// to Plex/Jellyfin as if it were a server token.
pub const LOCAL_ART_PREFIX: &str = qbz_local_catalog::SIDECAR_LOCAL_ART_PREFIX;
#[derive(Default)]
struct Snapshot {
path: Option<PathBuf>,
albums: HashMap<RemoteTagTarget, AlbumTagSidecar>,
}
static SNAPSHOT: LazyLock<RwLock<Snapshot>> = LazyLock::new(|| RwLock::new(Snapshot::default()));
pub fn path() -> Option<PathBuf> {
crate::local_state::db_path().and_then(|path| {
path.parent()
.map(|parent| parent.join("metadata_sidecars.db"))
})
}
fn load(path: &Path) -> HashMap<RemoteTagTarget, AlbumTagSidecar> {
if !path.is_file() {
return HashMap::new();
}
match RemoteTagSidecarStore::open(path).and_then(|store| store.all()) {
Ok(rows) => rows
.into_iter()
.map(|stored| (stored.target, stored.sidecar))
.collect(),
Err(error) => {
log::warn!("[qbz-qt] remote metadata sidecar load failed: {error}");
HashMap::new()
}
}
}
fn ensure_profile() {
let path = path();
let needs_reload = SNAPSHOT
.read()
.map(|snapshot| snapshot.path != path)
.unwrap_or(true);
if !needs_reload {
return;
}
let albums = path.as_deref().map(load).unwrap_or_default();
if let Ok(mut snapshot) = SNAPSHOT.write() {
if snapshot.path != path {
snapshot.path = path;
snapshot.albums = albums;
}
}
}
pub fn reload() {
let path = path();
let albums = path.as_deref().map(load).unwrap_or_default();
if let Ok(mut snapshot) = SNAPSHOT.write() {
snapshot.path = path;
snapshot.albums = albums;
}
}
pub fn active_source_instance(source: &str) -> String {
let value = match canonical_source(source) {
"plex" => crate::local_plex::settings().machine_id,
"jellyfin" => crate::media_servers_qt::get(MediaServerKind::Jellyfin).server_id,
"subsonic" => crate::media_servers_qt::get(MediaServerKind::Subsonic).server_id,
_ => String::new(),
};
if value.trim().is_empty() {
"default".to_string()
} else {
value
}
}
pub fn canonical_source(source: &str) -> &str {
match source.to_ascii_lowercase().as_str() {
"navidrome" | "gonic" | "airsonic" | "astiga" => "subsonic",
"plex" => "plex",
"jellyfin" => "jellyfin",
"subsonic" => "subsonic",
_ => "",
}
}
pub fn target(source: &str, source_instance: &str, album_id: &str) -> RemoteTagTarget {
RemoteTagTarget::new(canonical_source(source), source_instance, album_id)
}
pub fn sidecar(target: &RemoteTagTarget) -> Option<AlbumTagSidecar> {
ensure_profile();
SNAPSHOT
.read()
.ok()
.and_then(|snapshot| snapshot.albums.get(target).cloned())
}
pub fn save(target: &RemoteTagTarget, sidecar: &AlbumTagSidecar) -> Result<(), String> {
let path = path().ok_or_else(|| "active user data directory unavailable".to_string())?;
let mut store = RemoteTagSidecarStore::open(&path).map_err(|error| error.to_string())?;
store
.put(target, sidecar)
.map_err(|error| error.to_string())?;
reload();
Ok(())
}
/// Apply the effective sidecar to a mapped remote row without doing I/O.
pub fn apply(track: &mut LocalTrack, source: &str, source_instance: &str, album_id: &str) {
let target = target(source, source_instance, album_id);
let Some(sidecar) = sidecar(&target) else {
return;
};
qbz_library::apply_sidecar_to_track(track, &sidecar);
if let Some(path) = sidecar
.extended_album
.as_ref()
.and_then(|album| album.artwork_path.as_deref())
.filter(|path| !path.trim().is_empty())
{
track.artwork_path = Some(format!("{LOCAL_ART_PREFIX}{path}"));
}
}
pub fn local_art_path(token: &str) -> Option<&str> {
token.strip_prefix(LOCAL_ART_PREFIX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn navidrome_family_uses_the_subsonic_sidecar_namespace() {
for word in ["navidrome", "gonic", "airsonic", "astiga", "subsonic"] {
assert_eq!(canonical_source(word), "subsonic");
}
}
#[test]
fn local_art_marker_is_unambiguous_and_reversible() {
let token = format!("{LOCAL_ART_PREFIX}/cache/qbz/cover.jpg");
assert_eq!(local_art_path(&token), Some("/cache/qbz/cover.jpg"));
assert_eq!(local_art_path("/Items/12/Images/Primary"), None);
}
}
+87 -3
View File
@@ -20,7 +20,7 @@
//! ([`pick_and_add_folder`]). Kept as a record because the note was load
//! bearing: it justified a downgrade for weeks after its premise expired.
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::{LazyLock, Mutex};
use qbz_library::{LibraryDatabase, ScanEvent};
@@ -119,6 +119,19 @@ pub struct Snapshot {
pub scanning: bool,
pub processed: i32,
pub total: i32,
/// Progress within the currently scanned folder/root. These counters are
/// derived from the streaming scanner's global offsets; no second tree
/// walk is introduced merely to paint UI.
#[serde(rename = "sourceProcessed")]
pub source_processed: i32,
#[serde(rename = "sourceTotal")]
pub source_total: i32,
#[serde(rename = "currentRootId")]
pub current_root_id: i64,
#[serde(rename = "sourceIndex")]
pub source_index: i32,
#[serde(rename = "sourceCount")]
pub source_count: i32,
pub file: String,
pub cleaning: bool,
#[serde(rename = "cleanupStatus")]
@@ -203,6 +216,12 @@ static SCANNING: AtomicBool = AtomicBool::new(false);
static CANCEL: AtomicBool = AtomicBool::new(false);
static PROCESSED: AtomicU32 = AtomicU32::new(0);
static TOTAL: AtomicU32 = AtomicU32::new(0);
static SOURCE_PROCESSED: AtomicU32 = AtomicU32::new(0);
static SOURCE_TOTAL: AtomicU32 = AtomicU32::new(0);
static SOURCE_BASE: AtomicU32 = AtomicU32::new(0);
static CURRENT_ROOT_ID: AtomicI64 = AtomicI64::new(0);
static SOURCE_INDEX: AtomicU32 = AtomicU32::new(0);
static SOURCE_COUNT: AtomicU32 = AtomicU32::new(0);
static CURRENT_FILE: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
static CLEANING: AtomicBool = AtomicBool::new(false);
static CLEANUP_STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
@@ -298,6 +317,11 @@ pub fn snapshot() -> Snapshot {
scanning: SCANNING.load(Ordering::SeqCst),
processed: PROCESSED.load(Ordering::SeqCst) as i32,
total: TOTAL.load(Ordering::SeqCst) as i32,
source_processed: SOURCE_PROCESSED.load(Ordering::SeqCst) as i32,
source_total: SOURCE_TOTAL.load(Ordering::SeqCst) as i32,
current_root_id: CURRENT_ROOT_ID.load(Ordering::SeqCst),
source_index: SOURCE_INDEX.load(Ordering::SeqCst) as i32,
source_count: SOURCE_COUNT.load(Ordering::SeqCst) as i32,
file: CURRENT_FILE
.lock()
.unwrap_or_else(|e| e.into_inner())
@@ -688,7 +712,7 @@ pub async fn toggle_folder_enabled(id: i64) {
/// Scan every enabled folder (`None`) or exactly one (`Some(id)`).
///
/// Progress rides the statics; a 2 s ticker republishes the settings document
/// Progress rides the statics; a short ticker republishes the settings document
/// while the scan runs (the document is the only transport this port has, and
/// republishing per FILE would rebuild the whole snapshot thousands of times).
pub fn scan(folder_id: Option<i64>) -> bool {
@@ -698,13 +722,23 @@ pub fn scan(folder_id: Option<i64>) -> bool {
CANCEL.store(false, Ordering::SeqCst);
PROCESSED.store(0, Ordering::SeqCst);
TOTAL.store(0, Ordering::SeqCst);
SOURCE_PROCESSED.store(0, Ordering::SeqCst);
SOURCE_TOTAL.store(0, Ordering::SeqCst);
SOURCE_BASE.store(0, Ordering::SeqCst);
CURRENT_ROOT_ID.store(0, Ordering::SeqCst);
SOURCE_INDEX.store(0, Ordering::SeqCst);
SOURCE_COUNT.store(0, Ordering::SeqCst);
*CURRENT_FILE.lock().unwrap_or_else(|e| e.into_inner()) = String::new();
crate::spawn(async move {
// Paint the scanning state immediately. The ticker deliberately stays
// independent of per-file events so a large library never turns QML
// publication into work proportional to its track count.
super::publish_snapshot().await;
// Progress ticker.
crate::spawn(async {
while SCANNING.load(Ordering::SeqCst) {
tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
super::publish_snapshot().await;
}
});
@@ -715,9 +749,28 @@ pub fn scan(folder_id: Option<i64>) -> bool {
};
let cache = qbz_library::get_artwork_cache_dir();
let ids = folder_id.map(|id| vec![id]);
let source_count = db
.get_folders_with_metadata()
.map(|folders| {
folders
.into_iter()
.filter(|folder| {
folder.enabled
&& ids
.as_ref()
.map_or(true, |wanted| wanted.contains(&folder.id))
})
.count()
})
.unwrap_or(0);
SOURCE_COUNT.store(source_count.min(u32::MAX as usize) as u32, Ordering::SeqCst);
let on_event = move |event: ScanEvent| match event {
ScanEvent::TotalsAdded { total } => {
TOTAL.store(total, Ordering::SeqCst);
SOURCE_TOTAL.store(
total.saturating_sub(SOURCE_BASE.load(Ordering::SeqCst)),
Ordering::SeqCst,
);
}
ScanEvent::FileStarted { path } => {
let name = basename(&path);
@@ -726,6 +779,37 @@ pub fn scan(folder_id: Option<i64>) -> bool {
ScanEvent::FileDone { processed, total } => {
PROCESSED.store(processed, Ordering::SeqCst);
TOTAL.store(total, Ordering::SeqCst);
let base = SOURCE_BASE.load(Ordering::SeqCst);
SOURCE_PROCESSED.store(processed.saturating_sub(base), Ordering::SeqCst);
SOURCE_TOTAL.store(total.saturating_sub(base), Ordering::SeqCst);
}
ScanEvent::RootStarted { root_id, .. } => {
CURRENT_ROOT_ID.store(root_id, Ordering::SeqCst);
// `TOTAL`, rather than processed, is the next root's
// streaming offset even when a malformed file in the
// previous root was discovered but could not be emitted.
SOURCE_BASE.store(TOTAL.load(Ordering::SeqCst), Ordering::SeqCst);
SOURCE_PROCESSED.store(0, Ordering::SeqCst);
SOURCE_TOTAL.store(0, Ordering::SeqCst);
SOURCE_INDEX.fetch_add(1, Ordering::SeqCst);
}
ScanEvent::RootFinished {
root_id,
discovered,
..
} => {
// An unavailable root finishes without `RootStarted`
// (the scanner cannot prepare a generation for it). Keep
// the source ordinal/name honest instead of leaving the
// previous folder painted for this slot.
if CURRENT_ROOT_ID.load(Ordering::SeqCst) != root_id {
CURRENT_ROOT_ID.store(root_id, Ordering::SeqCst);
SOURCE_BASE.store(TOTAL.load(Ordering::SeqCst), Ordering::SeqCst);
SOURCE_INDEX.fetch_add(1, Ordering::SeqCst);
}
let value = discovered.min(u32::MAX as u64) as u32;
SOURCE_PROCESSED.store(value, Ordering::SeqCst);
SOURCE_TOTAL.store(value, Ordering::SeqCst);
}
// The missing-file cleanup phase. The reference puts it in the
// SAME slot the per-file name occupies, so the progress line
+15
View File
@@ -24,6 +24,8 @@ pub mod qbz_tag_editor_bridge {
#[qml_element]
#[qml_singleton]
#[qproperty(bool, editor_open)]
#[qproperty(bool, track_editor_open)]
#[qproperty(i32, track_editor_initial_index)]
#[qproperty(bool, editor_loading)]
#[qproperty(bool, editor_saving)]
#[qproperty(i32, editor_progress_current)]
@@ -51,6 +53,11 @@ pub mod qbz_tag_editor_bridge {
#[qinvokable]
fn leave(self: Pin<&mut QbzTagEditor>);
/// Promote the current track-modal snapshot into the existing full
/// album editor without resolving the album a second time.
#[qinvokable]
fn promote_to_album_editor(self: Pin<&mut QbzTagEditor>);
/// The entire validated draft, encoded as JSON. File paths never come
/// back from QML; Rust resolves row ids against the open session.
#[qinvokable]
@@ -96,6 +103,8 @@ use qbz_tag_editor_bridge::QbzTagEditor;
pub struct QbzTagEditorRust {
editor_open: bool,
track_editor_open: bool,
track_editor_initial_index: i32,
editor_loading: bool,
editor_saving: bool,
editor_progress_current: i32,
@@ -113,6 +122,8 @@ impl Default for QbzTagEditorRust {
fn default() -> Self {
Self {
editor_open: false,
track_editor_open: false,
track_editor_initial_index: -1,
editor_loading: false,
editor_saving: false,
editor_progress_current: 0,
@@ -153,6 +164,10 @@ impl qbz_tag_editor_bridge::QbzTagEditor {
crate::tag_editor_qt::leave();
}
pub fn promote_to_album_editor(self: Pin<&mut Self>) {
crate::tag_editor_qt::promote_to_album_editor();
}
pub fn save(self: Pin<&mut Self>, draft_json: QString) {
crate::tag_editor_qt::save(&draft_json.to_string());
}
+363 -28
View File
@@ -21,7 +21,12 @@ use serde::{Deserialize, Serialize};
#[derive(Clone)]
enum EditorTarget {
Library,
Ephemeral { session_path: String },
Ephemeral {
session_path: String,
},
Remote {
target: qbz_library::RemoteTagTarget,
},
}
#[derive(Clone)]
@@ -64,11 +69,49 @@ static ARTWORK_GEN: AtomicU64 = AtomicU64::new(0);
static REMOTE_SEQ: AtomicI32 = AtomicI32::new(0);
static ARTWORK_SEQ: AtomicU64 = AtomicU64::new(0);
static SAVE_ACTIVE: AtomicBool = AtomicBool::new(false);
static TRACK_MODAL_ACTIVE: AtomicBool = AtomicBool::new(false);
fn session() -> &'static Mutex<Option<EditorSession>> {
SESSION.get_or_init(|| Mutex::new(None))
}
/// Recover the physical source from the authoritative row. Older/cache rows
/// can carry an empty or folded `source`, while their album identity remains
/// explicitly namespaced. Treating those rows as local turns the server item
/// id in `file_path` into a filesystem path and produces the misleading
/// "local file versions only" toast.
fn editor_remote_source(track: &LocalTrack) -> String {
let direct =
crate::remote_metadata_qt::canonical_source(track.source.as_deref().unwrap_or_default());
if !direct.is_empty() {
return direct.to_string();
}
track
.album_group_key
.split_once(':')
.map(|(prefix, _)| crate::remote_metadata_qt::canonical_source(prefix))
.filter(|source| !source.is_empty())
.unwrap_or_default()
.to_string()
}
/// Sidecars belong to an album directory, not to the album identity string.
/// Folder grouping normally makes both values equal, but metadata grouping
/// deliberately does not. Prefer a real group directory, then the selected
/// file's containing folder. Requiring the selected path itself to be a file
/// was unnecessarily strict (a renamed/missing row can still be corrected in
/// a valid album sidecar) and broke mounted network libraries in particular.
fn editor_directory(track: &LocalTrack) -> Option<String> {
let group = Path::new(&track.album_group_key);
if group.is_dir() {
return Some(group.to_string_lossy().into_owned());
}
Path::new(&track.file_path)
.parent()
.filter(|directory| directory.is_dir())
.map(|directory| directory.to_string_lossy().into_owned())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct TagLayerDoc {
@@ -184,6 +227,7 @@ struct EditorSeed {
discogs_release_id: String,
artwork: ArtworkSeed,
sidecar_exists: bool,
remote_sidecar_only: bool,
can_direct_write: bool,
direct_write_reason: String,
inspection: InspectionDoc,
@@ -192,30 +236,63 @@ struct EditorSeed {
fn build_seed(open: &EditorSession) -> EditorSeed {
let tracks = &open.tracks;
let remote_target = match &open.target {
EditorTarget::Remote { target } => Some(target),
_ => None,
};
let cue_based = tracks
.iter()
.any(|track| track.cue_file_path.is_some() || track.cue_start_secs.is_some());
let paths = tracks
.iter()
.map(|track| track.file_path.clone())
.collect::<Vec<_>>();
let inspection = InspectionDoc::from_result(qbz_library::inspect_album_tag_layers(&paths));
let snapshots = qbz_library::read_editor_tag_snapshots(&paths);
let paths = if remote_target.is_some() {
Vec::new()
} else {
tracks
.iter()
.map(|track| track.file_path.clone())
.collect::<Vec<_>>()
};
let inspection = if remote_target.is_some() {
InspectionDoc {
file_count: 0,
canonical_layers: Vec::new(),
present_layers: Vec::new(),
conflicting_files: 0,
writable_files: 0,
direct_write_supported: false,
error: String::new(),
}
} else {
InspectionDoc::from_result(qbz_library::inspect_album_tag_layers(&paths))
};
let snapshots = if remote_target.is_some() {
Vec::new()
} else {
qbz_library::read_editor_tag_snapshots(&paths)
};
let snapshot_by_path = snapshots
.iter()
.map(|snapshot| (snapshot.file_path.as_str(), snapshot))
.collect::<HashMap<_, _>>();
let sidecar = qbz_library::read_album_sidecar(Path::new(&open.directory))
.ok()
.flatten();
let sidecar = remote_target
.and_then(crate::remote_metadata_qt::sidecar)
.or_else(|| {
(remote_target.is_none())
.then(|| qbz_library::read_album_sidecar(Path::new(&open.directory)))
.and_then(Result::ok)
.flatten()
});
let extended_sidecar = sidecar
.as_ref()
.and_then(|sidecar| sidecar.extended_album.as_ref());
let local_files = tracks
.iter()
.all(|track| Path::new(&track.file_path).is_file());
let can_direct_write = !cue_based && local_files && inspection.direct_write_supported;
let direct_write_reason = if cue_based {
let local_files = remote_target.is_none()
&& tracks
.iter()
.all(|track| Path::new(&track.file_path).is_file());
let can_direct_write =
remote_target.is_none() && !cue_based && local_files && inspection.direct_write_supported;
let direct_write_reason = if remote_target.is_some() {
qbz_i18n::t("Media-server metadata is stored in a local sidecar.")
} else if cue_based {
qbz_i18n::t("CUE-based albums use sidecar metadata.")
} else if !local_files {
qbz_i18n::t("One or more audio files are unavailable.")
@@ -285,6 +362,13 @@ fn build_seed(open: &EditorSession) -> EditorSeed {
.staged_artwork
.as_ref()
.map(|artwork| artwork.preview_path.clone())
.or_else(|| {
sidecar
.as_ref()
.and_then(|sidecar| sidecar.extended_album.as_ref())
.and_then(|album| album.artwork_path.clone())
.filter(|path| Path::new(path).is_file())
})
.or_else(|| {
tracks
.iter()
@@ -356,7 +440,12 @@ fn build_seed(open: &EditorSession) -> EditorSeed {
musicbrainz_album_artist_ids,
discogs_release_id,
artwork,
sidecar_exists: qbz_library::sidecar_path(Path::new(&open.directory)).exists(),
sidecar_exists: if remote_target.is_some() {
sidecar.is_some()
} else {
qbz_library::sidecar_path(Path::new(&open.directory)).exists()
},
remote_sidecar_only: remote_target.is_some(),
can_direct_write,
direct_write_reason,
inspection,
@@ -388,11 +477,15 @@ fn build_seed(open: &EditorSession) -> EditorSeed {
.unwrap_or_else(|| vec![artist_credit.clone()]);
TrackSeed {
id: track.id.to_string(),
file_name: Path::new(&track.file_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&track.file_path)
.to_string(),
file_name: if remote_target.is_some() {
track.file_path.clone()
} else {
Path::new(&track.file_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&track.file_path)
.to_string()
},
title: track.title.clone(),
track_number: track
.track_number
@@ -444,15 +537,17 @@ fn open_session(
directory: String,
tracks: Vec<LocalTrack>,
target: EditorTarget,
track_index: Option<usize>,
) {
if SAVE_ACTIVE.load(Ordering::Acquire) {
return;
}
if tracks.is_empty() || directory.trim().is_empty() {
if tracks.is_empty() {
crate::toast_qt::error(qbz_i18n::t("No local album version is selected."));
return;
}
if !Path::new(&directory).is_dir() {
let remote = matches!(target, EditorTarget::Remote { .. });
if !remote && (directory.trim().is_empty() || !Path::new(&directory).is_dir()) {
crate::toast_qt::info(qbz_i18n::t(
"Metadata editing is available for local file versions only.",
));
@@ -471,8 +566,15 @@ fn open_session(
artwork_candidates: HashMap::new(),
};
*session().lock().expect("tag editor session lock") = Some(open.clone());
crate::tag_editor_bridge::ui(|mut bridge| {
bridge.as_mut().set_editor_open(true);
crate::tag_editor_bridge::ui(move |mut bridge| {
let modal = track_index.is_some();
bridge.as_mut().set_editor_open(!modal);
bridge.as_mut().set_track_editor_open(modal);
bridge.as_mut().set_track_editor_initial_index(
track_index
.and_then(|index| i32::try_from(index).ok())
.unwrap_or(-1),
);
bridge.as_mut().set_editor_loading(true);
bridge.as_mut().set_editor_saving(false);
bridge.as_mut().set_editor_progress_current(0);
@@ -484,7 +586,10 @@ fn open_session(
bridge.as_mut().set_artwork_searching(false);
bridge.as_mut().set_artwork_loading(false);
});
crate::nav_qt::record("metadataeditor");
TRACK_MODAL_ACTIVE.store(track_index.is_some(), Ordering::Release);
if track_index.is_none() {
crate::nav_qt::record("metadataeditor");
}
crate::spawn(async move {
let started = std::time::Instant::now();
@@ -526,6 +631,7 @@ pub fn open(album_id: String) {
group_key,
tracks,
EditorTarget::Library,
None,
);
}
@@ -544,9 +650,104 @@ pub fn open_ephemeral(group_key: String) {
directory,
tracks,
EditorTarget::Ephemeral { session_path },
None,
);
}
/// Open the compact per-track editor on a real local row. The album siblings
/// are resolved from the authoritative library database, never from the
/// resident/paged QML model, so previous/next remains complete regardless of
/// virtualization or the active sort.
pub fn open_track(track: LocalTrack) {
let selected_id = track.id;
let group_key = track.album_group_key.clone();
let source = editor_remote_source(&track);
let remote = !source.is_empty();
if group_key.trim().is_empty() {
crate::toast_qt::info(qbz_i18n::t(
"Metadata editing is available for local file versions only.",
));
return;
}
let local_directory = (!remote).then(|| editor_directory(&track)).flatten();
crate::spawn(async move {
let lookup_key = group_key.clone();
let tracks = tokio::task::spawn_blocking(move || {
if remote {
crate::local_albums::fetch_album_tracks_blocking(&lookup_key)
} else {
crate::local_state::with_db(|db| db.get_album_tracks(&lookup_key))
.unwrap_or_default()
}
})
.await
.unwrap_or_default();
if !remote && local_directory.is_none() {
crate::toast_qt::info(qbz_i18n::t(
"The local album folder is not currently available.",
));
return;
}
let Some(index) = tracks.iter().position(|row| row.id == selected_id) else {
crate::toast_qt::info(qbz_i18n::t("The track is no longer in the library."));
return;
};
let target = if remote {
let effective_group = tracks
.first()
.map(|track| track.album_group_key.as_str())
.filter(|key| !key.is_empty())
.unwrap_or(&group_key);
let album_id = if source == "plex" {
effective_group
.strip_prefix("plex:album:")
.map(str::to_string)
.unwrap_or_else(|| effective_group.to_string())
} else {
effective_group
.strip_prefix(&format!("{source}:"))
.unwrap_or(effective_group)
.to_string()
};
EditorTarget::Remote {
target: crate::remote_metadata_qt::target(
&source,
&crate::remote_metadata_qt::active_source_instance(&source),
&album_id,
),
}
} else {
EditorTarget::Library
};
open_session(
group_key.clone(),
group_key.clone(),
if remote {
String::new()
} else {
local_directory.unwrap_or(group_key)
},
tracks,
target,
Some(index),
);
});
}
pub fn promote_to_album_editor() {
if session().lock().expect("tag editor session lock").is_none()
|| SAVE_ACTIVE.load(Ordering::Acquire)
{
return;
}
TRACK_MODAL_ACTIVE.store(false, Ordering::Release);
crate::tag_editor_bridge::ui(|mut bridge| {
bridge.as_mut().set_track_editor_open(false);
bridge.as_mut().set_editor_open(true);
});
crate::nav_qt::record("metadataeditor");
}
fn clear_session() {
if SAVE_ACTIVE.load(Ordering::Acquire) {
return;
@@ -555,6 +756,7 @@ fn clear_session() {
REMOTE_GEN.fetch_add(1, Ordering::SeqCst);
ARTWORK_GEN.fetch_add(1, Ordering::SeqCst);
let prior = session().lock().expect("tag editor session lock").take();
TRACK_MODAL_ACTIVE.store(false, Ordering::Release);
if let Some(path) = prior
.and_then(|session| session.staged_artwork)
.map(|artwork| artwork.preview_path)
@@ -569,6 +771,8 @@ fn clear_session() {
}
crate::tag_editor_bridge::ui(|mut bridge| {
bridge.as_mut().set_editor_open(false);
bridge.as_mut().set_track_editor_open(false);
bridge.as_mut().set_track_editor_initial_index(-1);
bridge.as_mut().set_editor_loading(false);
bridge.as_mut().set_remote_searching(false);
bridge.as_mut().set_remote_loading(false);
@@ -725,6 +929,11 @@ fn validate_draft(draft: SaveDraft, open: EditorSession) -> Result<SavePayload,
if !matches!(draft.persistence.as_str(), "sidecar" | "direct") {
return Err(qbz_i18n::t("Unknown metadata persistence mode."));
}
if direct && matches!(&open.target, EditorTarget::Remote { .. }) {
return Err(qbz_i18n::t(
"Media-server metadata can only be edited with a sidecar.",
));
}
if direct
&& open
.tracks
@@ -967,6 +1176,7 @@ enum SaveOutcome {
tracks: Vec<LocalTrack>,
},
Ephemeral,
Remote,
}
pub fn save(draft_json: &str) {
@@ -1002,7 +1212,32 @@ pub fn save(draft_json: &str) {
let result = tokio::task::spawn_blocking(move || {
let mut payload = payload;
let mut artwork_path = None::<String>;
if payload.direct {
let remote_target = match &payload.session.target {
EditorTarget::Remote { target } => Some(target.clone()),
_ => None,
};
if let Some(target) = remote_target.as_ref() {
if payload.direct {
return Err(qbz_library::LibraryError::Other(qbz_i18n::t(
"Media-server metadata can only be edited with a sidecar.",
)));
}
if let Some(cover) = payload.front_cover.as_ref() {
artwork_path =
qbz_library::MetadataExtractor::cache_artwork_bytes(&cover.bytes);
if artwork_path.is_none() {
return Err(qbz_library::LibraryError::Other(
"edited artwork could not be projected into the thumbnail cache"
.to_string(),
));
}
if let Some(extended) = payload.sidecar.extended_album.as_mut() {
extended.artwork_path = artwork_path.clone();
}
}
crate::remote_metadata_qt::save(target, &payload.sidecar)
.map_err(qbz_library::LibraryError::Other)?;
} else if payload.direct {
let total = payload.direct_tracks.len() as i32;
crate::tag_editor_bridge::ui(move |mut bridge| {
bridge.as_mut().set_editor_progress_total(total);
@@ -1071,6 +1306,7 @@ pub fn save(draft_json: &str) {
crate::local_ephemeral::apply_editor_update(session_path, &tracks)?;
SaveOutcome::Ephemeral
}
EditorTarget::Remote { .. } => SaveOutcome::Remote,
};
Ok::<_, qbz_library::LibraryError>(outcome)
})
@@ -1082,7 +1318,8 @@ pub fn save(draft_json: &str) {
});
SAVE_ACTIVE.store(false, Ordering::Release);
let editor_visible = crate::nav_qt::current_view() == "metadataeditor";
let editor_visible = crate::nav_qt::current_view() == "metadataeditor"
|| TRACK_MODAL_ACTIVE.load(Ordering::Acquire);
if SAVE_GEN.load(Ordering::SeqCst) != save_generation {
if !editor_visible {
clear_session();
@@ -1122,6 +1359,18 @@ pub fn save(draft_json: &str) {
}
crate::toast_qt::success(qbz_i18n::t("Album metadata saved."));
}
Ok(SaveOutcome::Remote) => {
// The remote caches remain authoritative and untouched. Their
// derived catalog projection notices the sidecar revision and
// rebuilds the affected source; direct mappers already see the
// refreshed in-process overlay immediately.
crate::local_catalog_qt::request_catch_up();
crate::local_bridge_ops::reload_browse();
if OPEN_GEN.load(Ordering::SeqCst) == open_generation {
close();
}
crate::toast_qt::success(qbz_i18n::t("Album metadata saved."));
}
Err(error) => {
log::error!("[qbz-qt] metadata save failed: {error}");
crate::toast_qt::error(qbz_i18n::t_args(
@@ -1692,6 +1941,39 @@ mod tests {
assert!(parse_optional_number("nope", "Track").is_err());
}
#[test]
fn editor_source_recovers_remote_rows_from_album_namespace() {
let mut track = LocalTrack {
source: None,
album_group_key: "jellyfin:album-9".to_string(),
file_path: "opaque-server-item".to_string(),
..LocalTrack::default()
};
assert_eq!(editor_remote_source(&track), "jellyfin");
track.album_group_key = "navidrome:album-4".to_string();
assert_eq!(editor_remote_source(&track), "subsonic");
track.source = Some("plex".to_string());
track.album_group_key.clear();
assert_eq!(editor_remote_source(&track), "plex");
}
#[test]
fn editor_directory_uses_real_parent_for_non_path_group_identity() {
let directory = std::env::temp_dir();
let file = directory.join("qbz-editor-location-fixture.flac");
let track = LocalTrack {
file_path: file.to_string_lossy().into_owned(),
album_group_key: "metadata:artist|album".to_string(),
..LocalTrack::default()
};
assert_eq!(
editor_directory(&track).as_deref(),
Some(directory.to_string_lossy().as_ref())
);
}
#[test]
fn year_validation_does_not_accept_sentinels_from_qml() {
assert_eq!(parse_year("").unwrap(), None);
@@ -1812,6 +2094,37 @@ mod tests {
);
}
#[test]
fn media_server_sessions_preserve_full_drafts_but_reject_direct_writes() {
let mut remote = session_fixture();
remote.directory.clear();
remote.target = EditorTarget::Remote {
target: qbz_library::RemoteTagTarget::new("jellyfin", "server-a", "album-9"),
};
for (index, track) in remote.tracks.iter_mut().enumerate() {
track.file_path = format!("native-track-{}", index + 1);
track.source = Some("jellyfin".to_string());
}
let rows = r#"[
{"id":"10","title":"First","trackNumber":"1","discNumber":"1",
"artistCredit":"Alpha","artists":["Alpha"],"composers":["Composer"],
"performers":["Player"],"musicbrainzRecordingId":"recording-1",
"musicbrainzTrackId":"track-1","musicbrainzArtistIds":["artist-1"]},
{"id":"20","title":"Second","trackNumber":"2","discNumber":"1",
"artistCredit":"Beta","artists":["Beta"]}
]"#;
let sidecar: SaveDraft = serde_json::from_str(&draft_json(rows)).unwrap();
let payload = validate_draft(sidecar, remote.clone()).unwrap();
assert!(!payload.direct);
assert_eq!(payload.sidecar.extended_tracks[0].composers, ["Composer"]);
assert_eq!(payload.sidecar.tracks[0].file_path, "native-track-1");
let direct_json =
draft_json(rows).replace("\"persistence\":\"sidecar\"", "\"persistence\":\"direct\"");
let direct: SaveDraft = serde_json::from_str(&direct_json).unwrap();
assert!(validate_draft(direct, remote).is_err());
}
#[test]
fn artwork_staging_rejects_non_images_and_uses_an_opaque_token() {
assert!(prepare_staged_artwork(99, b"not an image".to_vec(), "test".into()).is_err());
@@ -1855,4 +2168,26 @@ mod tests {
// type lazily, so neither AlbumView nor ephemeral can mount the editor.
assert!(!workspace.contains("primary: true"));
}
#[test]
fn track_editor_reuses_every_album_field_and_persistence_control() {
let wrapper = include_str!("../qml/controls/TrackMetadataModal.qml");
let shared = include_str!("../qml/controls/TagEditorModal.qml");
let workspace = include_str!("../qml/controls/TagEditorWorkspace.qml");
assert!(wrapper.contains("TagEditorModal"));
assert!(wrapper.contains("trackMode: true"));
assert!(wrapper.contains("leaveOnDestruction: false"));
assert!(shared.contains("onClicked: root.selectedTrackIndex--"));
assert!(shared.contains("onClicked: root.selectedTrackIndex++"));
assert!(shared.contains("QbzTagEditor.promoteToAlbumEditor()"));
assert!(shared.contains("root.persistence = index === 1 ? \"direct\" : \"sidecar\""));
assert!(shared.contains("QbzTagEditor.save(draft())"));
assert!(workspace.contains("workspace.editor.albumTitle"));
assert!(workspace.contains("workspace.editor.musicbrainzReleaseId"));
assert!(
workspace.contains("workspace.editor.musicbrainzRecordingId")
|| workspace.contains("rowData.musicbrainzRecordingId")
);
assert!(!wrapper.contains("filePath"));
}
}