feat(library): exclude chosen folders from music browsing
Replaces `src/lib/utils/podcastFilter.ts` — a shipped personal workaround that dropped any item whose name, album, album artist or artist was literally "Podcasts" — with a real user setting applied in Rust. The old filter was wrong twice over: it hardcoded one user's folder layout keyed on an English literal, and it put a domain rule (what a query should return) in the presentation layer. It slipped past `check:boundary` only because it matched on names rather than on an item-type array. - `repository::exclusions` owns the rule and the process-wide id set, the same shape as `online::STREAMING_QUALITY` so it survives a repository being rebuilt on re-login. - `HybridRepository` applies it where the cache and server legs of every cache-first query converge (`parallel_race` / `race_with_refresh`), plus the bespoke `get_items` path and the server-only reads. Filtering before the "has content" check is what makes a cache page of nothing but hidden items fall through to the server. - Exclusion is by stable item id, never by name, and matches an item's own id or any container link it carries (parent, album, library, series, season, artist). - A direct `get_item` lookup and the Downloads surface are deliberately unfiltered: hiding those would break playback and file management of anything inside a hidden folder. - `LibrarySettings` persists to `app_settings` and is restored in the setup hook, alongside the streaming-quality cap. Default is an empty list — nobody inherits the old "Podcasts" behaviour. - New commands `library_get_settings`, `library_set_settings` and `library_get_exclusion_candidates`; the candidates read goes through `get_items_unfiltered` so an already-hidden folder still appears in the picker and the setting can be undone. - Settings page gains a "Hidden Folders" section that renders the backend's candidate list and sends back ticked ids; it decides nothing. TRACES: UR-076 | DR-209 | UT-203
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
//! Library browsing preferences — currently, which folders are hidden.
|
||||
//!
|
||||
//! The setting replaces a hardcoded frontend filter that dropped any item
|
||||
//! literally named "Podcasts", which was one user's folder layout keyed on an
|
||||
//! English string and shipped to everyone. What is hidden is now a user choice
|
||||
//! made of stable ids, applied in the repository layer
|
||||
//! (`repository::exclusions`) so every query path agrees; the frontend only
|
||||
//! renders a picker over the candidates this module serves.
|
||||
//!
|
||||
//! TRACES: UR-076 | DR-209
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{debug, info, warn};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::repository::exclusions;
|
||||
use crate::repository::types::{GetItemsOptions, SearchScope};
|
||||
use crate::repository::MediaRepository;
|
||||
use crate::settings::LibrarySettings;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// `app_settings` key holding the persisted library preferences (JSON).
|
||||
///
|
||||
/// Persisted for the same reason the streaming cap is: a hidden folder that
|
||||
/// silently comes back on the next launch is a setting the user has to keep
|
||||
/// re-applying, and they would have no way to tell it had been forgotten.
|
||||
const LIBRARY_SETTINGS_KEY: &str = "library_settings";
|
||||
|
||||
/// How many immediate children of a library the picker will consider.
|
||||
///
|
||||
/// A music library's root listing is folders and (on some layouts) artists, not
|
||||
/// the whole catalog, so this is generous. It exists to stop a pathological
|
||||
/// library from turning the settings page into an unbounded fetch.
|
||||
const CANDIDATE_SCAN_LIMIT: usize = 500;
|
||||
|
||||
/// Something the user may choose to hide: a library, or a folder directly
|
||||
/// inside one.
|
||||
///
|
||||
/// Which containers are *offerable* is a domain question (it depends on the
|
||||
/// library's Jellyfin collection type and on what counts as a folder), so the
|
||||
/// list is assembled here and the frontend renders it verbatim.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExclusionCandidate {
|
||||
/// Stable Jellyfin item id — what gets stored when the user picks it.
|
||||
pub id: String,
|
||||
/// Display name of the folder (or of the library, for a whole-library entry).
|
||||
pub name: String,
|
||||
/// Library this candidate lives in, so the picker can group and disambiguate
|
||||
/// two folders that share a name.
|
||||
pub library_name: String,
|
||||
/// True when the candidate *is* a library rather than a folder inside one.
|
||||
pub is_library: bool,
|
||||
}
|
||||
|
||||
/// The library preferences currently in force.
|
||||
///
|
||||
/// Read from the in-memory exclusion set rather than the database: that set is
|
||||
/// what queries actually consult, so reading it is the only answer that cannot
|
||||
/// disagree with what the user is seeing.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_settings() -> Result<LibrarySettings, String> {
|
||||
Ok(LibrarySettings {
|
||||
excluded_item_ids: exclusions::excluded_item_ids(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the library preferences: apply them to every subsequent query and
|
||||
/// persist them.
|
||||
///
|
||||
/// Returns the sanitised value actually applied, so the picker shows what was
|
||||
/// stored rather than what it sent.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_set_settings(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
settings: LibrarySettings,
|
||||
) -> Result<LibrarySettings, String> {
|
||||
let sanitised = settings.sanitised();
|
||||
exclusions::set_excluded_item_ids(&sanitised.excluded_item_ids);
|
||||
persist_library_settings(&db, &sanitised).await;
|
||||
info!(
|
||||
"[Library] {} folder(s) hidden from browsing",
|
||||
sanitised.excluded_item_ids.len()
|
||||
);
|
||||
Ok(sanitised)
|
||||
}
|
||||
|
||||
/// The folders the user may choose to hide.
|
||||
///
|
||||
/// Offers each music library and the folders directly inside it. Music is the
|
||||
/// only scope offered because it is the one where a foreign folder — podcasts,
|
||||
/// audiobooks, sound effects — routinely shares a library with the media the
|
||||
/// user actually browses; the scope is decided here rather than in the UI so the
|
||||
/// collection-type table stays out of the frontend
|
||||
/// (see `SearchScope::for_collection_type`).
|
||||
///
|
||||
/// Reads through `HybridRepository::get_items_unfiltered` so folders that are
|
||||
/// *already* hidden still appear — otherwise the setting could never be undone.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_exclusion_candidates(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
) -> Result<Vec<ExclusionCandidate>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let libraries = repo
|
||||
.as_ref()
|
||||
.get_libraries()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
|
||||
let mut candidates: Vec<ExclusionCandidate> = Vec::new();
|
||||
|
||||
for library in libraries {
|
||||
if SearchScope::for_collection_type(&library.collection_type) != Some(SearchScope::Music) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push(ExclusionCandidate {
|
||||
id: library.id.clone(),
|
||||
name: library.name.clone(),
|
||||
library_name: library.name.clone(),
|
||||
is_library: true,
|
||||
});
|
||||
|
||||
let options = GetItemsOptions {
|
||||
recursive: Some(false),
|
||||
sort_by: Some("SortName".to_string()),
|
||||
sort_order: Some("Ascending".to_string()),
|
||||
limit: Some(CANDIDATE_SCAN_LIMIT),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match repo.get_items_unfiltered(&library.id, Some(options)).await {
|
||||
Ok(result) => {
|
||||
for item in result.items {
|
||||
if !item.is_folder {
|
||||
continue;
|
||||
}
|
||||
candidates.push(ExclusionCandidate {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
library_name: library.name.clone(),
|
||||
is_library: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// One unreachable library must not cost the user the picker for
|
||||
// the others — an empty section is recoverable, an error is not.
|
||||
warn!(
|
||||
"[Library] Could not list folders in {}: {:?}",
|
||||
library.name, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("[Library] {} exclusion candidate(s)", candidates.len());
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
/// Write the preferences to `app_settings`.
|
||||
///
|
||||
/// Failure is logged, not returned: the setting has already been applied in
|
||||
/// memory, and failing the whole call because the write failed would leave the
|
||||
/// picker showing a state that *is* in force.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
async fn persist_library_settings(db: &State<'_, DatabaseWrapper>, settings: &LibrarySettings) {
|
||||
let db_service = {
|
||||
let database = db.0.lock_safe();
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let encoded = match serde_json::to_string(settings) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("[Library] Failed to encode library settings: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
vec![
|
||||
QueryParam::String(LIBRARY_SETTINGS_KEY.to_string()),
|
||||
QueryParam::String(encoded),
|
||||
],
|
||||
);
|
||||
|
||||
if let Err(e) = db_service.execute(query).await {
|
||||
warn!("[Library] Failed to persist library settings: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the persisted preferences at startup, into the exclusion set the
|
||||
/// repository consults.
|
||||
///
|
||||
/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
|
||||
/// default — nothing hidden — in place, so a database problem shows the user
|
||||
/// more than they asked for rather than less.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub async fn restore_library_settings(app: &tauri::AppHandle) {
|
||||
let db_service = {
|
||||
let Some(db) = app.try_state::<DatabaseWrapper>() else {
|
||||
warn!("[Library] No database available; nothing hidden from browsing");
|
||||
return;
|
||||
};
|
||||
let database = db.0.lock_safe();
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String(LIBRARY_SETTINGS_KEY.to_string())],
|
||||
);
|
||||
|
||||
let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("[Library] Failed to read library settings: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(stored) = stored else { return };
|
||||
let settings: LibrarySettings = match serde_json::from_str(&stored) {
|
||||
Ok(settings) => settings,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[Library] Ignoring unreadable persisted library settings {:?}: {}",
|
||||
stored, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let settings = settings.sanitised();
|
||||
exclusions::set_excluded_item_ids(&settings.excluded_item_ids);
|
||||
if !settings.excluded_item_ids.is_empty() {
|
||||
info!(
|
||||
"[Library] Restored {} hidden folder(s)",
|
||||
settings.excluded_item_ids.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The persisted form must round-trip through the same camelCase JSON the
|
||||
/// IPC boundary uses — a rename here silently un-hides every folder the user
|
||||
/// chose, with no setting having been changed.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_library_settings_round_trip_through_json() {
|
||||
let settings = LibrarySettings {
|
||||
excluded_item_ids: vec!["folder-1".to_string(), "folder-2".to_string()],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&settings).expect("serialises");
|
||||
assert!(json.contains("\"excludedItemIds\""), "camelCase on the wire");
|
||||
|
||||
let parsed: LibrarySettings = serde_json::from_str(&json).expect("parses back");
|
||||
assert_eq!(parsed, settings);
|
||||
}
|
||||
|
||||
/// Settings persisted before this feature existed — and a row with the key
|
||||
/// missing entirely — must load as "nothing hidden", never as an error the
|
||||
/// caller has to handle or a default that hides something.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_library_settings_default_hides_nothing() {
|
||||
let parsed: LibrarySettings = serde_json::from_str("{}").expect("parses");
|
||||
assert!(parsed.excluded_item_ids.is_empty());
|
||||
assert!(LibrarySettings::default().excluded_item_ids.is_empty());
|
||||
}
|
||||
|
||||
/// Blank and duplicate ids are dropped on the way in, so a half-written or
|
||||
/// hand-edited value cannot grow the list without bound or store an id that
|
||||
/// matches nothing yet still shows as a selection.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_library_settings_sanitised() {
|
||||
let settings = LibrarySettings {
|
||||
excluded_item_ids: vec![
|
||||
" folder-1 ".to_string(),
|
||||
"".to_string(),
|
||||
" ".to_string(),
|
||||
"folder-1".to_string(),
|
||||
"folder-2".to_string(),
|
||||
],
|
||||
}
|
||||
.sanitised();
|
||||
|
||||
assert_eq!(
|
||||
settings.excluded_item_ids,
|
||||
vec!["folder-1".to_string(), "folder-2".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user