Added filter. Improved UI. Fixed settings window
This commit is contained in:
+5
-1
@@ -47,6 +47,7 @@ pub enum Action {
|
|||||||
NewPlaylist,
|
NewPlaylist,
|
||||||
ToggleHelp,
|
ToggleHelp,
|
||||||
ToggleViewMode,
|
ToggleViewMode,
|
||||||
|
OpenLibraryFilters,
|
||||||
OpenCommandLine,
|
OpenCommandLine,
|
||||||
OpenSearch,
|
OpenSearch,
|
||||||
EditSelected,
|
EditSelected,
|
||||||
@@ -125,7 +126,9 @@ impl Action {
|
|||||||
| Action::GoToTab(_)
|
| Action::GoToTab(_)
|
||||||
| Action::GoToRelease
|
| Action::GoToRelease
|
||||||
| Action::ToggleViewMode => Category::Navigation,
|
| Action::ToggleViewMode => Category::Navigation,
|
||||||
Action::EditSelected | Action::DeleteSelected => Category::Library,
|
Action::EditSelected | Action::DeleteSelected | Action::OpenLibraryFilters => {
|
||||||
|
Category::Library
|
||||||
|
}
|
||||||
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
|
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
|
||||||
Action::ToggleHelp | Action::Quit => Category::System,
|
Action::ToggleHelp | Action::Quit => Category::System,
|
||||||
}
|
}
|
||||||
@@ -190,6 +193,7 @@ impl Action {
|
|||||||
Action::NewPlaylist => "Create a playlist".into(),
|
Action::NewPlaylist => "Create a playlist".into(),
|
||||||
Action::ToggleHelp => "Show / hide keybindings".into(),
|
Action::ToggleHelp => "Show / hide keybindings".into(),
|
||||||
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
||||||
|
Action::OpenLibraryFilters => "Library filters…".into(),
|
||||||
Action::OpenCommandLine => "Command line (:help for commands)".into(),
|
Action::OpenCommandLine => "Command line (:help for commands)".into(),
|
||||||
Action::OpenSearch => "Search artists, releases, tracks".into(),
|
Action::OpenSearch => "Search artists, releases, tracks".into(),
|
||||||
Action::EditSelected => "Edit the selected item".into(),
|
Action::EditSelected => "Edit the selected item".into(),
|
||||||
|
|||||||
+42
-4
@@ -76,10 +76,18 @@ pub async fn run(
|
|||||||
let library = Arc::new(Library::open(&db_path)?);
|
let library = Arc::new(Library::open(&db_path)?);
|
||||||
tracing::info!(path = %db_path.display(), "library opened");
|
tracing::info!(path = %db_path.display(), "library opened");
|
||||||
|
|
||||||
|
let (settings, settings_warning) = crate::config::settings::load();
|
||||||
|
let status_message = match (startup_warning, settings_warning) {
|
||||||
|
(Some(left), Some(right)) => Some(format!("{left}; {right}")),
|
||||||
|
(Some(message), None) | (None, Some(message)) => Some(message),
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
let mut state = AppState {
|
let mut state = AppState {
|
||||||
status_message: startup_warning,
|
status_message,
|
||||||
..AppState::default()
|
..AppState::default()
|
||||||
};
|
};
|
||||||
|
state.player.volume = settings.volume;
|
||||||
|
state.global.filters = settings.library;
|
||||||
if let Err(err) = state.visualizer.load_library() {
|
if let Err(err) = state.visualizer.load_library() {
|
||||||
state.status_message = Some(format!("visualizations disabled: {err:#}"));
|
state.status_message = Some(format!("visualizations disabled: {err:#}"));
|
||||||
}
|
}
|
||||||
@@ -191,13 +199,16 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
|||||||
{
|
{
|
||||||
global.loading = true;
|
global.loading = true;
|
||||||
let page = global.next_page;
|
let page = global.next_page;
|
||||||
|
let hide_featured_only = global.filters.hide_featured_only;
|
||||||
let limit = *global
|
let limit = *global
|
||||||
.page_limit
|
.page_limit
|
||||||
.get_or_insert_with(|| (needed as i64).clamp(48, 200));
|
.get_or_insert_with(|| (needed as i64).clamp(48, 200));
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = library.artists(page, limit).map_err(err_string);
|
let result = library
|
||||||
|
.artists(page, limit, hide_featured_only)
|
||||||
|
.map_err(err_string);
|
||||||
let _ = tx.send(AppEvent::ArtistsLoaded(result));
|
let _ = tx.send(AppEvent::ArtistsLoaded(result));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -389,7 +400,10 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
.player
|
.player
|
||||||
.seek(std::time::Duration::from_secs_f64(target));
|
.seek(std::time::Duration::from_secs_f64(target));
|
||||||
}
|
}
|
||||||
Effect::SetVolume(volume) => runtime.player.set_volume(player::amplitude(volume)),
|
Effect::SetVolume(volume) => {
|
||||||
|
runtime.player.set_volume(player::amplitude(volume));
|
||||||
|
save_app_settings(state);
|
||||||
|
}
|
||||||
Effect::SetOptions => {}
|
Effect::SetOptions => {}
|
||||||
Effect::EnqueueRelease { id, next } => {
|
Effect::EnqueueRelease { id, next } => {
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
@@ -1104,11 +1118,12 @@ fn refresh_artists(state: &mut AppState, runtime: &Runtime) {
|
|||||||
let global = &mut state.global;
|
let global = &mut state.global;
|
||||||
let needed = artist_grid_capacity() + ARTISTS_PREFETCH_MARGIN;
|
let needed = artist_grid_capacity() + ARTISTS_PREFETCH_MARGIN;
|
||||||
let limit = (global.artists.len().max(needed) as i64).clamp(48, 1000);
|
let limit = (global.artists.len().max(needed) as i64).clamp(48, 1000);
|
||||||
|
let hide_featured_only = global.filters.hide_featured_only;
|
||||||
global.reloading = true;
|
global.reloading = true;
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let event = match library.artists(1, limit) {
|
let event = match library.artists(1, limit, hide_featured_only) {
|
||||||
Ok(page) => AppEvent::ArtistsReloaded { page, limit },
|
Ok(page) => AppEvent::ArtistsReloaded { page, limit },
|
||||||
Err(err) => AppEvent::ArtistsLoaded(Err(err_string(err))),
|
Err(err) => AppEvent::ArtistsLoaded(Err(err_string(err))),
|
||||||
};
|
};
|
||||||
@@ -1116,6 +1131,29 @@ fn refresh_artists(state: &mut AppState, runtime: &Runtime) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn save_app_settings(state: &AppState) {
|
||||||
|
let settings = crate::config::settings::AppSettings {
|
||||||
|
volume: state.player.volume,
|
||||||
|
library: state.global.filters,
|
||||||
|
};
|
||||||
|
if let Err(err) = crate::config::settings::save(&settings) {
|
||||||
|
tracing::warn!(%err, "saving app settings failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_artist_pagination(state: &mut AppState) {
|
||||||
|
let global = &mut state.global;
|
||||||
|
global.artists.clear();
|
||||||
|
global.total = 0;
|
||||||
|
global.has_more = true;
|
||||||
|
global.next_page = 1;
|
||||||
|
global.loading = false;
|
||||||
|
global.error = None;
|
||||||
|
global.selected = 0;
|
||||||
|
global.page_limit = None;
|
||||||
|
global.reloading = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Swap queue entries for their fresh library copies; tracks that were
|
/// Swap queue entries for their fresh library copies; tracks that were
|
||||||
/// deleted leave the queue.
|
/// deleted leave the queue.
|
||||||
fn apply_queue_refresh(
|
fn apply_queue_refresh(
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
Popup::ConfirmDelete { target, label } => {
|
Popup::ConfirmDelete { target, label } => {
|
||||||
handle_confirm_delete(state, runtime, target, label, key);
|
handle_confirm_delete(state, runtime, target, label, key);
|
||||||
}
|
}
|
||||||
|
Popup::LibraryFilters { cursor } => handle_library_filters(state, runtime, cursor, key),
|
||||||
Popup::TrackInfo {
|
Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -63,6 +64,23 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_library_filters(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) {
|
||||||
|
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::Enter | KeyCode::Char(' ') => {
|
||||||
|
state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only;
|
||||||
|
super::save_app_settings(state);
|
||||||
|
super::reset_artist_pagination(state);
|
||||||
|
super::refresh_artists(state, runtime);
|
||||||
|
state.popup = Some(Popup::LibraryFilters { cursor });
|
||||||
|
}
|
||||||
|
_ => state.popup = Some(Popup::LibraryFilters { cursor }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||||
fn handle_fed_input(
|
fn handle_fed_input(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
|
|||||||
+8
-4
@@ -17,7 +17,7 @@ pub enum Loadable<T> {
|
|||||||
Failed(String),
|
Failed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tile geometry for the Global artist grid (kept here so selection math in
|
/// Tile geometry for the Library artist grid (kept here so selection math in
|
||||||
/// update() and rendering in ui::global agree). Width × height in cells,
|
/// update() and rendering in ui::global agree). Width × height in cells,
|
||||||
/// including the tile border; the art area inside is 18×8 cells = 18×16 px.
|
/// including the tile border; the art area inside is 18×8 cells = 18×16 px.
|
||||||
pub const TILE_WIDTH: u16 = 20;
|
pub const TILE_WIDTH: u16 = 20;
|
||||||
@@ -52,7 +52,7 @@ pub enum ArtState {
|
|||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A drill-down view pushed on top of the Global artist grid. Cursors live
|
/// A drill-down view pushed on top of the Library artist grid. Cursors live
|
||||||
/// in the stack entry so going Back restores the previous position.
|
/// in the stack entry so going Back restores the previous position.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum GlobalView {
|
pub enum GlobalView {
|
||||||
@@ -82,7 +82,7 @@ pub enum GlobalView {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Global tab: the whole server library of artists.
|
/// The Library tab: the whole server library of artists.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct GlobalTab {
|
pub struct GlobalTab {
|
||||||
pub artists: Vec<ArtistCard>,
|
pub artists: Vec<ArtistCard>,
|
||||||
@@ -93,6 +93,7 @@ pub struct GlobalTab {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub selected: usize,
|
pub selected: usize,
|
||||||
pub view: ViewMode,
|
pub view: ViewMode,
|
||||||
|
pub filters: crate::config::settings::LibraryFilters,
|
||||||
pub stack: Vec<GlobalView>,
|
pub stack: Vec<GlobalView>,
|
||||||
/// Page size, fixed at the first request — the offset is
|
/// Page size, fixed at the first request — the offset is
|
||||||
/// `(page-1) * limit`, so it must not change between pages.
|
/// `(page-1) * limit`, so it must not change between pages.
|
||||||
@@ -113,6 +114,7 @@ impl Default for GlobalTab {
|
|||||||
error: None,
|
error: None,
|
||||||
selected: 0,
|
selected: 0,
|
||||||
view: ViewMode::default(),
|
view: ViewMode::default(),
|
||||||
|
filters: crate::config::settings::LibraryFilters::default(),
|
||||||
stack: Vec::new(),
|
stack: Vec::new(),
|
||||||
page_limit: None,
|
page_limit: None,
|
||||||
reloading: false,
|
reloading: false,
|
||||||
@@ -503,6 +505,8 @@ pub enum Popup {
|
|||||||
},
|
},
|
||||||
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
|
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
|
||||||
ConfirmDelete { target: DeleteTarget, label: String },
|
ConfirmDelete { target: DeleteTarget, label: String },
|
||||||
|
/// Library-home filters. Cursor is kept for the next filters added here.
|
||||||
|
LibraryFilters { cursor: usize },
|
||||||
/// Track metadata viewer; left/right switch between selected tracks.
|
/// Track metadata viewer; left/right switch between selected tracks.
|
||||||
TrackInfo {
|
TrackInfo {
|
||||||
tracks: Vec<TrackItem>,
|
tracks: Vec<TrackItem>,
|
||||||
@@ -662,7 +666,7 @@ impl Tab {
|
|||||||
|
|
||||||
pub fn title(self) -> &'static str {
|
pub fn title(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Tab::Global => "Global",
|
Tab::Global => "Library",
|
||||||
Tab::Playlists => "Playlists",
|
Tab::Playlists => "Playlists",
|
||||||
Tab::Queue => "Queue",
|
Tab::Queue => "Queue",
|
||||||
Tab::Federation => "Settings",
|
Tab::Federation => "Settings",
|
||||||
|
|||||||
@@ -218,6 +218,12 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
Action::OpenLibraryFilters => {
|
||||||
|
if state.active_tab == Tab::Global && state.global.stack.is_empty() {
|
||||||
|
state.popup = Some(super::state::Popup::LibraryFilters { cursor: 0 });
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
Action::OpenCommandLine => {
|
Action::OpenCommandLine => {
|
||||||
state.cmdline.active = true;
|
state.cmdline.active = true;
|
||||||
state.cmdline.input.clear();
|
state.cmdline.input.clear();
|
||||||
@@ -2571,6 +2577,21 @@ mod tests {
|
|||||||
assert_eq!(state.player.volume, 0);
|
assert_eq!(state.player.volume, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_filters_popup_opens_only_on_root() {
|
||||||
|
let mut state = AppState::default();
|
||||||
|
update(&mut state, Action::OpenLibraryFilters);
|
||||||
|
assert!(matches!(
|
||||||
|
state.popup,
|
||||||
|
Some(crate::app::state::Popup::LibraryFilters { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
state.popup = None;
|
||||||
|
state.global.stack.push(GlobalView::Search { cursor: 0 });
|
||||||
|
update(&mut state, Action::OpenLibraryFilters);
|
||||||
|
assert!(state.popup.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn back_closes_help_first() {
|
fn back_closes_help_first() {
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ command = "DeleteSelected"
|
|||||||
key_sequence = "v"
|
key_sequence = "v"
|
||||||
command = "ToggleViewMode"
|
command = "ToggleViewMode"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "f"
|
||||||
|
command = "OpenLibraryFilters"
|
||||||
|
context = "library"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = ":"
|
key_sequence = ":"
|
||||||
command = "OpenCommandLine"
|
command = "OpenCommandLine"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
pub mod settings;
|
||||||
|
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct LibraryFilters {
|
||||||
|
#[serde(default)]
|
||||||
|
pub hide_featured_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryFilters {
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.hide_featured_only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AppSettings {
|
||||||
|
#[serde(default = "default_volume")]
|
||||||
|
pub volume: u8,
|
||||||
|
#[serde(default)]
|
||||||
|
pub library: LibraryFilters,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
volume: default_volume(),
|
||||||
|
library: LibraryFilters::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppSettings {
|
||||||
|
pub fn normalized(mut self) -> Self {
|
||||||
|
self.volume = self.volume.min(100);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_volume() -> u8 {
|
||||||
|
80
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> (AppSettings, Option<String>) {
|
||||||
|
let Some(path) = settings_path() else {
|
||||||
|
return (AppSettings::default(), None);
|
||||||
|
};
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(text) => match toml::from_str::<AppSettings>(&text) {
|
||||||
|
Ok(settings) => (settings.normalized(), None),
|
||||||
|
Err(err) => (
|
||||||
|
AppSettings::default(),
|
||||||
|
Some(format!("settings.toml is malformed: {err}")),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => (AppSettings::default(), None),
|
||||||
|
Err(err) => (
|
||||||
|
AppSettings::default(),
|
||||||
|
Some(format!("settings.toml could not be read: {err}")),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(settings: &AppSettings) -> Result<()> {
|
||||||
|
let path = settings_path().context("cannot determine the config directory")?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
toml::to_string_pretty(&settings.clone().normalized())?,
|
||||||
|
)
|
||||||
|
.with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_path() -> Option<std::path::PathBuf> {
|
||||||
|
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("settings.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_parse_and_normalize() {
|
||||||
|
let settings: AppSettings = toml::from_str(
|
||||||
|
r#"
|
||||||
|
volume = 150
|
||||||
|
|
||||||
|
[library]
|
||||||
|
hide_featured_only = true
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let settings = settings.normalized();
|
||||||
|
|
||||||
|
assert_eq!(settings.volume, 100);
|
||||||
|
assert!(settings.library.hide_featured_only);
|
||||||
|
assert!(settings.library.is_active());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ use music_dht::{
|
|||||||
EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId,
|
EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId,
|
||||||
PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||||
};
|
};
|
||||||
|
use rusqlite::{Connection, OpenFlags, params};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::library::Library;
|
use crate::library::Library;
|
||||||
@@ -159,6 +160,7 @@ pub struct FedStatus {
|
|||||||
pub connected_peers: Vec<String>,
|
pub connected_peers: Vec<String>,
|
||||||
pub known_contacts: usize,
|
pub known_contacts: usize,
|
||||||
pub stored_dht_records: Option<usize>,
|
pub stored_dht_records: Option<usize>,
|
||||||
|
pub stored_dht_bytes: Option<u64>,
|
||||||
pub published_items: usize,
|
pub published_items: usize,
|
||||||
pub last_sync: Option<String>,
|
pub last_sync: Option<String>,
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
@@ -250,6 +252,37 @@ fn now_label() -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unix_time_ms() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dht_record_payload_bytes(data_dir: PathBuf, now_ms: u64) -> Result<u64> {
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<u64> {
|
||||||
|
let path = data_dir.join("state.sqlite3");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let conn = Connection::open_with_flags(
|
||||||
|
&path,
|
||||||
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
|
)
|
||||||
|
.with_context(|| format!("opening {}", path.display()))?;
|
||||||
|
let bytes: i64 = conn.query_row(
|
||||||
|
"SELECT COALESCE(SUM(length(payload)), 0)
|
||||||
|
FROM dht_records
|
||||||
|
WHERE expires_at_ms > ?1",
|
||||||
|
params![now_ms as i64],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(bytes.max(0) as u64)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("DHT size query task failed")?
|
||||||
|
}
|
||||||
|
|
||||||
impl Federation {
|
impl Federation {
|
||||||
pub fn new(library: Arc<Library>) -> Arc<Self> {
|
pub fn new(library: Arc<Library>) -> Arc<Self> {
|
||||||
let dirs = crate::config::project_dirs();
|
let dirs = crate::config::project_dirs();
|
||||||
@@ -534,6 +567,10 @@ impl Federation {
|
|||||||
.collect();
|
.collect();
|
||||||
status.known_contacts = service.known_peers().len();
|
status.known_contacts = service.known_peers().len();
|
||||||
status.stored_dht_records = service.dht_record_count().await.ok();
|
status.stored_dht_records = service.dht_record_count().await.ok();
|
||||||
|
status.stored_dht_bytes =
|
||||||
|
dht_record_payload_bytes(self.data_dir.clone(), unix_time_ms())
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
status.published_items = service
|
status.published_items = service
|
||||||
.list_local_items()
|
.list_local_items()
|
||||||
.await
|
.await
|
||||||
|
|||||||
+82
-15
@@ -205,20 +205,42 @@ impl Library {
|
|||||||
// Reads (same shapes the API used to return)
|
// Reads (same shapes the API used to return)
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
pub fn artists(&self, page: i64, limit: i64) -> Result<ArtistsPage> {
|
pub fn artists(&self, page: i64, limit: i64, hide_featured_only: bool) -> Result<ArtistsPage> {
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
let total: i64 = conn.query_row("SELECT COUNT(*) FROM artists", [], |row| row.get(0))?;
|
let hide_featured_only = i64::from(hide_featured_only);
|
||||||
|
let total: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*)
|
||||||
|
FROM artists a
|
||||||
|
WHERE ?1 = 0
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM release_artists ra
|
||||||
|
WHERE ra.artist_id = a.id
|
||||||
|
)",
|
||||||
|
[hide_featured_only],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
let offset = (page.max(1) - 1) * limit;
|
let offset = (page.max(1) - 1) * limit;
|
||||||
let mut statement = conn.prepare(
|
let mut statement = conn.prepare(
|
||||||
"SELECT a.id, a.name, a.image_path,
|
"SELECT a.id, a.name, a.image_path,
|
||||||
(SELECT COUNT(*) FROM release_artists ra WHERE ra.artist_id = a.id),
|
(SELECT COUNT(DISTINCT ra.release_id)
|
||||||
(SELECT COUNT(*) FROM track_artists ta WHERE ta.artist_id = a.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
|
FROM artists a
|
||||||
ORDER BY a.name COLLATE NOCASE
|
WHERE ?1 = 0
|
||||||
LIMIT ?1 OFFSET ?2",
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM release_artists ra
|
||||||
|
WHERE ra.artist_id = a.id
|
||||||
|
)
|
||||||
|
ORDER BY release_count DESC, track_count DESC, a.name COLLATE NOCASE
|
||||||
|
LIMIT ?2 OFFSET ?3",
|
||||||
)?;
|
)?;
|
||||||
let items = statement
|
let items = statement
|
||||||
.query_map(params![limit, offset], |row| {
|
.query_map(params![hide_featured_only, limit, offset], |row| {
|
||||||
Ok(ArtistCard {
|
Ok(ArtistCard {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
name: row.get(1)?,
|
name: row.get(1)?,
|
||||||
@@ -371,8 +393,12 @@ impl Library {
|
|||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
let mut statement = conn.prepare(
|
let mut statement = conn.prepare(
|
||||||
"SELECT a.id, a.name, a.image_path,
|
"SELECT a.id, a.name, a.image_path,
|
||||||
(SELECT COUNT(*) FROM release_artists ra WHERE ra.artist_id = a.id),
|
(SELECT COUNT(DISTINCT ra.release_id)
|
||||||
(SELECT COUNT(*) FROM track_artists ta WHERE ta.artist_id = a.id)
|
FROM release_artists ra
|
||||||
|
WHERE ra.artist_id = a.id),
|
||||||
|
(SELECT COUNT(DISTINCT ta.track_id)
|
||||||
|
FROM track_artists ta
|
||||||
|
WHERE ta.artist_id = a.id)
|
||||||
FROM artists a WHERE instr(norm(a.name), ?1) > 0
|
FROM artists a WHERE instr(norm(a.name), ?1) > 0
|
||||||
ORDER BY a.name COLLATE NOCASE LIMIT ?2",
|
ORDER BY a.name COLLATE NOCASE LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
@@ -1222,12 +1248,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 {
|
fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 {
|
||||||
|
add_track_with_featured(lib, title, artist, &[], album)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_track_with_featured(
|
||||||
|
lib: &Library,
|
||||||
|
title: &str,
|
||||||
|
artist: &str,
|
||||||
|
featured: &[&str],
|
||||||
|
album: &str,
|
||||||
|
) -> i64 {
|
||||||
let import = import::TrackImport {
|
let import = import::TrackImport {
|
||||||
release_type: None,
|
release_type: None,
|
||||||
file_path: format!("/music/{artist}/{album}/{title}.mp3"),
|
file_path: format!("/music/{artist}/{album}/{title}.mp3"),
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
artists: vec![artist.to_string()],
|
artists: vec![artist.to_string()],
|
||||||
featured_artists: Vec::new(),
|
featured_artists: featured.iter().map(|name| (*name).to_string()).collect(),
|
||||||
album_artists: vec![artist.to_string()],
|
album_artists: vec![artist.to_string()],
|
||||||
release_title: album.to_string(),
|
release_title: album.to_string(),
|
||||||
year: Some(2020),
|
year: Some(2020),
|
||||||
@@ -1244,11 +1280,40 @@ mod tests {
|
|||||||
import::upsert_track(lib, &import).unwrap().0
|
import::upsert_track(lib, &import).unwrap().0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artists_page_prioritizes_releases_then_tracks() {
|
||||||
|
let lib = test_library();
|
||||||
|
add_track(&lib, "Solo", "Zed", "Zed Album");
|
||||||
|
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 zed_pos = page
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.position(|artist| artist.name == "Zed")
|
||||||
|
.unwrap();
|
||||||
|
let guest_pos = page
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.position(|artist| artist.name == "Guest")
|
||||||
|
.unwrap();
|
||||||
|
let guest = &page.items[guest_pos];
|
||||||
|
|
||||||
|
assert_eq!(guest.release_count, 0);
|
||||||
|
assert_eq!(guest.track_count, 2);
|
||||||
|
assert!(zed_pos < guest_pos);
|
||||||
|
|
||||||
|
let filtered = lib.artists(1, 10, true).unwrap();
|
||||||
|
assert!(filtered.items.iter().all(|artist| artist.release_count > 0));
|
||||||
|
assert!(!filtered.items.iter().any(|artist| artist.name == "Guest"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn import_creates_artist_release_track() {
|
fn import_creates_artist_release_track() {
|
||||||
let lib = test_library();
|
let lib = test_library();
|
||||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||||
let page = lib.artists(1, 10).unwrap();
|
let page = lib.artists(1, 10, false).unwrap();
|
||||||
assert_eq!(page.total, 1);
|
assert_eq!(page.total, 1);
|
||||||
assert_eq!(page.items[0].name, "Artist");
|
assert_eq!(page.items[0].name, "Artist");
|
||||||
assert_eq!(page.items[0].track_count, 1);
|
assert_eq!(page.items[0].track_count, 1);
|
||||||
@@ -1269,7 +1334,7 @@ mod tests {
|
|||||||
let first = add_track(&lib, "Song", "Artist", "Album");
|
let first = add_track(&lib, "Song", "Artist", "Album");
|
||||||
let second = add_track(&lib, "Song", "Artist", "Album");
|
let second = add_track(&lib, "Song", "Artist", "Album");
|
||||||
assert_eq!(first, second);
|
assert_eq!(first, second);
|
||||||
let page = lib.artists(1, 10).unwrap();
|
let page = lib.artists(1, 10, false).unwrap();
|
||||||
assert_eq!(page.items[0].track_count, 1);
|
assert_eq!(page.items[0].track_count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1357,9 +1422,9 @@ mod tests {
|
|||||||
fn deleting_artist_cleans_up_own_content() {
|
fn deleting_artist_cleans_up_own_content() {
|
||||||
let lib = test_library();
|
let lib = test_library();
|
||||||
add_track(&lib, "Song", "Solo", "Solo Album");
|
add_track(&lib, "Song", "Solo", "Solo Album");
|
||||||
let page = lib.artists(1, 10).unwrap();
|
let page = lib.artists(1, 10, false).unwrap();
|
||||||
lib.delete_artist(page.items[0].id).unwrap();
|
lib.delete_artist(page.items[0].id).unwrap();
|
||||||
assert_eq!(lib.artists(1, 10).unwrap().total, 0);
|
assert_eq!(lib.artists(1, 10, false).unwrap().total, 0);
|
||||||
assert_eq!(lib.search("Song", 10).unwrap().len(), 0);
|
assert_eq!(lib.search("Song", 10).unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1368,7 +1433,9 @@ mod tests {
|
|||||||
let lib = test_library();
|
let lib = test_library();
|
||||||
let track_id = add_track(&lib, "Only", "Artist", "Album");
|
let track_id = add_track(&lib, "Only", "Artist", "Album");
|
||||||
lib.delete_track(track_id).unwrap();
|
lib.delete_track(track_id).unwrap();
|
||||||
let detail = lib.artist(lib.artists(1, 10).unwrap().items[0].id).unwrap();
|
let detail = lib
|
||||||
|
.artist(lib.artists(1, 10, false).unwrap().items[0].id)
|
||||||
|
.unwrap();
|
||||||
assert!(detail.releases.is_empty());
|
assert!(detail.releases.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,16 @@ fn status_line(label: &str, value: String) -> Line<'static> {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bytes_label(bytes: u64) -> String {
|
||||||
|
if bytes >= 1024 * 1024 {
|
||||||
|
format!("{bytes} B ({:.1} MiB)", bytes as f64 / 1024.0 / 1024.0)
|
||||||
|
} else if bytes >= 1024 {
|
||||||
|
format!("{bytes} B ({:.1} KiB)", bytes as f64 / 1024.0)
|
||||||
|
} else {
|
||||||
|
format!("{bytes} B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn short_id(id: &str) -> String {
|
fn short_id(id: &str) -> String {
|
||||||
id.chars().take(12).collect::<String>() + "…"
|
id.chars().take(12).collect::<String>() + "…"
|
||||||
}
|
}
|
||||||
@@ -240,6 +250,13 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
.map(|count| count.to_string())
|
.map(|count| count.to_string())
|
||||||
.unwrap_or_else(|| "unavailable".to_string()),
|
.unwrap_or_else(|| "unavailable".to_string()),
|
||||||
));
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Stored DHT bytes",
|
||||||
|
status
|
||||||
|
.stored_dht_bytes
|
||||||
|
.map(bytes_label)
|
||||||
|
.unwrap_or_else(|| "unavailable".to_string()),
|
||||||
|
));
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Published items",
|
"Published items",
|
||||||
status.published_items.to_string(),
|
status.published_items.to_string(),
|
||||||
|
|||||||
+15
-9
@@ -36,10 +36,11 @@ fn error_style() -> Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect {
|
fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect {
|
||||||
let block = Block::bordered()
|
bordered_line(frame, area, Line::styled(title, theme::header()))
|
||||||
.title(title)
|
}
|
||||||
.title_style(theme::header())
|
|
||||||
.border_style(theme::dim());
|
fn bordered_line(frame: &mut Frame, area: Rect, title: Line<'static>) -> Rect {
|
||||||
|
let block = Block::bordered().title(title).border_style(theme::dim());
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
inner
|
inner
|
||||||
@@ -271,11 +272,16 @@ fn scroll_offset(items: &[PlanItem], cursor_item: Option<usize>, viewport: u16)
|
|||||||
fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let global = &state.global;
|
let global = &state.global;
|
||||||
let title = if global.total > 0 {
|
let title = if global.total > 0 {
|
||||||
format!(" Global — {} artists ", global.total)
|
format!(" Library — {} artists ", global.total)
|
||||||
} else {
|
} else {
|
||||||
" Global ".to_string()
|
" Library ".to_string()
|
||||||
};
|
};
|
||||||
let inner = bordered(frame, area, title);
|
let mut title_spans = vec![Span::styled(title, theme::tab_active())];
|
||||||
|
if global.filters.is_active() {
|
||||||
|
title_spans.push(Span::raw(" "));
|
||||||
|
title_spans.push(Span::styled(" FILTERED ", theme::tab_active()));
|
||||||
|
}
|
||||||
|
let inner = bordered_line(frame, area, Line::from(title_spans));
|
||||||
|
|
||||||
if global.artists.is_empty() {
|
if global.artists.is_empty() {
|
||||||
let message = if let Some(error) = &global.error {
|
let message = if let Some(error) = &global.error {
|
||||||
@@ -373,7 +379,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
Some(Loadable::Ready(detail)) => detail.name.clone(),
|
Some(Loadable::Ready(detail)) => detail.name.clone(),
|
||||||
_ => "Artist".to_string(),
|
_ => "Artist".to_string(),
|
||||||
};
|
};
|
||||||
let inner = bordered(frame, area, format!(" Global ▸ {name} "));
|
let inner = bordered(frame, area, format!(" Library ▸ {name} "));
|
||||||
|
|
||||||
let detail = match loadable {
|
let detail = match loadable {
|
||||||
Some(Loadable::Ready(detail)) => detail,
|
Some(Loadable::Ready(detail)) => detail,
|
||||||
@@ -640,7 +646,7 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
|||||||
Some(Loadable::Ready(detail)) => detail.title.clone(),
|
Some(Loadable::Ready(detail)) => detail.title.clone(),
|
||||||
_ => "Release".to_string(),
|
_ => "Release".to_string(),
|
||||||
};
|
};
|
||||||
let inner = bordered(frame, area, format!(" Global ▸ {title} "));
|
let inner = bordered(frame, area, format!(" Library ▸ {title} "));
|
||||||
|
|
||||||
let detail = match loadable {
|
let detail = match loadable {
|
||||||
Some(Loadable::Ready(detail)) => detail,
|
Some(Loadable::Ready(detail)) => detail,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
..
|
..
|
||||||
}) => draw_edit(frame, title, fields, *focus, error.as_deref()),
|
}) => draw_edit(frame, title, fields, *focus, error.as_deref()),
|
||||||
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, label),
|
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, label),
|
||||||
|
Some(Popup::LibraryFilters { cursor }) => draw_library_filters(frame, state, *cursor),
|
||||||
Some(Popup::TrackInfo {
|
Some(Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -39,6 +40,49 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
|
||||||
|
let area = centered(frame.area(), 46, 6);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Library filters ")
|
||||||
|
.title_style(theme::header())
|
||||||
|
.border_style(theme::accent());
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let [list_area, _, footer] = Layout::vertical([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.areas(inner);
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::styled("space/enter toggle · esc close", theme::dim()))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
footer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||||
fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::LineEdit) {
|
fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::LineEdit) {
|
||||||
let area = centered(frame.area(), 64, 5);
|
let area = centered(frame.area(), 64, 5);
|
||||||
|
|||||||
Reference in New Issue
Block a user