Added network catalog slices

This commit is contained in:
Ultradesu
2026-07-25 22:40:27 +03:00
parent b725ca8d05
commit 788bc2e01f
14 changed files with 878 additions and 152 deletions
Generated
+6 -5
View File
@@ -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",
+5
View File
@@ -107,6 +107,11 @@ pub enum AppEvent {
name: String,
result: Result<crate::federation::FedArtistCard, String>,
},
/// 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 {
+147 -7
View File
@@ -41,6 +41,12 @@ pub struct Runtime {
pub federation: Arc<crate::federation::Federation>,
/// When the last Federation-tab status snapshot was requested.
pub fed_status_at: Option<std::time::Instant>,
pub library_network_refresh_at: Option<std::time::Instant>,
pub library_network_refreshing: Arc<std::sync::atomic::AtomicBool>,
pub library_network_cursors:
Arc<std::sync::Mutex<std::collections::HashMap<String, Option<String>>>>,
pub library_network_done: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
pub library_network_mode: crate::config::settings::LibrarySourceMode,
/// Coalesces urgent personal-device syncs after remote playback commands.
pub device_sync_running: Arc<std::sync::atomic::AtomicBool>,
pub device_sync_requested: Arc<std::sync::atomic::AtomicBool>,
@@ -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;
}
+31 -4
View File
@@ -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);
+1
View File
@@ -253,6 +253,7 @@ mod tests {
year,
cover_path: None,
track_count: 1,
availability: crate::library::models::Availability::Local,
}
}
+18 -1
View File
@@ -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<Effect> {
}
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<Effect> {
}
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
+45 -1
View File
@@ -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);
}
}
+16
View File
@@ -2239,6 +2239,22 @@ impl DeviceSync {
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn active_remote_endpoint_ids(&self) -> Result<Vec<String>> {
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::<rusqlite::Result<Vec<_>>>()?)
}
fn active_device_count(&self) -> Result<usize> {
let conn = lock(&self.conn);
Ok(conn.query_row(
+113 -101
View File
@@ -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<String>,
#[serde(default)]
release: Option<String>,
}
/// Header line preceding raw image bytes (artist image / release cover).
#[derive(Debug, Default, Serialize, Deserialize)]
struct ImageHeader {
ok: bool,
#[serde(default)]
error: Option<String>,
#[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<String>,
#[serde(default)]
artist: Option<CatalogArtist>,
}
/// 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<CatalogRelease>,
/// Tracks where the requested artist is featured instead of being a
/// release/main artist.
#[serde(default)]
pub appears_on: Vec<CatalogAppearance>,
}
#[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<i32>,
#[serde(default)]
pub tracks: Vec<CatalogTrack>,
}
#[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<i32>,
#[serde(default)]
pub track: CatalogTrack,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CatalogTrack {
#[serde(default)]
pub title: String,
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)]
pub featured_artists: Vec<String>,
#[serde(default)]
pub track_number: Option<i32>,
#[serde(default)]
pub disc_number: Option<i32>,
#[serde(default)]
pub duration_seconds: Option<f64>,
/// Stable audio content id (`b3:<64 hex>`) when known.
#[serde(default)]
pub content_id: Option<String>,
/// 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<Cat
return Ok(CatalogResponse {
ok: false,
error: Some("artist not found in the library".to_string()),
artist: None,
..CatalogResponse::default()
});
};
Ok(CatalogResponse {
ok: true,
error: None,
artist: Some(artist),
..CatalogResponse::default()
})
}
fn build_artist_slice(
library: &Library,
cursor: Option<String>,
limit: usize,
) -> Result<CatalogResponse> {
let offset = cursor
.as_deref()
.and_then(|value| value.parse::<usize>().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<crate::federation::TransportStats>,
) -> Result<(Vec<CatalogArtistPreview>, Option<String>)> {
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?;
+94
View File
@@ -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<Vec<NetworkLibrarySource>> {
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<String>,
limit: usize,
) -> Result<(usize, Option<String>)> {
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<NetworkArtistPreview> = 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).
+317 -10
View File
@@ -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<String>,
}
#[derive(Debug, Clone)]
pub struct NetworkArtistPreview {
pub artist_key: String,
pub name: String,
pub image_path: Option<String>,
pub release_count: i64,
pub track_count: i64,
}
pub struct Library {
conn: Mutex<Connection>,
/// 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<ArtistsPage> {
pub fn artists(
&self,
page: i64,
limit: i64,
filters: crate::config::settings::LibraryFilters,
) -> Result<ArtistsPage> {
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<ArtistsPage> {
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::<rusqlite::Result<Vec<_>>>()?;
@@ -368,6 +411,247 @@ impl Library {
})
}
fn local_artist_cards(&self, hide_featured_only: bool) -> Result<Vec<ArtistCard>> {
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::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
fn merged_artists(
&self,
page: i64,
limit: i64,
filters: crate::config::settings::LibraryFilters,
) -> Result<ArtistsPage> {
let mut by_key: HashMap<String, ArtistCard> = 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<String>>(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<ArtistCard> = 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::<Vec<_>>();
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<NetworkArtistPreview>, Option<String>)> {
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::<rusqlite::Result<Vec<_>>>()?;
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<usize> {
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<ArtistDetail> {
let conn = self.lock();
let (name, image_path): (String, Option<String>) = 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::<rusqlite::Result<Vec<_>>>()?;
@@ -1994,6 +2279,7 @@ fn release_card_from_row(row: &rusqlite::Row) -> rusqlite::Result<ReleaseCard> {
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<String> {
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());
}
+16
View File
@@ -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<String>,
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<i32>,
pub cover_path: Option<String>,
pub track_count: i64,
pub availability: Availability,
}
#[derive(Debug)]
+39 -7
View File
@@ -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<Availability>,
) {
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<usize>, 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),
);
}
}
+30 -16
View File
@@ -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<Line<'static>> = 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,
);