Added network catalog slices
This commit is contained in:
Generated
+6
-5
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -253,6 +253,7 @@ mod tests {
|
||||
year,
|
||||
cover_path: None,
|
||||
track_count: 1,
|
||||
availability: crate::library::models::Availability::Local,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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?;
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user