From 788bc2e01fd14c5be47385b7aa85067e1b23f3b3 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Sat, 25 Jul 2026 22:40:27 +0300 Subject: [PATCH] Added network catalog slices --- Cargo.lock | 11 +- src/app/event.rs | 5 + src/app/mod.rs | 154 +++++++++++++++++- src/app/popup.rs | 35 +++- src/app/state.rs | 1 + src/app/update.rs | 19 ++- src/config/settings.rs | 46 +++++- src/devices.rs | 16 ++ src/federation/catalog.rs | 214 +++++++++++++------------ src/federation/mod.rs | 94 +++++++++++ src/library/mod.rs | 327 ++++++++++++++++++++++++++++++++++++-- src/library/models.rs | 16 ++ src/ui/global.rs | 46 +++++- src/ui/popup.rs | 46 ++++-- 14 files changed, 878 insertions(+), 152 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9f705b..6fb8a5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1456,7 +1456,7 @@ dependencies = [ [[package]] name = "federation-net" version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git#085a4752da25a8d3fe7eac673af081a4a73c08bd" +source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" dependencies = [ "blake3", "data-encoding", @@ -2990,7 +2990,7 @@ dependencies = [ [[package]] name = "music-dht" version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git#085a4752da25a8d3fe7eac673af081a4a73c08bd" +source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" dependencies = [ "async-trait", "blake3", @@ -3140,12 +3140,13 @@ dependencies = [ [[package]] name = "netlink-proto" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" dependencies = [ "bytes", - "futures", + "futures-channel", + "futures-util", "log", "netlink-packet-core", "netlink-sys", diff --git a/src/app/event.rs b/src/app/event.rs index 9811d43..cde3809 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -107,6 +107,11 @@ pub enum AppEvent { name: String, result: Result, }, + /// A network-library source refreshed its cached top-artist slice. + NetworkArtistCacheUpdated { + source_id: String, + count: usize, + }, /// A streamed image for the open card arrived (artist image when /// `release` is None, a release cover otherwise). FedCardArt { diff --git a/src/app/mod.rs b/src/app/mod.rs index 685604e..85c52f6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -41,6 +41,12 @@ pub struct Runtime { pub federation: Arc, /// When the last Federation-tab status snapshot was requested. pub fed_status_at: Option, + pub library_network_refresh_at: Option, + pub library_network_refreshing: Arc, + pub library_network_cursors: + Arc>>>, + pub library_network_done: Arc>>, + pub library_network_mode: crate::config::settings::LibrarySourceMode, /// Coalesces urgent personal-device syncs after remote playback commands. pub device_sync_running: Arc, pub device_sync_requested: Arc, @@ -182,6 +188,11 @@ pub async fn run( devices, federation, fed_status_at: None, + library_network_refresh_at: None, + library_network_refreshing: Arc::new(std::sync::atomic::AtomicBool::new(false)), + library_network_cursors: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + library_network_done: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), + library_network_mode: crate::config::settings::LibrarySourceMode::Local, device_sync_running: Arc::new(std::sync::atomic::AtomicBool::new(false)), device_sync_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)), fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()), @@ -753,21 +764,21 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { { global.loading = true; let page = global.next_page; - let hide_featured_only = global.filters.hide_featured_only; + let filters = global.filters; let limit = *global .page_limit .get_or_insert_with(|| (needed as i64).clamp(48, 200)); let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); tokio::task::spawn_blocking(move || { - let result = library - .artists(page, limit, hide_featured_only) - .map_err(err_string); + let result = library.artists(page, limit, filters).map_err(err_string); let _ = tx.send(AppEvent::ArtistsLoaded(result)); }); } } + maybe_refresh_network_library(state, runtime); + // Liked ids load once per session — markers are shown everywhere. if !state.likes_loaded { state.likes_loaded = true; @@ -904,6 +915,117 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { } } +fn maybe_refresh_network_library(state: &AppState, runtime: &mut Runtime) { + if state.active_tab != state::Tab::Global + || !state.global.stack.is_empty() + || !state.global.filters.source_mode.includes_network() + || !state.federation.settings.enabled + { + return; + } + let mode = state.global.filters.source_mode; + if runtime.library_network_mode != mode { + runtime.library_network_mode = mode; + runtime.library_network_refresh_at = None; + if let Ok(mut cursors) = runtime.library_network_cursors.lock() { + cursors.clear(); + } + if let Ok(mut done) = runtime.library_network_done.lock() { + done.clear(); + } + } + let near_end = state + .global + .artists + .len() + .saturating_sub(state.global.selected) + <= ARTISTS_PREFETCH_MARGIN + || state.global.artists.len() < artist_grid_capacity(); + let refresh_interval = if near_end { + Duration::from_secs(2) + } else { + Duration::from_secs(60) + }; + let due = runtime + .library_network_refresh_at + .is_none_or(|at| at.elapsed() > refresh_interval); + if !due { + return; + } + if !near_end { + if let Ok(mut cursors) = runtime.library_network_cursors.lock() { + cursors.clear(); + } + if let Ok(mut done) = runtime.library_network_done.lock() { + done.clear(); + } + } + use std::sync::atomic::Ordering; + if runtime + .library_network_refreshing + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + runtime.library_network_refresh_at = Some(std::time::Instant::now()); + let federation = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + let running = Arc::clone(&runtime.library_network_refreshing); + let cursors = Arc::clone(&runtime.library_network_cursors); + let done = Arc::clone(&runtime.library_network_done); + let limit = if near_end { 64 } else { 96 }; + tokio::spawn(async move { + let result = async { + let sources = federation.network_library_sources(mode).await?; + for source in sources { + let source_id = source.endpoint_id.clone(); + if done + .lock() + .map(|done| done.contains(&source_id)) + .unwrap_or(false) + { + continue; + } + let cursor = cursors + .lock() + .ok() + .and_then(|cursors| cursors.get(&source_id).cloned()) + .flatten(); + match tokio::time::timeout( + Duration::from_secs(4), + federation.cache_artist_slice_from_source(source, cursor.clone(), limit), + ) + .await + { + Ok(Ok((count, next_cursor))) => { + if let Some(next_cursor) = next_cursor { + if let Ok(mut cursors) = cursors.lock() { + cursors.insert(source_id.clone(), Some(next_cursor)); + } + } else if let Ok(mut done) = done.lock() { + done.insert(source_id.clone()); + } + let _ = tx.send(AppEvent::NetworkArtistCacheUpdated { source_id, count }); + } + Ok(Err(err)) => { + tracing::debug!(source = %source_id, "network library source failed: {err:#}"); + } + Err(_) => { + tracing::debug!(source = %source_id, "network library source timed out"); + } + } + } + Ok::<(), anyhow::Error>(()) + } + .await; + if let Err(err) = result { + tracing::debug!("network library refresh skipped: {err:#}"); + } + running.store(false, Ordering::SeqCst); + }); +} + /// Load and decode a local image file for the art cache. fn spawn_art_fetch(runtime: &Runtime, key: String, path: String, width: u16, height: u16) { let tx = runtime.event_tx.clone(); @@ -2208,12 +2330,12 @@ fn refresh_artists(state: &mut AppState, runtime: &Runtime) { let global = &mut state.global; let needed = artist_grid_capacity() + ARTISTS_PREFETCH_MARGIN; let limit = (global.artists.len().max(needed) as i64).clamp(48, 1000); - let hide_featured_only = global.filters.hide_featured_only; + let filters = global.filters; global.reloading = true; let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); tokio::task::spawn_blocking(move || { - let event = match library.artists(1, limit, hide_featured_only) { + let event = match library.artists(1, limit, filters) { Ok(page) => AppEvent::ArtistsReloaded { page, limit }, Err(err) => AppEvent::ArtistsLoaded(Err(err_string(err))), }; @@ -2727,6 +2849,12 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent }; } } + AppEvent::NetworkArtistCacheUpdated { source_id, count } => { + tracing::debug!(source = %source_id, count, "network artist cache updated"); + if state.active_tab == state::Tab::Global && state.global.stack.is_empty() { + refresh_artists(state, runtime); + } + } AppEvent::FedCardArt { name, release, @@ -2779,6 +2907,10 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } AppEvent::ArtistsReloaded { page, limit } => { let global = &mut state.global; + let selected_key = global + .artists + .get(global.selected) + .map(|artist| music_dht::normalize_name(&artist.name)); global.reloading = false; global.loading = false; global.error = None; @@ -2788,7 +2920,15 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent global.page_limit = Some(limit); global.artists = page.items; if !global.artists.is_empty() { - global.selected = global.selected.min(global.artists.len() - 1); + global.selected = selected_key + .as_deref() + .and_then(|key| { + global + .artists + .iter() + .position(|artist| music_dht::normalize_name(&artist.name) == key) + }) + .unwrap_or_else(|| global.selected.min(global.artists.len() - 1)); } else { global.selected = 0; } diff --git a/src/app/popup.rs b/src/app/popup.rs index 7c09c9d..08c214b 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -226,14 +226,41 @@ fn handle_connected_devices( } } -fn handle_library_filters(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) { +fn handle_library_filters( + state: &mut AppState, + runtime: &mut Runtime, + cursor: usize, + key: KeyEvent, +) { + let max_cursor = crate::config::settings::LibrarySourceMode::ALL.len(); + let cursor = cursor.min(max_cursor); match key.code { KeyCode::Esc | KeyCode::Char('q') => {} - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.popup = Some(Popup::LibraryFilters { cursor: 0 }); + KeyCode::Up | KeyCode::Char('k') => { + state.popup = Some(Popup::LibraryFilters { + cursor: cursor.saturating_sub(1), + }); + } + KeyCode::Down | KeyCode::Char('j') => { + state.popup = Some(Popup::LibraryFilters { + cursor: (cursor + 1).min(max_cursor), + }); } KeyCode::Enter | KeyCode::Char(' ') => { - state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only; + if cursor == 0 { + state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only; + } else if let Some(mode) = + crate::config::settings::LibrarySourceMode::ALL.get(cursor - 1) + { + state.global.filters.source_mode = *mode; + } + runtime.library_network_refresh_at = None; + if let Ok(mut cursors) = runtime.library_network_cursors.lock() { + cursors.clear(); + } + if let Ok(mut done) = runtime.library_network_done.lock() { + done.clear(); + } super::save_app_settings(state); super::reset_artist_pagination(state); super::refresh_artists(state, runtime); diff --git a/src/app/state.rs b/src/app/state.rs index dcb67ac..cf1fa8b 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -253,6 +253,7 @@ mod tests { year, cover_path: None, track_count: 1, + availability: crate::library::models::Availability::Local, } } diff --git a/src/app/update.rs b/src/app/update.rs index e89bd5d..7df3787 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -498,6 +498,10 @@ fn open_edit_popup(state: &mut AppState) { let Some(artist) = state.global.artists.get(state.global.selected).cloned() else { return; }; + if artist.id < 0 { + state.status_message = Some("remote artists cannot be edited here".into()); + return; + } state.popup = Some(artist_edit_popup( artist.id, &artist.name, @@ -664,6 +668,10 @@ fn delete_selected(state: &mut AppState) -> Option { } if state.global.stack.is_empty() { let artist = state.global.artists.get(state.global.selected).cloned()?; + if artist.id < 0 { + state.status_message = Some("remote artists cannot be deleted here".into()); + return None; + } state.popup = Some(Popup::ConfirmDelete { target: DeleteTarget::Artist(artist.id), label: format!( @@ -1942,10 +1950,17 @@ fn select_current(state: &mut AppState) -> Option { } let outcome = match state.global.stack.last().copied() { None => match state.global.artists.get(state.global.selected) { - Some(artist) => Outcome::Push(GlobalView::Artist { + Some(artist) + if state.global.filters.source_mode.includes_network() + && artist.availability.is_remoteish() => + { + Outcome::OpenFedArtist(artist.name.clone()) + } + Some(artist) if artist.id >= 0 => Outcome::Push(GlobalView::Artist { id: artist.id, cursor: 0, }), + Some(artist) => Outcome::OpenFedArtist(artist.name.clone()), None => Outcome::Nothing, }, Some(GlobalView::Artist { id, cursor: _ }) if state.artist_fed_button => { @@ -2689,6 +2704,7 @@ mod tests { image_path: None, release_count: 1, track_count: 2, + availability: crate::library::models::Availability::Local, }) .collect(); state @@ -2854,6 +2870,7 @@ mod tests { year: None, cover_path: None, track_count: 1, + availability: crate::library::models::Availability::Local, }; let columns = grid_columns(); // The terminal size can be visible to tests. Build enough albums to diff --git a/src/config/settings.rs b/src/config/settings.rs index d07281b..3eb5ebc 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,15 +1,58 @@ use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LibrarySourceMode { + #[default] + Local, + My, + Global, +} + +impl LibrarySourceMode { + pub const ALL: [LibrarySourceMode; 3] = [ + LibrarySourceMode::Local, + LibrarySourceMode::My, + LibrarySourceMode::Global, + ]; + + pub fn label(self) -> &'static str { + match self { + LibrarySourceMode::Local => "Local", + LibrarySourceMode::My => "My", + LibrarySourceMode::Global => "Global", + } + } + + pub fn description(self) -> &'static str { + match self { + LibrarySourceMode::Local => "only this device", + LibrarySourceMode::My => "this device + connected devices", + LibrarySourceMode::Global => "my devices + known federation peers", + } + } + + pub fn includes_network(self) -> bool { + !matches!(self, LibrarySourceMode::Local) + } + + pub fn includes_global_peers(self) -> bool { + matches!(self, LibrarySourceMode::Global) + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct LibraryFilters { #[serde(default)] pub hide_featured_only: bool, + #[serde(default)] + pub source_mode: LibrarySourceMode, } impl LibraryFilters { pub fn is_active(&self) -> bool { - self.hide_featured_only + self.hide_featured_only || self.source_mode != LibrarySourceMode::Local } } @@ -98,5 +141,6 @@ hide_featured_only = true assert_eq!(settings.volume, 100); assert!(settings.library.hide_featured_only); assert!(settings.library.is_active()); + assert_eq!(settings.library.source_mode, LibrarySourceMode::Local); } } diff --git a/src/devices.rs b/src/devices.rs index 29967a5..10a495f 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -2239,6 +2239,22 @@ impl DeviceSync { Ok(rows.collect::>>()?) } + pub fn active_remote_endpoint_ids(&self) -> Result> { + let own = self.ensure_identity()?.device_id; + let conn = lock(&self.conn); + let mut stmt = conn.prepare( + "SELECT endpoint_id + FROM sync_devices + WHERE trusted_at_ms IS NOT NULL + AND revoked_at_ms IS NULL + AND device_id != ?1 + AND endpoint_id != '' + ORDER BY last_seen_ms DESC", + )?; + let rows = stmt.query_map([own], |row| row.get::<_, String>(0))?; + Ok(rows.collect::>>()?) + } + fn active_device_count(&self) -> Result { let conn = lock(&self.conn); Ok(conn.query_row( diff --git a/src/federation/catalog.rs b/src/federation/catalog.rs index 9d5c834..0f0bc5a 100644 --- a/src/federation/catalog.rs +++ b/src/federation/catalog.rs @@ -11,114 +11,22 @@ use std::collections::HashMap; use std::sync::Arc; use anyhow::{Context, Result}; +pub use music_dht::catalog::{ + CATALOG_ALPN, CatalogAppearance, CatalogArtist, CatalogArtistPreview, CatalogRelease, + CatalogTrack, +}; +use music_dht::catalog::{CatalogImageHeader as ImageHeader, CatalogRequest, CatalogResponse}; use music_dht::{ByteStream, EndpointId, ItemKind, MusicDhtService, StreamAcceptor}; -use serde::{Deserialize, Serialize}; use tokio::io::AsyncReadExt; use crate::library::Library; -/// ALPN of the catalog protocol. -pub const CATALOG_ALPN: &[u8] = b"furumi-fd/catalog/1"; - /// Upper bound for one catalog response (thousands of tracks fit easily). const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024; -#[derive(Debug, Serialize, Deserialize)] -struct CatalogRequest { - /// Artist display name; matched case-insensitively by the owner. - artist: String, - /// What is being asked for: `None`/"catalog" — the JSON catalog; - /// "artist_image" — the artist's image; "release_cover" — the cover of - /// `release`. Image responses are a JSON header line + raw bytes. - #[serde(default)] - want: Option, - #[serde(default)] - release: Option, -} - -/// Header line preceding raw image bytes (artist image / release cover). -#[derive(Debug, Default, Serialize, Deserialize)] -struct ImageHeader { - ok: bool, - #[serde(default)] - error: Option, - #[serde(default)] - mime_type: String, - #[serde(default)] - size: u64, -} - /// Images above this size are skipped rather than transferred. const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024; -#[derive(Debug, Default, Serialize, Deserialize)] -struct CatalogResponse { - ok: bool, - #[serde(default)] - error: Option, - #[serde(default)] - artist: Option, -} - -/// One peer's library slice for an artist. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CatalogArtist { - #[serde(default)] - pub name: String, - #[serde(default)] - pub releases: Vec, - /// Tracks where the requested artist is featured instead of being a - /// release/main artist. - #[serde(default)] - pub appears_on: Vec, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CatalogRelease { - #[serde(default)] - pub title: String, - #[serde(default)] - pub release_type: String, - #[serde(default)] - pub year: Option, - #[serde(default)] - pub tracks: Vec, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CatalogAppearance { - #[serde(default)] - pub release_title: String, - #[serde(default)] - pub release_type: String, - #[serde(default)] - pub year: Option, - #[serde(default)] - pub track: CatalogTrack, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CatalogTrack { - #[serde(default)] - pub title: String, - #[serde(default)] - pub artists: Vec, - #[serde(default)] - pub featured_artists: Vec, - #[serde(default)] - pub track_number: Option, - #[serde(default)] - pub disc_number: Option, - #[serde(default)] - pub duration_seconds: Option, - /// Stable audio content id (`b3:<64 hex>`) when known. - #[serde(default)] - pub content_id: Option, - /// Hex DHT item id — the key the audio is requested by (FedPlay). - #[serde(default)] - pub item_id: String, -} - // --------------------------------------------------------------------------- // Serving side // --------------------------------------------------------------------------- @@ -165,14 +73,28 @@ async fn serve_one( ); match request.want.as_deref() { - None | Some("catalog") => { + Some("artists") => { + let cursor = request.cursor.clone(); + let limit = request.limit.unwrap_or(64).clamp(1, 200); + let response = + tokio::task::spawn_blocking(move || build_artist_slice(&library, cursor, limit)) + .await? + .unwrap_or_else(|err| CatalogResponse { + ok: false, + error: Some(format!("artist slice failed: {err:#}")), + ..CatalogResponse::default() + }); + let payload = serde_json::to_vec(&response)?; + stream.send.write_all(&payload).await?; + } + None | Some("catalog") | Some("artist") => { let response = tokio::task::spawn_blocking(move || build_catalog(&library, own, &request.artist)) .await? .unwrap_or_else(|err| CatalogResponse { ok: false, error: Some(format!("catalog lookup failed: {err:#}")), - artist: None, + ..CatalogResponse::default() }); let payload = serde_json::to_vec(&response)?; stream.send.write_all(&payload).await?; @@ -198,7 +120,7 @@ async fn serve_one( let response = CatalogResponse { ok: false, error: Some(format!("unknown request kind '{other}'")), - artist: None, + ..CatalogResponse::default() }; stream .send @@ -273,13 +195,41 @@ fn build_catalog(library: &Library, own: EndpointId, artist: &str) -> Result, + limit: usize, +) -> Result { + let offset = cursor + .as_deref() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let (artists, next_cursor) = library.artist_preview_slice(offset, limit)?; + Ok(CatalogResponse { + ok: true, + artists: artists + .into_iter() + .map(|artist| CatalogArtistPreview { + artist_key: artist.artist_key, + name: artist.name, + image_path: artist.image_path, + release_count: artist.release_count, + track_count: artist.track_count, + }) + .collect(), + next_cursor, + ..CatalogResponse::default() }) } @@ -377,6 +327,8 @@ pub async fn fetch_catalog( artist: artist.to_string(), want: None, release: None, + cursor: None, + limit: None, })?; line.push(b'\n'); stream.send.write_all(&line).await?; @@ -411,6 +363,64 @@ pub async fn fetch_catalog( response.artist.context("empty catalog response") } +/// Fetches a thin top-artist slice from one peer. +pub async fn fetch_artist_slice( + service: &MusicDhtService, + owner: EndpointId, + cursor: Option<&str>, + limit: usize, + transport_stats: &Arc, +) -> Result<(Vec, Option)> { + let mut stream = service + .open_stream(owner, CATALOG_ALPN) + .await + .map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?; + crate::federation::record_stream_transport( + transport_stats, + "catalog", + "outbound", + "open", + &stream, + ); + let mut line = serde_json::to_vec(&CatalogRequest { + artist: String::new(), + want: Some("artists".to_string()), + release: None, + cursor: cursor.map(str::to_string), + limit: Some(limit), + })?; + line.push(b'\n'); + stream.send.write_all(&line).await?; + stream.send.finish()?; + + let mut payload = Vec::new(); + tokio::io::AsyncReadExt::take(StreamReader(&mut stream), MAX_CATALOG_BYTES + 1) + .read_to_end(&mut payload) + .await?; + anyhow::ensure!( + payload.len() as u64 <= MAX_CATALOG_BYTES, + "catalog response exceeds {MAX_CATALOG_BYTES} bytes" + ); + let response: CatalogResponse = + serde_json::from_slice(&payload).context("malformed catalog response")?; + crate::federation::record_stream_transport( + transport_stats, + "catalog", + "outbound", + "done", + &stream, + ); + if !response.ok { + anyhow::bail!( + "peer refused the artist slice: {}", + response + .error + .unwrap_or_else(|| "unknown error".to_string()) + ); + } + Ok((response.artists, response.next_cursor)) +} + /// Fetches an image (artist image or a release cover) from a peer over the /// catalog protocol. `release: None` asks for the artist image. Returns the /// raw bytes and a file extension, or None when the peer has no image. @@ -440,6 +450,8 @@ pub async fn fetch_image( "artist_image".to_string() }), release: release.map(str::to_string), + cursor: None, + limit: None, })?; line.push(b'\n'); stream.send.write_all(&line).await?; diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 3a3f993..0f31757 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -32,6 +32,7 @@ use rusqlite::{Connection, OpenFlags, params}; use serde::{Deserialize, Serialize}; use crate::library::Library; +use crate::library::NetworkArtistPreview; use crate::library::models::{ArtistRef, TrackItem}; pub use audio::{AUDIO_ALPN, DownloadProgress, StreamingStart, TrackMetadata}; @@ -395,6 +396,12 @@ pub struct FedPlayable { pub imported: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct NetworkLibrarySource { + pub endpoint_id: String, + pub kind: &'static str, +} + // --------------------------------------------------------------------------- // The federation manager (lives in Runtime, not AppState) // --------------------------------------------------------------------------- @@ -862,6 +869,93 @@ impl Federation { status } + pub async fn network_library_sources( + &self, + mode: crate::config::settings::LibrarySourceMode, + ) -> Result> { + if !mode.includes_network() { + return Ok(Vec::new()); + } + let service = self.service().await?; + let own = service.endpoint_id().to_string(); + let mut seen = std::collections::HashSet::new(); + seen.insert(own); + let mut sources = Vec::new(); + for endpoint_id in self.devices.active_remote_endpoint_ids()? { + if seen.insert(endpoint_id.clone()) { + sources.push(NetworkLibrarySource { + endpoint_id, + kind: "personal", + }); + } + } + if mode.includes_global_peers() { + for endpoint in service + .connected_peers() + .into_iter() + .map(|peer| peer.to_string()) + .chain( + service + .known_peers() + .into_iter() + .map(|peer| peer.peer_id.to_string()), + ) + { + if seen.insert(endpoint.clone()) { + sources.push(NetworkLibrarySource { + endpoint_id: endpoint, + kind: "federation", + }); + } + if sources.len() >= 32 { + break; + } + } + } + Ok(sources) + } + + pub async fn cache_artist_slice_from_source( + &self, + source: NetworkLibrarySource, + cursor: Option, + limit: usize, + ) -> Result<(usize, Option)> { + let service = self.service().await?; + let owner = EndpointId::from_str(&source.endpoint_id) + .map_err(|_| anyhow::anyhow!("malformed endpoint id '{}'", source.endpoint_id))?; + let replace_source = cursor.is_none(); + let (artists, next_cursor) = catalog::fetch_artist_slice( + &service, + owner, + cursor.as_deref(), + limit, + &self.transport_stats, + ) + .await?; + let artists: Vec = artists + .into_iter() + .map(|artist| NetworkArtistPreview { + artist_key: if artist.artist_key.trim().is_empty() { + music_dht::normalize_name(&artist.name) + } else { + artist.artist_key + }, + name: artist.name, + image_path: None, + release_count: artist.release_count, + track_count: artist.track_count, + }) + .collect(); + let count = self.library.replace_network_artist_cache( + &source.endpoint_id, + source.kind, + &artists, + replace_source, + )?; + Ok((count, next_cursor)) + } + /// Searches the federated network: matching tracks plus the artists a /// card can be assembled for (from artist records and from the artist /// names of matching tracks/releases). diff --git a/src/library/mod.rs b/src/library/mod.rs index 231653e..a4b10ac 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -18,12 +18,13 @@ use anyhow::{Context as _, Result}; use rusqlite::{Connection, OptionalExtension as _, params}; use models::{ - ArtistCard, ArtistDetail, ArtistRef, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseCard, - ReleaseDetail, ReleaseEdit, SearchResults, TrackEdit, TrackItem, + ArtistCard, ArtistDetail, ArtistRef, ArtistsPage, Availability, PlaylistCard, PlaylistDetail, + ReleaseCard, ReleaseDetail, ReleaseEdit, SearchResults, TrackEdit, TrackItem, }; /// The virtual "Liked tracks" playlist id, kept from the server API. pub const LIKES_PLAYLIST_ID: i64 = -1; +const NETWORK_ARTIST_CACHE_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000; const SCHEMA: &str = " CREATE TABLE IF NOT EXISTS artists ( @@ -132,6 +133,21 @@ CREATE INDEX IF NOT EXISTS idx_history_track ON history(track_id); CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id); CREATE INDEX IF NOT EXISTS idx_fed_playlist_tracks_playlist ON fed_playlist_tracks(playlist_sync_id, position); +CREATE TABLE IF NOT EXISTS network_artist_cache ( + source_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + artist_key TEXT NOT NULL, + name TEXT NOT NULL, + image_path TEXT, + release_count INTEGER NOT NULL DEFAULT 0, + track_count INTEGER NOT NULL DEFAULT 0, + seen_at_ms INTEGER NOT NULL, + PRIMARY KEY (source_id, artist_key) +); +CREATE INDEX IF NOT EXISTS idx_network_artist_cache_kind + ON network_artist_cache(source_kind, seen_at_ms); +CREATE INDEX IF NOT EXISTS idx_network_artist_cache_artist + ON network_artist_cache(artist_key); "; /// The SELECT column list every TrackItem row is built from; artist lists @@ -192,6 +208,15 @@ pub struct ExportTrack { pub content_id: Option, } +#[derive(Debug, Clone)] +pub struct NetworkArtistPreview { + pub artist_key: String, + pub name: String, + pub image_path: Option, + pub release_count: i64, + pub track_count: i64, +} + pub struct Library { conn: Mutex, /// Directory where extracted embedded covers are stored. @@ -314,7 +339,24 @@ impl Library { // Reads (same shapes the API used to return) // ----------------------------------------------------------------- - pub fn artists(&self, page: i64, limit: i64, hide_featured_only: bool) -> Result { + pub fn artists( + &self, + page: i64, + limit: i64, + filters: crate::config::settings::LibraryFilters, + ) -> Result { + if !filters.source_mode.includes_network() { + return self.local_artists(page, limit, filters.hide_featured_only); + } + self.merged_artists(page, limit, filters) + } + + fn local_artists( + &self, + page: i64, + limit: i64, + hide_featured_only: bool, + ) -> Result { let conn = self.lock(); let hide_featured_only = i64::from(hide_featured_only); let total: i64 = conn.query_row( @@ -356,6 +398,7 @@ impl Library { image_path: row.get(2)?, release_count: row.get(3)?, track_count: row.get(4)?, + availability: Availability::Local, }) })? .collect::>>()?; @@ -368,6 +411,247 @@ impl Library { }) } + fn local_artist_cards(&self, hide_featured_only: bool) -> Result> { + let conn = self.lock(); + let hide_featured_only = i64::from(hide_featured_only); + let mut statement = conn.prepare( + "SELECT a.id, a.name, a.image_path, + (SELECT COUNT(DISTINCT ra.release_id) + FROM release_artists ra + WHERE ra.artist_id = a.id) AS release_count, + (SELECT COUNT(DISTINCT ta.track_id) + FROM track_artists ta + WHERE ta.artist_id = a.id) AS track_count + FROM artists a + WHERE ?1 = 0 + OR EXISTS ( + SELECT 1 + FROM release_artists ra + WHERE ra.artist_id = a.id + )", + )?; + statement + .query_map(params![hide_featured_only], |row| { + Ok(ArtistCard { + id: row.get(0)?, + name: row.get(1)?, + image_path: row.get(2)?, + release_count: row.get(3)?, + track_count: row.get(4)?, + availability: Availability::Local, + }) + })? + .collect::>>() + .map_err(Into::into) + } + + fn merged_artists( + &self, + page: i64, + limit: i64, + filters: crate::config::settings::LibraryFilters, + ) -> Result { + let mut by_key: HashMap = HashMap::new(); + for artist in self.local_artist_cards(filters.hide_featured_only)? { + let key = music_dht::normalize_name(&artist.name); + if !key.is_empty() { + by_key.insert(key, artist); + } + } + + let conn = self.lock(); + let cutoff = now_ms_i64().saturating_sub(NETWORK_ARTIST_CACHE_TTL_MS); + let source_predicate = if filters.source_mode.includes_global_peers() { + "seen_at_ms >= ?1" + } else { + "seen_at_ms >= ?1 AND source_kind = 'personal'" + }; + let release_predicate = if filters.hide_featured_only { + " AND release_count > 0" + } else { + "" + }; + let sql = format!( + "SELECT artist_key, + COALESCE(NULLIF(MIN(name), ''), artist_key) AS name, + MAX(image_path) AS image_path, + MAX(release_count) AS release_count, + MAX(track_count) AS track_count + FROM network_artist_cache + WHERE {source_predicate}{release_predicate} + GROUP BY artist_key" + ); + let mut statement = conn.prepare(&sql)?; + let rows = statement.query_map([cutoff], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + })?; + for row in rows { + let (artist_key, name, image_path, release_count, track_count) = row?; + if artist_key.trim().is_empty() || track_count <= 0 { + continue; + } + match by_key.get_mut(&artist_key) { + Some(local) => { + if release_count > local.release_count || track_count > local.track_count { + local.availability = Availability::Mixed; + } + local.release_count = local.release_count.max(release_count); + local.track_count = local.track_count.max(track_count); + if local.image_path.is_none() { + local.image_path = image_path; + } + } + None => { + by_key.insert( + artist_key.clone(), + ArtistCard { + id: remote_artist_id(&artist_key), + name, + image_path, + release_count, + track_count, + availability: Availability::Remote, + }, + ); + } + } + } + + let mut items: Vec = by_key.into_values().collect(); + items.sort_by(|left, right| { + right + .release_count + .cmp(&left.release_count) + .then_with(|| right.track_count.cmp(&left.track_count)) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + }); + let total = items.len() as i64; + let offset = (page.max(1) - 1) * limit; + let page_items = items + .into_iter() + .skip(offset.max(0) as usize) + .take(limit.max(0) as usize) + .collect::>(); + let has_more = offset + (page_items.len() as i64) < total; + Ok(ArtistsPage { + items: page_items, + total, + page: page.max(1), + has_more, + }) + } + + pub fn artist_preview_slice( + &self, + offset: usize, + limit: usize, + ) -> Result<(Vec, Option)> { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT a.name, a.image_path, + (SELECT COUNT(DISTINCT ra.release_id) + FROM release_artists ra + WHERE ra.artist_id = a.id) AS release_count, + (SELECT COUNT(DISTINCT ta.track_id) + FROM track_artists ta + WHERE ta.artist_id = a.id) AS track_count + FROM artists a + WHERE EXISTS ( + SELECT 1 FROM track_artists ta WHERE ta.artist_id = a.id + ) + ORDER BY release_count DESC, track_count DESC, a.name COLLATE NOCASE + LIMIT ?1 OFFSET ?2", + )?; + let items = statement + .query_map(params![limit as i64 + 1, offset as i64], |row| { + let name: String = row.get(0)?; + Ok(NetworkArtistPreview { + artist_key: music_dht::normalize_name(&name), + name, + image_path: row.get(1)?, + release_count: row.get(2)?, + track_count: row.get(3)?, + }) + })? + .collect::>>()?; + let has_more = items.len() > limit; + let mut items: Vec<_> = items + .into_iter() + .take(limit) + .filter(|artist| !artist.artist_key.is_empty() && artist.track_count > 0) + .collect(); + let next = has_more.then(|| (offset + items.len()).to_string()); + Ok((std::mem::take(&mut items), next)) + } + + pub fn replace_network_artist_cache( + &self, + source_id: &str, + source_kind: &str, + artists: &[NetworkArtistPreview], + replace_source: bool, + ) -> Result { + let source_id = source_id.trim(); + let source_kind = source_kind.trim(); + if source_id.is_empty() || source_kind.is_empty() { + return Ok(0); + } + let now = now_ms_i64(); + let mut conn = self.lock(); + let tx = conn.transaction()?; + if replace_source { + tx.execute( + "DELETE FROM network_artist_cache WHERE source_id = ?1", + [source_id], + )?; + } + let mut inserted = 0usize; + { + let mut stmt = tx.prepare( + "INSERT INTO network_artist_cache + (source_id, source_kind, artist_key, name, image_path, + release_count, track_count, seen_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(source_id, artist_key) DO UPDATE SET + source_kind = excluded.source_kind, + name = excluded.name, + image_path = excluded.image_path, + release_count = excluded.release_count, + track_count = excluded.track_count, + seen_at_ms = excluded.seen_at_ms", + )?; + for artist in artists { + let artist_key = if artist.artist_key.trim().is_empty() { + music_dht::normalize_name(&artist.name) + } else { + artist.artist_key.clone() + }; + if artist_key.is_empty() || artist.track_count <= 0 { + continue; + } + stmt.execute(params![ + source_id, + source_kind, + artist_key, + artist.name.trim(), + artist.image_path.as_deref(), + artist.release_count.max(0), + artist.track_count.max(0), + now, + ])?; + inserted += 1; + } + } + tx.commit()?; + Ok(inserted) + } + pub fn artist(&self, id: i64) -> Result { let conn = self.lock(); let (name, image_path): (String, Option) = conn @@ -519,6 +803,7 @@ impl Library { image_path: row.get(2)?, release_count: row.get(3)?, track_count: row.get(4)?, + availability: Availability::Local, }) })? .collect::>>()?; @@ -1994,6 +2279,7 @@ fn release_card_from_row(row: &rusqlite::Row) -> rusqlite::Result { year: row.get(3)?, cover_path: row.get(4)?, track_count: row.get(5)?, + availability: Availability::Local, }) } @@ -2183,6 +2469,20 @@ pub(crate) fn audio_content_id(path: &str) -> Option { Some(format!("b3:{}", hasher.finalize().to_hex())) } +fn now_ms_i64() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + +fn remote_artist_id(artist_key: &str) -> i64 { + let hash = blake3::hash(artist_key.as_bytes()); + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&hash.as_bytes()[..8]); + -(i64::from_be_bytes(bytes) & i64::MAX).max(1) +} + fn ensure_schema_migrations(conn: &Connection) -> Result<()> { let fed_like_columns = table_columns(conn, "fed_likes")?; if !fed_like_columns @@ -2309,6 +2609,13 @@ mod tests { id } + fn artist_filters(hide_featured_only: bool) -> crate::config::settings::LibraryFilters { + crate::config::settings::LibraryFilters { + hide_featured_only, + ..Default::default() + } + } + #[test] fn artists_page_prioritizes_releases_then_tracks() { let lib = test_library(); @@ -2316,7 +2623,7 @@ mod tests { add_track_with_featured(&lib, "Guest One", "A Host", &["Guest"], "A Host Album"); add_track_with_featured(&lib, "Guest Two", "B Host", &["Guest"], "B Host Album"); - let page = lib.artists(1, 10, false).unwrap(); + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); let zed_pos = page .items .iter() @@ -2333,7 +2640,7 @@ mod tests { assert_eq!(guest.track_count, 2); assert!(zed_pos < guest_pos); - let filtered = lib.artists(1, 10, true).unwrap(); + let filtered = lib.artists(1, 10, artist_filters(true)).unwrap(); assert!(filtered.items.iter().all(|artist| artist.release_count > 0)); assert!(!filtered.items.iter().any(|artist| artist.name == "Guest")); } @@ -2342,7 +2649,7 @@ mod tests { fn import_creates_artist_release_track() { let lib = test_library(); let track_id = add_track(&lib, "Song", "Artist", "Album"); - let page = lib.artists(1, 10, false).unwrap(); + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); assert_eq!(page.total, 1); assert_eq!(page.items[0].name, "Artist"); assert_eq!(page.items[0].track_count, 1); @@ -2363,7 +2670,7 @@ mod tests { let first = add_track(&lib, "Song", "Artist", "Album"); let second = add_track(&lib, "Song", "Artist", "Album"); assert_eq!(first, second); - let page = lib.artists(1, 10, false).unwrap(); + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); assert_eq!(page.items[0].track_count, 1); } @@ -2675,9 +2982,9 @@ mod tests { fn deleting_artist_cleans_up_own_content() { let lib = test_library(); add_track(&lib, "Song", "Solo", "Solo Album"); - let page = lib.artists(1, 10, false).unwrap(); + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); lib.delete_artist(page.items[0].id).unwrap(); - assert_eq!(lib.artists(1, 10, false).unwrap().total, 0); + assert_eq!(lib.artists(1, 10, artist_filters(false)).unwrap().total, 0); assert_eq!(lib.search("Song", 10).unwrap().len(), 0); } @@ -2687,7 +2994,7 @@ mod tests { let track_id = add_track(&lib, "Only", "Artist", "Album"); lib.delete_track(track_id).unwrap(); let detail = lib - .artist(lib.artists(1, 10, false).unwrap().items[0].id) + .artist(lib.artists(1, 10, artist_filters(false)).unwrap().items[0].id) .unwrap(); assert!(detail.releases.is_empty()); } diff --git a/src/library/models.rs b/src/library/models.rs index 1b69914..7633030 100644 --- a/src/library/models.rs +++ b/src/library/models.rs @@ -1,6 +1,20 @@ //! Data shapes the views render. They mirror what the furumusic API used to //! return, but every field is now filled from the local SQLite library. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Availability { + #[default] + Local, + Mixed, + Remote, +} + +impl Availability { + pub fn is_remoteish(self) -> bool { + matches!(self, Availability::Mixed | Availability::Remote) + } +} + #[derive(Debug, Clone)] pub struct ArtistCard { pub id: i64, @@ -9,6 +23,7 @@ pub struct ArtistCard { pub image_path: Option, pub release_count: i64, pub track_count: i64, + pub availability: Availability, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -107,6 +122,7 @@ pub struct ReleaseCard { pub year: Option, pub cover_path: Option, pub track_count: i64, + pub availability: Availability, } #[derive(Debug)] diff --git a/src/ui/global.rs b/src/ui/global.rs index c405ccc..70974ae 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -12,7 +12,7 @@ use crate::app::state::{ fed_release_groups, release_groups, }; use crate::art::cache_key; -use crate::library::models::{ArtistCard, ReleaseCard, SearchResults}; +use crate::library::models::{ArtistCard, Availability, ReleaseCard, SearchResults}; const TILE_MARQUEE_STEP_MS: u128 = 250; const TILE_MARQUEE_PAUSE_STEPS: u128 = 4; @@ -83,13 +83,14 @@ fn draw_art(frame: &mut Frame, area: Rect, art_state: Option<&ArtState>) { /// Bordered tile with artwork, a title line and a dim meta line. The /// selected tile gets a thick accent border and an inverted (filled) /// caption so it stands out in a large grid; the artwork stays untouched. -fn draw_tile( +fn draw_tile_with_availability( frame: &mut Frame, tile: Rect, art_state: Option<&ArtState>, title: &str, meta: &str, selected: bool, + availability: Option, ) { let block = if selected { Block::bordered() @@ -106,6 +107,9 @@ fn draw_tile( ..inner }; draw_art(frame, art_area, art_state); + if let Some(availability) = availability { + draw_availability_badge(frame, art_area, availability); + } if inner.height > ART_CELL_HEIGHT { let name_area = Rect { @@ -137,6 +141,27 @@ fn draw_tile( } } +fn draw_availability_badge(frame: &mut Frame, area: Rect, availability: Availability) { + if area.width < 2 || area.height == 0 { + return; + } + let (label, style) = match availability { + Availability::Local => ("●", Style::new().fg(Color::Green)), + Availability::Mixed => ("◐", Style::new().fg(Color::Yellow)), + Availability::Remote => ("⇅", theme::accent()), + }; + let badge = Rect { + x: area.x + area.width.saturating_sub(2), + y: area.y, + width: 2, + height: 1, + }; + frame.render_widget( + Paragraph::new(Line::styled(label, style)).alignment(Alignment::Right), + badge, + ); +} + fn tile_title(title: &str, width: u16, selected: bool) -> String { let width = usize::from(width); if width == 0 { @@ -272,9 +297,13 @@ fn scroll_offset(items: &[PlanItem], cursor_item: Option, viewport: u16) fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) { let global = &state.global; let title = if global.total > 0 { - format!(" Library — {} artists ", global.total) + format!( + " Library — {} artists · {} ", + global.total, + global.filters.source_mode.label() + ) } else { - " Library ".to_string() + format!(" Library · {} ", global.filters.source_mode.label()) }; let mut title_spans = vec![Span::styled(title, theme::tab_active())]; if global.filters.is_active() { @@ -323,13 +352,14 @@ fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) { width: TILE_WIDTH, height: TILE_HEIGHT, }; - draw_tile( + draw_tile_with_availability( frame, tile, tile_art(state, artist.image_path.as_ref()), &artist.name, &artist_tile_meta(artist), index == global.selected, + Some(artist.availability), ); } } @@ -557,13 +587,14 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: if tile.width < 3 { break; } - draw_tile( + draw_tile_with_availability( frame, tile, tile_art(state, release.cover_path.as_ref()), &release.title, &release_tile_meta(release), cursor == tracks + position, + Some(release.availability), ); } } @@ -1055,13 +1086,14 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz if let Some(year) = release.year { meta = format!("{meta} · {year}"); } - draw_tile( + draw_tile_with_availability( frame, tile, tile_art(state, release.cover_path.as_ref()), &release.title, &meta, cursor == *position, + Some(Availability::Remote), ); } } diff --git a/src/ui/popup.rs b/src/ui/popup.rs index 4651b80..7a1a476 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -371,7 +371,7 @@ fn clip_cells(text: &str, max_width: usize) -> String { } fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { - let area = centered(frame.area(), 46, 6); + let area = centered(frame.area(), 54, 9); let block = Block::bordered() .title(" Library filters ") .title_style(theme::header()) @@ -381,33 +381,47 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { frame.render_widget(block, area); let [list_area, _, footer] = Layout::vertical([ - Constraint::Length(1), + Constraint::Length(4), Constraint::Length(1), Constraint::Length(1), ]) .areas(inner); + let mut rows: Vec> = Vec::new(); let checked = if state.global.filters.hide_featured_only { "[x]" } else { "[ ]" }; - let row = Rect { - height: 1, - ..list_area - }; - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!("{checked} "), theme::accent()), - Span::raw("Hide featured only"), - ])), - row, - ); - if cursor == 0 { - frame.buffer_mut().set_style(row, theme::tab_active()); + rows.push(Line::from(vec![ + Span::styled(format!("{checked} "), theme::accent()), + Span::raw("Hide featured only"), + ])); + for mode in crate::config::settings::LibrarySourceMode::ALL { + let marker = if state.global.filters.source_mode == mode { + "(*)" + } else { + "( )" + }; + rows.push(Line::from(vec![ + Span::styled(format!("{marker} "), theme::accent()), + Span::raw(mode.label()), + Span::styled(format!(" {}", mode.description()), theme::dim()), + ])); + } + for (index, line) in rows.into_iter().enumerate() { + let row = Rect { + y: list_area.y + index as u16, + height: 1, + ..list_area + }; + frame.render_widget(Paragraph::new(line), row); + if cursor == index { + frame.buffer_mut().set_style(row, theme::tab_active()); + } } frame.render_widget( - Paragraph::new(Line::styled("space/enter toggle · esc close", theme::dim())) + Paragraph::new(Line::styled("space/enter select · esc close", theme::dim())) .alignment(Alignment::Center), footer, );