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:
79 files changed
+6576
-452
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 || ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ---------------------------------------------------------------
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
¤t_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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user