From f73e3e7b95f665a1cb59fbdbb1d23c605a31f7cd Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Sun, 2 Aug 2026 00:40:50 +0100 Subject: [PATCH] Added nix files. revorked queue append, added library migration. --- CHANGELOG.md | 32 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- devenv.nix | 23 +++ flake.lock | 362 +++++++++++++++++++++++++++++++++ flake.nix | 30 +++ rust-toolchain.toml | 4 + src/app/action.rs | 6 + src/app/cmdline.rs | 13 ++ src/app/event.rs | 2 + src/app/mod.rs | 162 ++++++++++++++- src/app/popup.rs | 86 +++++++- src/app/state.rs | 117 ++++++++++- src/app/update.rs | 142 ++++++++++++- src/app/update_tests.rs | 69 ++++++- src/config/default_keymap.toml | 10 + src/config/settings.rs | 17 ++ src/federation/mod.rs | 144 ++++++++++--- src/library/mod.rs | 337 ++++++++++++++++++++++++++++++ src/library/tests.rs | 124 +++++++++++ src/ui/federation.rs | 19 ++ src/ui/mod.rs | 2 +- src/ui/popup.rs | 71 ++++++- 23 files changed, 1707 insertions(+), 69 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 devenv.nix create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rust-toolchain.toml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4f32750 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to Furumi are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.5] - 2026-08-02 + +### Added + +- Command history navigation with the Up and Down arrow keys. +- A copy-friendly plain terminal view for connection tickets, device and Jam + invites, and track-sharing links. +- A configurable permanent music directory with write validation and an + optional safe migration of existing Furumi-managed files. +- Queue reordering for one track or a `Shift+V` selection with `Alt+K` and + `Alt+J`. +- Reproducible Nix/devenv tooling with Rust 1.97 and ALSA development files, + plus automatic Rust 1.97 selection through `rust-toolchain.toml`. + +### Changed + +- Sequential `a` actions now build one ordered play-next block instead of + reversing independently inserted tracks. +- Federated artist images and release covers are stored beside permanent music + in an `Artist/Release` directory tree. + +[Unreleased]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.5...HEAD +[0.2.5]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.4...v0.2.5 diff --git a/Cargo.lock b/Cargo.lock index c1e425a..8c90a68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "furumi_tui" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "blake3", diff --git a/Cargo.toml b/Cargo.toml index f0b51d1..5fdb817 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi_tui" -version = "0.2.4" +version = "0.2.5" edition = "2024" rust-version = "1.97" description = "A federated P2P player for personal music libraries" diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..1bdf4e3 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,23 @@ +{ pkgs, ... }: + +{ + languages.rust = { + enable = true; + channel = "stable"; + version = "1.97.0"; + components = [ + "cargo" + "clippy" + "rust-src" + "rustfmt" + ]; + }; + + packages = [ + pkgs.pkg-config + ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + pkgs.alsa-lib + ]; + + env.RUST_BACKTRACE = "1"; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0d513e4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,362 @@ +{ + "nodes": { + "cachix": { + "inputs": { + "devenv": [ + "devenv" + ], + "flake-compat": [ + "devenv", + "flake-compat" + ], + "git-hooks": [ + "devenv", + "git-hooks" + ], + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1777487137, + "narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=", + "owner": "cachix", + "repo": "cachix", + "rev": "a66a440c321d35f7193472c317f42a55ccd1cb93", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "latest", + "repo": "cachix", + "type": "github" + } + }, + "crate2nix": { + "flake": false, + "locked": { + "lastModified": 1772186516, + "narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=", + "owner": "rossng", + "repo": "crate2nix", + "rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e", + "type": "github" + }, + "original": { + "owner": "rossng", + "repo": "crate2nix", + "rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e", + "type": "github" + } + }, + "devenv": { + "inputs": { + "cachix": "cachix", + "crate2nix": "crate2nix", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "ghostty": "ghostty", + "git-hooks": "git-hooks", + "nix": "nix", + "nixd": "nixd", + "nixpkgs": [ + "nixpkgs" + ], + "rust-overlay": "rust-overlay" + }, + "locked": { + "lastModified": 1785616853, + "narHash": "sha256-WKA53CfbSwXh/QuGHc3gsWoLNHYaKw4xgvkc9ceRgUA=", + "owner": "cachix", + "repo": "devenv", + "rev": "a3feed9ca96c66548496e8af2804d2a3a045d22e", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_2": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1782949081, + "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "ghostty": { + "flake": false, + "locked": { + "lastModified": 1784602798, + "narHash": "sha256-298x90knBUWX5GHGXh2SKsAKvStjU2ri9UgOGoF79/8=", + "owner": "ghostty-org", + "repo": "ghostty", + "rev": "88b4cd047fa627cdca6781bc7e7dc8b75a2cecb9", + "type": "github" + }, + "original": { + "owner": "ghostty-org", + "repo": "ghostty", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": [ + "devenv", + "flake-compat" + ], + "nixpkgs": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1782908218, + "narHash": "sha256-wLMOrPgVyeF3XmP+qfYcLqnVdTxikdcSvbIY7rA9jTA=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "9f7e99119ece7705299595299f3b031f39356de1", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "nix": { + "inputs": { + "flake-compat": [ + "devenv", + "flake-compat" + ], + "flake-parts": [ + "devenv", + "flake-parts" + ], + "git-hooks-nix": [ + "devenv", + "git-hooks" + ], + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "nixpkgs-23-11": [ + "devenv" + ], + "nixpkgs-regression": [ + "devenv" + ] + }, + "locked": { + "lastModified": 1785349663, + "narHash": "sha256-JSD8lPe5kalvPKx5X+inX8ZZdGLeXkAbd3Jiv7UDf+I=", + "owner": "cachix", + "repo": "nix", + "rev": "f33db89fd6db6edc337d93212f6628ab6d25f407", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "devenv-2.34", + "repo": "nix", + "type": "github" + } + }, + "nixd": { + "inputs": { + "flake-parts": [ + "devenv", + "flake-parts" + ], + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1783935112, + "narHash": "sha256-IAQ14nteIKXAz4cd75UcZrsHGEVJ7QNrkUwX9rmeZ/Y=", + "owner": "nix-community", + "repo": "nixd", + "rev": "a64cd33e53b316b6b092ea0a966640cd2309bf3d", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixd", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1772624091, + "narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1782614948, + "narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1785571196, + "narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "flake-parts": "flake-parts_2", + "nixpkgs": "nixpkgs_2", + "rust-overlay": "rust-overlay_2" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1782875958, + "narHash": "sha256-5eqDcnBjb1424HRQdnhuhNOBZguq1Z2tqSa2OMF/m2c=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "13139aefa973f3d96c60c0fbab801de058ae25ca", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "rust-overlay_2": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1785562362, + "narHash": "sha256-J15aBa3d6B1SUUAQydQ06wFjPzrrcVceb6jkdfwfGls=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "5f29c219a7655519f8a9f8c6968064b82c17cc93", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "devenv", + "nixd", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1780220602, + "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "db947814a175b7ca6ded66e21383d938df01c227", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..43de5f8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,30 @@ +{ + description = "Furumi TUI development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + devenv.url = "github:cachix/devenv"; + devenv.inputs.nixpkgs.follows = "nixpkgs"; + rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = inputs@{ flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + imports = [ inputs.devenv.flakeModule ]; + + perSystem = { ... }: { + devenv.shells.default = { + imports = [ ./devenv.nix ]; + }; + }; + }; +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..b51043e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.0" +profile = "minimal" +components = ["clippy", "rust-src", "rustfmt"] diff --git a/src/app/action.rs b/src/app/action.rs index ee66a6d..ec21964 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -38,6 +38,8 @@ pub enum Action { OpenCurrentTrackInfo, QueueAddNext, QueueAddLast, + MoveQueueUp, + MoveQueueDown, /// Download the selected federated track(s) into the local library. DownloadSelected, RemoveFromQueue, @@ -107,6 +109,8 @@ impl Action { | Action::OpenListenHistory => Category::Playback, Action::QueueAddNext | Action::QueueAddLast + | Action::MoveQueueUp + | Action::MoveQueueDown | Action::DownloadSelected | Action::RemoveFromQueue | Action::ClearQueue @@ -193,6 +197,8 @@ impl Action { Action::OpenCurrentTrackInfo => "Current track info".into(), Action::QueueAddNext => "Queue: add next".into(), Action::QueueAddLast => "Queue: add to end".into(), + Action::MoveQueueUp => "Queue: move track/selection up".into(), + Action::MoveQueueDown => "Queue: move track/selection down".into(), Action::DownloadSelected => "Federation: download to library".into(), Action::RemoveFromQueue => "Queue: remove selected".into(), Action::ClearQueue => "Queue: clear".into(), diff --git a/src/app/cmdline.rs b/src/app/cmdline.rs index 4258275..9286038 100644 --- a/src/app/cmdline.rs +++ b/src/app/cmdline.rs @@ -17,6 +17,16 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { match key.code { KeyCode::Esc => cancel(state), KeyCode::Enter => commit(state, runtime), + KeyCode::Up => { + if state.cmdline.history_previous() { + after_change(state, runtime); + } + } + KeyCode::Down => { + if state.cmdline.history_next() { + after_change(state, runtime); + } + } // Backspace on an empty line closes it, like vim. KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state), _ => { @@ -162,7 +172,9 @@ pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) { /// Enter: close the line. Live commands already took effect (their view /// stays open); one-shot commands execute here. fn commit(state: &mut AppState, runtime: &mut Runtime) { + let remembered = state.cmdline.input.as_str().to_string(); let parsed = command::parse(&state.cmdline.input); + state.cmdline.remember(&remembered); close(state); match parsed { Parsed::Empty => {} @@ -272,6 +284,7 @@ fn close(state: &mut AppState) { state.cmdline.active = false; state.cmdline.input.clear(); state.cmdline.live = false; + state.cmdline.begin_history_navigation(); } /// Pop the live search view if this command-line session opened it. diff --git a/src/app/event.rs b/src/app/event.rs index ef8f023..504abf4 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -56,6 +56,8 @@ pub enum AppEvent { LocalContentIdsLoaded(Result, String>), /// Counts and storage footprint of the local library/database. LocalLibraryStatsLoaded(Result), + MusicDirectoryValidated(Result), + MusicDirectoryChanged(Result), ListenHistoryLoaded(Result, String>), /// One content id became available locally while the UI is open. LocalContentAvailable { diff --git a/src/app/mod.rs b/src/app/mod.rs index 826d621..4e6f2b6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,6 +8,7 @@ pub mod state; pub mod update; use std::io; +use std::io::Write as _; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; @@ -61,6 +62,8 @@ pub struct Runtime { pub art_semaphore: Arc, /// The terminal screen was externally disturbed and needs a full repaint. pub force_redraw: bool, + /// The alternate screen is temporarily suspended for copy-friendly text. + pub plain_text_mode: bool, /// Monotonic sequence for live search; stale responses are dropped. pub search_seq: Arc, pub player: player::Controller, @@ -106,6 +109,15 @@ fn refresh_local_library_stats(runtime: &Runtime) { }); } +pub(super) fn validate_music_directory(state: &mut AppState, runtime: &Runtime, path: PathBuf) { + state.status_message = Some("checking music directory write access…".into()); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = Library::validate_music_directory(&path).map_err(err_string); + let _ = tx.send(AppEvent::MusicDirectoryValidated(result)); + }); +} + fn spawn_artist_federation_enrichment(runtime: &Runtime, id: i64, name: String) { let fed = Arc::clone(&runtime.federation); let tx = runtime.event_tx.clone(); @@ -244,6 +256,7 @@ pub async fn run( }; state.player.volume = settings.volume; state.global.filters = settings.library; + state.music_dir = settings.music_dir.clone(); if let Err(err) = state.visualizer.load_library() { state.status_message = Some(format!("visualizations disabled: {err:#}")); } @@ -255,6 +268,7 @@ pub async fn run( Arc::clone(&library), Arc::clone(&devices), Arc::clone(&jam), + settings.music_dir.clone(), ); state.federation.settings = federation.settings(); state.federation.devices = Some(devices.status()); @@ -288,6 +302,7 @@ pub async fn run( fed_streaming: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), force_redraw: false, + plain_text_mode: false, search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)), player: player::spawn(move |event| { let _ = player_events.send(AppEvent::Player(event)); @@ -318,11 +333,30 @@ pub async fn run( visual_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); loop { - if runtime.force_redraw { - terminal.clear()?; - runtime.force_redraw = false; + let plain_text = match state.popup.as_ref() { + Some(state::Popup::PlainText { text }) => Some(text.as_str()), + _ => None, + }; + match (runtime.plain_text_mode, plain_text) { + (false, Some(text)) => { + enter_plain_text_mode(text)?; + runtime.plain_text_mode = true; + } + (true, None) => { + leave_plain_text_mode()?; + runtime.plain_text_mode = false; + runtime.force_redraw = true; + } + _ => {} + } + + if !runtime.plain_text_mode { + if runtime.force_redraw { + terminal.clear()?; + runtime.force_redraw = false; + } + terminal.draw(|frame| ui::draw(frame, &state, &keymap))?; } - terminal.draw(|frame| ui::draw(frame, &state, &keymap))?; tokio::select! { maybe_event = input.next() => match maybe_event { @@ -347,6 +381,10 @@ pub async fn run( } if state.should_quit { + if runtime.plain_text_mode { + leave_plain_text_mode()?; + runtime.plain_text_mode = false; + } state.shutting_down = true; terminal.draw(|frame| ui::draw(frame, &state, &keymap))?; runtime.federation.shutdown().await; @@ -356,6 +394,32 @@ pub async fn run( } } +fn enter_plain_text_mode(text: &str) -> Result<()> { + let text: String = text + .chars() + .filter(|character| !character.is_control()) + .collect(); + crossterm::execute!( + io::stdout(), + crossterm::terminal::LeaveAlternateScreen, + crossterm::event::DisableBracketedPaste, + crossterm::terminal::Clear(crossterm::terminal::ClearType::All), + crossterm::cursor::MoveTo(0, 0), + crossterm::style::Print(text) + )?; + io::stdout().flush()?; + Ok(()) +} + +fn leave_plain_text_mode() -> Result<()> { + crossterm::execute!( + io::stdout(), + crossterm::terminal::EnterAlternateScreen, + crossterm::event::EnableBracketedPaste + )?; + Ok(()) +} + fn sync_player_shared(state: &mut AppState, runtime: &Runtime) { if state.device_playback.is_control() { extrapolate_control_position(state); @@ -453,6 +517,7 @@ fn apply_playback_state_to_ui( .filter(|track| update::track_allowed_by_source_mode(state, track)) .collect(); state.player.queue_pos = queue_pos.min(state.player.queue.len().saturating_sub(1)); + state.player.play_next_end = None; state.player.playing = wire.playing && !state.player.queue.is_empty(); state.player.paused = wire.paused; state.device_playback.local_idle_since_ms = if state.player.playing && !state.player.paused { @@ -1331,6 +1396,14 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { } Effect::SetOptions => {} Effect::PlaybackQueueChanged => {} + Effect::QueueOrderChanged { restart_current } => { + if restart_current && state.player.playing && state.player.current.is_some() { + let paused = state.player.paused; + start_current_audio(state, runtime, state.player.position_secs, paused); + push_media_metadata(state, runtime); + push_media_update(state, runtime, true); + } + } Effect::SourceModeChanged => { runtime.library_network_refresh_at = None; if let Ok(mut cursors) = runtime.library_network_cursors.lock() { @@ -1349,6 +1422,30 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { perform_effect(state, runtime, effect); } } + Effect::ChangeMusicDirectory { + path, + move_existing, + } => { + if state.music_dir_changing { + state.status_message = Some("music directory change is already running".into()); + return; + } + state.music_dir_changing = true; + state.status_message = Some(if move_existing { + "moving saved music to the new directory…".into() + } else { + "changing music save directory…".into() + }); + let federation = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = federation + .change_media_dir(path, move_existing) + .await + .map_err(err_string); + let _ = tx.send(AppEvent::MusicDirectoryChanged(result)); + }); + } Effect::EnqueueRelease { id, next } => { let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); @@ -1733,6 +1830,7 @@ fn is_controlled_playback_effect(effect: &Effect) -> bool { | Effect::SetOptions | Effect::RemoveQueueIndices { .. } | Effect::PlaybackQueueChanged + | Effect::QueueOrderChanged { .. } ) } @@ -1766,7 +1864,9 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime, Effect::SetOptions | Effect::RemoveQueueIndices { .. } | Effect::PlaybackQueueChanged - | Effect::LoadListenHistory => {} + | Effect::QueueOrderChanged { .. } + | Effect::LoadListenHistory + | Effect::ChangeMusicDirectory { .. } => {} _ => {} } if local_only_volume { @@ -2678,6 +2778,7 @@ fn save_app_settings(state: &AppState) { let settings = crate::config::settings::AppSettings { volume: state.player.volume, library: state.global.filters, + music_dir: state.music_dir.clone(), }; if let Err(err) = crate::config::settings::save(&settings) { tracing::warn!(%err, "saving app settings failed"); @@ -2939,6 +3040,44 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent Err(err) => state::Loadable::Failed(err), }); } + AppEvent::MusicDirectoryValidated(result) => match result { + Ok(path) => { + let current = std::fs::canonicalize(&state.music_dir) + .unwrap_or_else(|_| state.music_dir.clone()); + if path == current { + state.status_message = Some("this is already the music save directory".into()); + } else { + state.popup = Some(state::Popup::ConfirmMusicDirectory { path }); + state.status_message = None; + } + } + Err(message) => { + state.status_message = Some(format!( + "music directory is not writable; nothing changed: {message}" + )); + } + }, + AppEvent::MusicDirectoryChanged(result) => { + state.music_dir_changing = false; + match result { + Ok(stats) => { + state.music_dir = runtime.federation.media_dir(); + save_app_settings(state); + state.status_message = Some(format!( + "music directory changed · moved {} track(s), {} image(s)", + stats.tracks, stats.images + )); + let _ = runtime.event_tx.send(AppEvent::LibraryChanged { + message: state.status_message.clone(), + }); + } + Err(message) => { + state.status_message = Some(format!( + "music directory change failed; old library kept: {message}" + )); + } + } + } AppEvent::FederationStatus(status) => { state.federation.status = Some(status); } @@ -3018,6 +3157,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent title: "Device invite".to_string(), text: invite, help: "Use this invite on another client within 10 minutes to pair it with this device group.".to_string(), + cursor: 0, }); state.status_message = Some("device invite generated".to_string()); } @@ -3117,6 +3257,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent title: "Jam invite".to_string(), text: invite, help: "Copied capability lets federation peers control this host player until restart or regeneration.".to_string(), + cursor: 0, }); state.status_message = Some("Jam started".into()); } @@ -3273,6 +3414,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent // Skip the failed track instead of stalling the queue. if state.player.queue_pos + 1 < state.player.queue.len() { state.player.queue_pos += 1; + update::normalize_play_next_block(&mut state.player); start_current_audio(state, runtime, 0.0, state.player.paused); push_media_metadata(state, runtime); push_media_update(state, runtime, true); @@ -3374,6 +3516,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent text: ticket, help: "Copy this ticket and paste it into Connect to a peer on another client." .to_string(), + cursor: 0, }); } Err(message) => state.status_message = Some(message), @@ -3516,6 +3659,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent .take() .unwrap_or(state.player.queue_pos + 1); state.player.queue_pos = next_pos.min(state.player.queue.len().saturating_sub(1)); + update::normalize_play_next_block(&mut state.player); state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); state.player.position_secs = 0.0; state.player.track_started_at = Some(now_epoch_seconds()); @@ -3648,9 +3792,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } AppEvent::EnqueueTracks { tracks, next } => { let previous_len = state.player.queue.len(); - update::enqueue_tracks(state, tracks, next); + let restart_current = update::enqueue_tracks(state, tracks, next); let count = state.player.queue.len().saturating_sub(previous_len); - record_control_playback_state(state, runtime, false); + perform_effect( + state, + runtime, + Effect::QueueOrderChanged { restart_current }, + ); state.status_message = Some(if count == 0 { "no tracks available in the current source mode".to_string() } else if next { diff --git a/src/app/popup.rs b/src/app/popup.rs index 532360b..0310138 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -136,17 +136,52 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} _ => state.popup = Some(Popup::FedText { title, text }), }, - Popup::FedCopyText { title, text, help } => match key.code { + Popup::FedCopyText { + title, + text, + help, + mut cursor, + } => match key.code { KeyCode::Esc | KeyCode::Char('q') => {} + KeyCode::Left | KeyCode::Right | KeyCode::Tab | KeyCode::BackTab => { + cursor = usize::from(cursor == 0); + state.popup = Some(Popup::FedCopyText { + title, + text, + help, + cursor, + }); + } + KeyCode::Enter if cursor == 1 => state.popup = Some(Popup::PlainText { text }), + KeyCode::Char('p') => state.popup = Some(Popup::PlainText { text }), KeyCode::Enter | KeyCode::Char('c') => match copy_to_clipboard(&text) { Ok(()) => state.status_message = Some("copied to clipboard".to_string()), Err(err) => { state.status_message = Some(format!("copy failed: {err}")); - state.popup = Some(Popup::FedCopyText { title, text, help }); + state.popup = Some(Popup::FedCopyText { + title, + text, + help, + cursor, + }); } }, - _ => state.popup = Some(Popup::FedCopyText { title, text, help }), + _ => { + state.popup = Some(Popup::FedCopyText { + title, + text, + help, + cursor, + }) + } }, + Popup::PlainText { text } => match key.code { + KeyCode::Esc => {} + _ => state.popup = Some(Popup::PlainText { text }), + }, + Popup::ConfirmMusicDirectory { path } => { + handle_music_directory_confirmation(state, runtime, path, key) + } Popup::FederationStatusDetails { focus, status_cursor, @@ -502,6 +537,13 @@ fn handle_fed_input( !state.federation.settings.network_id.is_empty(); super::fed_apply_settings(state, runtime); } + FedInputField::MusicDirectory => { + if value.is_empty() { + state.status_message = Some("music directory is empty".into()); + } else { + super::validate_music_directory(state, runtime, value.into()); + } + } FedInputField::ConnectTicket => { if value.is_empty() { state.status_message = Some("ticket is empty".into()); @@ -551,6 +593,31 @@ fn handle_fed_input( } } +fn handle_music_directory_confirmation( + state: &mut AppState, + runtime: &mut Runtime, + path: std::path::PathBuf, + key: KeyEvent, +) { + let move_existing = match key.code { + KeyCode::Esc | KeyCode::Char('q') => return, + KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => true, + KeyCode::Char('n') | KeyCode::Char('N') => false, + _ => { + state.popup = Some(Popup::ConfirmMusicDirectory { path }); + return; + } + }; + super::perform_effect( + state, + runtime, + crate::app::update::Effect::ChangeMusicDirectory { + path, + move_existing, + }, + ); +} + fn handle_device_pairing( state: &mut AppState, runtime: &Runtime, @@ -934,10 +1001,15 @@ fn handle_track_info( KeyCode::Char('c') => { if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) { match crate::share::track_share_link(track) { - Some(link) => match copy_to_clipboard(&link) { - Ok(()) => state.status_message = Some("frid link copied".into()), - Err(err) => state.status_message = Some(format!("copy failed: {err}")), - }, + Some(link) => { + state.popup = Some(Popup::FedCopyText { + title: "Track share link".into(), + text: link, + help: "Copy the link with a clipboard helper, or show it as one clean terminal line for mouse selection.".into(), + cursor: 0, + }); + return; + } None => state.status_message = Some("no content id for this track yet".into()), } } diff --git a/src/app/state.rs b/src/app/state.rs index 94d889d..2c9164a 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -389,6 +389,7 @@ mod tests { featured_tracks: Vec::new(), }; let mut state = AppState::default(); + state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local; state.artist_fed_views.insert( detail.id, Loadable::Ready(crate::federation::FedArtistCard { @@ -716,7 +717,16 @@ pub enum Popup { title: String, text: String, help: String, + cursor: usize, }, + /// A copy-friendly terminal screen containing exactly one logical line. + /// The app loop temporarily leaves the alternate screen while this is + /// active so terminal selection does not acquire TUI borders or hard + /// line breaks. + PlainText { text: String }, + /// The destination is already write-tested; Enter/y migrates managed + /// files, while n changes only the destination for future downloads. + ConfirmMusicDirectory { path: std::path::PathBuf }, /// Incoming trusted-device pairing request. DevicePairing { request_id: String, @@ -801,6 +811,7 @@ impl StatusDetailFocus { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FedInputField { + MusicDirectory, NetworkId, ConnectTicket, DeviceName, @@ -811,6 +822,7 @@ pub enum FedInputField { impl FedInputField { pub fn title(self) -> &'static str { match self { + FedInputField::MusicDirectory => "Music save directory", FedInputField::NetworkId => "Network ID", FedInputField::ConnectTicket => "Connect to peer (paste ticket)", FedInputField::DeviceName => "Device name", @@ -821,6 +833,9 @@ impl FedInputField { pub fn help(self) -> &'static str { match self { + FedInputField::MusicDirectory => { + "Federated tracks saved to your library use this directory. The directory is checked for write access before anything changes." + } FedInputField::NetworkId => { "A unique network id. It must match exactly on every client that should see and connect to the same peers." } @@ -867,6 +882,7 @@ impl FedRow { /// the config directory. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsRow { + MusicDirectory, Federation(FedRow), StatusDetails, DeviceName, @@ -1004,7 +1020,7 @@ pub fn device_status_order(state: &AppState) -> Vec { } pub fn settings_rows(state: &AppState) -> Vec { - let mut rows = Vec::new(); + let mut rows = vec![SettingsRow::MusicDirectory]; rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation)); rows.push(SettingsRow::DeviceName); rows.push(SettingsRow::DeviceInvite); @@ -1064,6 +1080,98 @@ pub struct Cmdline { /// A live command (search) applied effects during this session; Esc /// undoes them, Enter keeps them. pub live: bool, + /// Commands committed during this process, oldest first. + pub history: Vec, + /// Entry currently recalled with Up/Down. `None` is the editable draft + /// after the newest history entry. + history_index: Option, + history_draft: String, +} + +impl Cmdline { + const HISTORY_LIMIT: usize = 100; + + pub fn begin_history_navigation(&mut self) { + self.history_index = None; + self.history_draft.clear(); + } + + pub fn remember(&mut self, value: &str) { + let value = value.trim(); + if value.is_empty() { + return; + } + if self.history.last().is_none_or(|last| last != value) { + self.history.push(value.to_string()); + if self.history.len() > Self::HISTORY_LIMIT { + self.history.remove(0); + } + } + self.begin_history_navigation(); + } + + pub fn history_previous(&mut self) -> bool { + if self.history.is_empty() { + return false; + } + let index = match self.history_index { + Some(index) => index.saturating_sub(1), + None => { + self.history_draft = self.input.as_str().to_string(); + self.history.len() - 1 + } + }; + self.history_index = Some(index); + self.input = LineEdit::new(self.history[index].clone()); + true + } + + pub fn history_next(&mut self) -> bool { + let Some(index) = self.history_index else { + return false; + }; + if index + 1 < self.history.len() { + self.history_index = Some(index + 1); + self.input = LineEdit::new(self.history[index + 1].clone()); + } else { + self.history_index = None; + self.input = LineEdit::new(std::mem::take(&mut self.history_draft)); + } + true + } +} + +#[cfg(test)] +mod cmdline_history_tests { + use super::Cmdline; + use crate::app::input::LineEdit; + + #[test] + fn history_walks_oldest_and_restores_the_draft() { + let mut cmdline = Cmdline::default(); + cmdline.remember("volume 20"); + cmdline.remember("/ambient"); + cmdline.input = LineEdit::new("unfinished"); + + assert!(cmdline.history_previous()); + assert_eq!(cmdline.input.as_str(), "/ambient"); + assert!(cmdline.history_previous()); + assert_eq!(cmdline.input.as_str(), "volume 20"); + assert!(cmdline.history_previous()); + assert_eq!(cmdline.input.as_str(), "volume 20"); + assert!(cmdline.history_next()); + assert_eq!(cmdline.input.as_str(), "/ambient"); + assert!(cmdline.history_next()); + assert_eq!(cmdline.input.as_str(), "unfinished"); + } + + #[test] + fn history_deduplicates_consecutive_commands() { + let mut cmdline = Cmdline::default(); + cmdline.remember("q"); + cmdline.remember(" q "); + assert_eq!(cmdline.history, vec!["q"]); + } } /// Live search state driven by the `:/query` command. @@ -1169,6 +1277,9 @@ impl RepeatMode { pub struct PlayerBar { pub queue: Vec, pub queue_pos: usize, + /// Exclusive end of the contiguous "play next" block built by + /// sequential `a` actions. New `a` additions are inserted here. + pub play_next_end: Option, pub current: Option, /// A track is loaded (playing or paused); false = stopped. pub playing: bool, @@ -1194,6 +1305,7 @@ impl Default for PlayerBar { Self { queue: Vec::new(), queue_pos: 0, + play_next_end: None, current: None, playing: false, paused: false, @@ -1283,6 +1395,9 @@ pub struct AppState { pub status_message: Option, pub spinner_frame: usize, pub settings_cursor: usize, + /// Root for music permanently downloaded from federation peers. + pub music_dir: std::path::PathBuf, + pub music_dir_changing: bool, pub player: PlayerBar, pub device_playback: DevicePlaybackState, pub jam: crate::jam::JamStatus, diff --git a/src/app/update.rs b/src/app/update.rs index 1c341d6..4e824d6 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -48,8 +48,19 @@ pub enum Effect { }, /// Queue/options changed without a direct audio engine action. PlaybackQueueChanged, + /// Queue order changed. A decoded gapless-next source must be discarded + /// by restarting the current source at its current position. + QueueOrderChanged { + restart_current: bool, + }, /// Persist and apply a Local / My / Global source-mode change. SourceModeChanged, + /// Switch the permanent federation-download root, optionally relocating + /// files managed under the previous root first. + ChangeMusicDirectory { + path: std::path::PathBuf, + move_existing: bool, + }, /// Persist the federation settings and start/stop the node. FedApplySettings, /// Force an immediate library publish into the DHT. @@ -180,6 +191,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option { } Action::ToggleShuffle => { state.player.shuffle = !state.player.shuffle; + state.player.play_next_end = None; // Shuffle physically reorders the unplayed tail, so the Queue // tab always shows the real upcoming order; turning it off // restores the original ordering. @@ -275,6 +287,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option { Action::OpenCommandLine => { state.cmdline.active = true; state.cmdline.input.clear(); + state.cmdline.begin_history_navigation(); None } Action::OpenSearch => { @@ -283,6 +296,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option { state.cmdline.active = true; state.cmdline.input = crate::app::input::LineEdit::new("/"); state.cmdline.live = true; + state.cmdline.begin_history_navigation(); state.search = SearchState::default(); state.active_tab = Tab::Global; if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) { @@ -371,6 +385,8 @@ pub fn update(state: &mut AppState, action: Action) -> Option { Action::RemoveFromQueue => remove_selected_from_queue(state), Action::QueueAddNext => queue_add(state, true), Action::QueueAddLast => queue_add(state, false), + Action::MoveQueueUp => move_selected_queue(state, true), + Action::MoveQueueDown => move_selected_queue(state, false), Action::GoToRelease => { let track = selected_track(state).or_else(|| state.player.current.clone()); match track { @@ -418,6 +434,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option { let had_tracks = !state.player.queue.is_empty(); state.player.queue.clear(); state.player.queue_pos = 0; + state.player.play_next_end = None; state.player.current = None; state.player.playing = false; state.player.paused = false; @@ -1030,6 +1047,68 @@ fn selected_queue_indices(state: &AppState) -> Vec { .unwrap_or_else(|| vec![state.queue_tab.cursor.min(state.player.queue.len() - 1)]) } +fn move_selected_queue(state: &mut AppState, up: bool) -> Option { + let indices = selected_queue_indices(state); + let Some(&start) = indices.first() else { + state.status_message = Some("queue is empty".into()); + return None; + }; + let end = *indices.last().unwrap_or(&start); + let len = state.player.queue.len(); + if (up && start == 0) || (!up && end + 1 >= len) { + state.status_message = Some(if up { + "selection is already at the top of the queue".into() + } else { + "selection is already at the bottom of the queue".into() + }); + return None; + } + + let old_current = state.player.queue_pos; + let new_current = if up { + let displaced = state.player.queue.remove(start - 1); + state.player.queue.insert(end, displaced); + if old_current == start - 1 { + end + } else if (start..=end).contains(&old_current) { + old_current - 1 + } else { + old_current + } + } else { + let displaced = state.player.queue.remove(end + 1); + state.player.queue.insert(start, displaced); + if old_current == end + 1 { + start + } else if (start..=end).contains(&old_current) { + old_current + 1 + } else { + old_current + } + }; + let delta: isize = if up { -1 } else { 1 }; + state.player.queue_pos = new_current; + state.queue_tab.cursor = (state.queue_tab.cursor as isize + delta) as usize; + if state + .track_selection + .is_active_for(&TrackSelectionScope::Queue) + { + state.track_selection.anchor = (state.track_selection.anchor as isize + delta) as usize; + state.track_selection.cursor = (state.track_selection.cursor as isize + delta) as usize; + } + // An explicit manual order supersedes both the temporary play-next block + // and a saved pre-shuffle order. + state.player.play_next_end = None; + state.player.original_order = None; + let restart_current = state.player.prefetched_pos.take().is_some(); + state.status_message = Some(format!( + "moved {} track(s) {}", + indices.len(), + if up { "up" } else { "down" } + )); + Some(Effect::QueueOrderChanged { restart_current }) +} + fn remove_selected_from_queue(state: &mut AppState) -> Option { let indices = selected_queue_indices(state); if indices.is_empty() { @@ -1081,6 +1160,10 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval .iter() .filter(|index| **index < old_queue_pos) .count(); + if let Some(end) = state.player.play_next_end { + let removed_before_end = unique.iter().filter(|index| **index < end).count(); + state.player.play_next_end = Some(end.saturating_sub(removed_before_end)); + } let was_loaded = state.player.playing; let was_paused = state.player.paused; @@ -1107,6 +1190,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval state.player.track_started_at = None; state.player.listen_id = None; state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1); + normalize_play_next_block(&mut state.player); return QueueRemovalOutcome { restart_paused: was_loaded.then_some(was_paused), stop: false, @@ -1134,6 +1218,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval .cloned() }); state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1); + normalize_play_next_block(&mut state.player); QueueRemovalOutcome { restart_paused: None, stop: false, @@ -1267,7 +1352,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option { if !tracks.is_empty() { let count = tracks.len(); let title = tracks[0].title.clone(); - enqueue_tracks(state, tracks, next); + let restart_current = enqueue_tracks(state, tracks, next); state.track_selection.clear(); state.status_message = Some(if count == 1 && next { format!("queued next: {title}") @@ -1278,7 +1363,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option { } else { format!("queued: {count} tracks") }); - return Some(Effect::PlaybackQueueChanged); + return Some(Effect::QueueOrderChanged { restart_current }); } if let Some(id) = selected_release_id(state) { return Some(Effect::EnqueueRelease { id, next }); @@ -1372,19 +1457,22 @@ pub(crate) fn track_artist_refs(track: &TrackItem) -> Vec, next: bool) { +/// Insert tracks into the stable "play next" block (`next`) or at the end. +/// Returns whether a decoded gapless-next source became stale. +pub fn enqueue_tracks(state: &mut AppState, tracks: Vec, next: bool) -> bool { let tracks: Vec<_> = tracks .into_iter() .filter(|track| track_allowed_by_source_mode(state, track)) .collect(); let player = &mut state.player; if tracks.is_empty() { - return; + return false; } let insert_at = if next && !player.queue.is_empty() { - (player.queue_pos + 1).min(player.queue.len()) + player + .play_next_end + .filter(|end| *end >= player.queue_pos.saturating_add(1) && *end <= player.queue.len()) + .unwrap_or_else(|| (player.queue_pos + 1).min(player.queue.len())) } else if next { 0 } else { @@ -1394,14 +1482,28 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec, next: bool) for (offset, track) in tracks.into_iter().enumerate() { player.queue.insert(insert_at + offset, track); } - if let Some(prefetched) = &mut player.prefetched_pos - && insert_at <= *prefetched - { - *prefetched += count; + let restart_current = player + .prefetched_pos + .is_some_and(|prefetched| insert_at <= prefetched); + if restart_current { + player.prefetched_pos = None; } if insert_at <= player.queue_pos && player.current.is_some() { player.queue_pos += count; } + if next { + player.play_next_end = Some(insert_at + count); + } + restart_current +} + +pub(super) fn normalize_play_next_block(player: &mut super::state::PlayerBar) { + if player + .play_next_end + .is_some_and(|end| end <= player.queue_pos || end > player.queue.len()) + { + player.play_next_end = None; + } } /// Manual queue navigation (n / p); the tail is pre-shuffled when shuffle @@ -1426,6 +1528,7 @@ fn queue_step(state: &mut AppState, direction: isize) -> Option { } else { player.queue_pos = next as usize; } + normalize_play_next_block(player); Some(Effect::PlayCurrent) } @@ -1464,9 +1567,11 @@ pub fn advance_after_finish(state: &mut AppState) -> Option { repeat => { if player.queue_pos + 1 < player.queue.len() { player.queue_pos += 1; + normalize_play_next_block(player); Some(Effect::PlayCurrent) } else if repeat == super::state::RepeatMode::All { player.queue_pos = 0; + player.play_next_end = None; Some(Effect::PlayCurrent) } else { player.playing = false; @@ -2076,6 +2181,7 @@ fn select_current(state: &mut AppState) -> Option { return None; } state.player.queue_pos = state.queue_tab.cursor.min(state.player.queue.len() - 1); + normalize_play_next_block(&mut state.player); return Some(Effect::PlayCurrent); } if state.active_tab != Tab::Global { @@ -2616,6 +2722,19 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve fn federation_select(state: &mut AppState) -> Option { use super::state::{FedInputField, FedRow, Popup, SettingsRow}; match settings_rows(state).get(state.settings_cursor).copied()? { + SettingsRow::MusicDirectory => { + if state.music_dir_changing { + state.status_message = Some("music directory change is already running".into()); + return None; + } + state.popup = Some(Popup::FedInput { + field: FedInputField::MusicDirectory, + input: crate::app::input::LineEdit::new( + state.music_dir.to_string_lossy().into_owned(), + ), + }); + None + } SettingsRow::Federation(FedRow::Toggle) => { let settings = &mut state.federation.settings; if !settings.enabled && settings.network_id.trim().is_empty() { @@ -2789,6 +2908,7 @@ fn require_connected_devices_enabled(state: &mut AppState) -> bool { pub(super) fn on_new_queue(state: &mut AppState) { let player = &mut state.player; player.original_order = None; + player.play_next_end = None; if player.shuffle && !player.queue.is_empty() { player.original_order = Some(player.queue.iter().map(track_key).collect()); shuffle_range(player, (player.queue_pos + 1).min(player.queue.len())); diff --git a/src/app/update_tests.rs b/src/app/update_tests.rs index 06d2f68..5207e4c 100644 --- a/src/app/update_tests.rs +++ b/src/app/update_tests.rs @@ -457,6 +457,7 @@ fn local_mode_hides_pending_federation_tracks_from_playlists_and_playback() { active_tab: Tab::Playlists, ..AppState::default() }; + state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local; state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 }); state.playlist_views.insert( 7, @@ -512,6 +513,7 @@ fn network_modes_show_pending_federation_playlist_tracks() { #[test] fn local_mode_rejects_async_federation_queue_additions() { let mut state = AppState::default(); + state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local; enqueue_tracks( &mut state, @@ -732,13 +734,78 @@ fn artist_top_track_selection_queues_all_selected_tracks() { update(&mut state, Action::MoveDown); assert_eq!( update(&mut state, Action::QueueAddLast), - Some(Effect::PlaybackQueueChanged), + Some(Effect::QueueOrderChanged { + restart_current: false, + }), ); let queued: Vec = state.player.queue.iter().map(|track| track.id).collect(); assert_eq!(queued, vec![1, 2]); assert!(!state.track_selection.is_active()); } +#[test] +fn sequential_queue_next_additions_keep_their_order_as_one_block() { + let mut state = AppState::default(); + state.player.queue = (10..=13).map(test_track).collect(); + state.player.queue_pos = 0; + state.player.current = Some(test_track(10)); + + assert!(!enqueue_tracks(&mut state, vec![test_track(1)], true)); + assert!(!enqueue_tracks(&mut state, vec![test_track(2)], true)); + assert!(!enqueue_tracks(&mut state, vec![test_track(3)], true)); + + assert_eq!( + state + .player + .queue + .iter() + .map(|track| track.id) + .collect::>(), + vec![10, 1, 2, 3, 11, 12, 13] + ); + assert_eq!(state.player.play_next_end, Some(4)); +} + +#[test] +fn queue_selection_moves_as_a_group_and_preserves_current_track() { + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = (1..=5).map(test_track).collect(); + state.player.queue_pos = 0; + state.player.current = Some(test_track(1)); + state.queue_tab.cursor = 2; + state.track_selection.start(TrackSelectionScope::Queue, 2); + state + .track_selection + .set_cursor(TrackSelectionScope::Queue, 3); + + assert_eq!( + update(&mut state, Action::MoveQueueUp), + Some(Effect::QueueOrderChanged { + restart_current: false, + }) + ); + assert_eq!( + state + .player + .queue + .iter() + .map(|track| track.id) + .collect::>(), + vec![1, 3, 4, 2, 5] + ); + assert_eq!(state.player.queue_pos, 0); + assert_eq!(state.queue_tab.cursor, 1); + assert_eq!( + state + .track_selection + .indices(&TrackSelectionScope::Queue, 5), + Some(vec![1, 2]) + ); +} + #[test] fn removing_current_queue_track_requests_paused_restart() { let mut state = AppState { diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml index fcba394..06d62dd 100644 --- a/src/config/default_keymap.toml +++ b/src/config/default_keymap.toml @@ -63,6 +63,16 @@ command = "QueueAddNext" key_sequence = "shift-a" command = "QueueAddLast" +[[keymaps]] +key_sequence = "alt-k" +command = "MoveQueueUp" +context = "queue" + +[[keymaps]] +key_sequence = "alt-j" +command = "MoveQueueDown" +context = "queue" + [[keymaps]] key_sequence = "shift-c" command = "OpenConnectedDevices" diff --git a/src/config/settings.rs b/src/config/settings.rs index c3f16dc..8670ab7 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,5 +1,6 @@ use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -50,6 +51,9 @@ pub struct AppSettings { pub volume: u8, #[serde(default)] pub library: LibraryFilters, + /// Root used for music materialized from federation peers. + #[serde(default = "default_music_dir")] + pub music_dir: PathBuf, } impl Default for AppSettings { @@ -57,6 +61,7 @@ impl Default for AppSettings { Self { volume: default_volume(), library: LibraryFilters::default(), + music_dir: default_music_dir(), } } } @@ -64,6 +69,9 @@ impl Default for AppSettings { impl AppSettings { pub fn normalized(mut self) -> Self { self.volume = self.volume.min(100); + if self.music_dir.as_os_str().is_empty() { + self.music_dir = default_music_dir(); + } self } } @@ -72,6 +80,14 @@ fn default_volume() -> u8 { 80 } +/// The historical permanent-download location, kept as the default for +/// backward compatibility with existing installations. +pub fn default_music_dir() -> PathBuf { + crate::config::project_dirs() + .map(|dirs| dirs.data_dir().join("federation-media")) + .unwrap_or_else(|| PathBuf::from("federation-media")) +} + pub fn load() -> (AppSettings, Option) { let Some(path) = settings_path() else { return (AppSettings::default(), None); @@ -129,5 +145,6 @@ hide_featured_only = true assert_eq!(settings.volume, 100); assert!(settings.library.hide_featured_only); assert_eq!(settings.library.source_mode, LibrarySourceMode::Global); + assert_eq!(settings.music_dir, default_music_dir()); } } diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 6e44335..ed13584 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -422,7 +422,9 @@ pub struct Federation { jam: Arc, data_dir: PathBuf, cache_dir: PathBuf, - media_dir: PathBuf, + media_dir: std::sync::Mutex, + /// Serializes permanent downloads/card writes with a directory change. + media_change: tokio::sync::Mutex<()>, metadata_cache: std::sync::Mutex>, settings: std::sync::Mutex, running: tokio::sync::Mutex>, @@ -524,6 +526,7 @@ impl Federation { library: Arc, devices: Arc, jam: Arc, + media_dir: PathBuf, ) -> Arc { let dirs = crate::config::project_dirs(); let data_dir = dirs @@ -534,10 +537,6 @@ impl Federation { .as_ref() .map(|d| d.cache_dir().join("fedcache")) .unwrap_or_else(|| PathBuf::from("fedcache")); - let media_dir = dirs - .as_ref() - .map(|d| d.data_dir().join("federation-media")) - .unwrap_or_else(|| PathBuf::from("federation-media")); let initial_error = [&data_dir, &cache_dir, &media_dir] .into_iter() .find_map(|dir| { @@ -551,7 +550,8 @@ impl Federation { jam, data_dir, cache_dir, - media_dir, + media_dir: std::sync::Mutex::new(media_dir), + media_change: tokio::sync::Mutex::new(()), metadata_cache: std::sync::Mutex::new(Default::default()), settings: std::sync::Mutex::new(load_settings()), running: tokio::sync::Mutex::new(None), @@ -566,6 +566,40 @@ impl Federation { lock(&self.settings).clone() } + pub fn media_dir(&self) -> PathBuf { + lock(&self.media_dir).clone() + } + + /// Switches the permanent-download root while excluding concurrent + /// downloads. Validation runs again here because permissions may have + /// changed after the confirmation popup was shown. + pub async fn change_media_dir( + self: &Arc, + requested: PathBuf, + move_existing: bool, + ) -> Result { + let _guard = self.media_change.lock().await; + let old = self.media_dir(); + let library = Arc::clone(&self.library); + let requested_for_task = requested.clone(); + let (path, stats) = tokio::task::spawn_blocking(move || -> Result<_> { + let path = Library::validate_music_directory(&requested_for_task)?; + if path == std::fs::canonicalize(&old).unwrap_or(old.clone()) { + return Ok((path, crate::library::MusicRelocationStats::default())); + } + let stats = if move_existing && old.exists() { + library.relocate_managed_music(&old, &path)? + } else { + crate::library::MusicRelocationStats::default() + }; + Ok((path, stats)) + }) + .await + .context("music directory task failed")??; + *lock(&self.media_dir) = path; + Ok(stats) + } + pub async fn create_jam(&self) -> Result { let service = { let running = self.running.lock().await; @@ -1403,11 +1437,6 @@ impl Federation { Ok(peer.to_string()) } - /// Directory for streamed (never library-imported) card artwork. - fn art_cache_dir(&self) -> PathBuf { - self.cache_dir.join("art") - } - /// Returns a cached-or-streamed image for the card: the artist image /// (`release: None`) or a release cover. Peers are tried in order until /// one answers with an image; the result lands in the art cache and its @@ -1418,14 +1447,11 @@ impl Federation { artist: &str, release: Option<&str>, ) -> Option { - let dir = self.art_cache_dir(); + let _guard = self.media_change.lock().await; + let dir = music_art_dir(&self.media_dir(), artist, release); let stem = match release { - Some(release) => format!( - "cover-{}-{}", - sanitize_file_stem(artist), - sanitize_file_stem(release) - ), - None => format!("artist-{}", sanitize_file_stem(artist)), + Some(_) => "cover".to_string(), + None => "artist".to_string(), }; // Reuse a previously streamed copy of any known image type. for extension in ["jpg", "png", "webp", "gif", "bmp"] { @@ -1489,7 +1515,7 @@ impl Federation { F: FnMut(DownloadProgress) + Send, { let save = self.settings().save_on_listen; - self.fetch_playable_with_progress(fed, save, false, progress, None) + self.fetch_playable_with_progress(fed, save, save, progress, None) .await } @@ -1504,7 +1530,7 @@ impl Federation { S: FnMut(StreamingStart) + Send, { let save = self.settings().save_on_listen; - self.fetch_playable_with_progress(fed, save, false, progress, Some(&mut stream_start)) + self.fetch_playable_with_progress(fed, save, save, progress, Some(&mut stream_start)) .await } @@ -1558,6 +1584,11 @@ impl Federation { where F: FnMut(DownloadProgress) + Send, { + let _media_guard = if save { + Some(self.media_change.lock().await) + } else { + None + }; let mut fed = fed.clone(); let mut tried_content_lookup = false; @@ -1636,8 +1667,10 @@ impl Federation { } Err(_) => anyhow::bail!("malformed owner id '{}'", fed.owner), }; + let media_dir; let dir = if save { - &self.media_dir + media_dir = music_release_dir(&self.media_dir(), &fed); + &media_dir } else { &self.cache_dir }; @@ -1698,8 +1731,14 @@ impl Federation { } // Same for the cover: the peer's library cover wins over an // embedded picture; embedded art stays as the fallback. - if import_cover.is_some() { - import.cover = import_cover; + if let Some((bytes, extension)) = &import_cover { + match save_release_cover(&import_path, bytes, extension) { + Ok(()) => import.cover = None, + Err(err) => { + tracing::warn!(%err, "saving the release cover beside its music failed"); + import.cover = import_cover; + } + } } let (track_id, _) = crate::library::import::upsert_track(&library, &import)?; // A like that referenced the federated track moves onto the @@ -1711,7 +1750,13 @@ impl Federation { // created (or still image-less) main artist. if let (Some((bytes, extension)), Some(artist_name)) = (&artist_image, import.artists.first()) - && let Err(err) = save_artist_image(&library, artist_name, bytes, extension) + && let Err(err) = save_artist_image( + &library, + artist_name, + import_path.parent().and_then(Path::parent), + bytes, + extension, + ) { tracing::warn!(%err, "saving the artist image failed"); } @@ -1943,15 +1988,13 @@ impl Federation { fn save_artist_image( library: &Library, artist_name: &str, + artist_dir: Option<&Path>, bytes: &[u8], extension: &str, ) -> Result<()> { - let covers_dir = library.covers_dir(); - std::fs::create_dir_all(covers_dir)?; - let path = covers_dir.join(format!( - "artist-{}.{extension}", - sanitize_file_stem(artist_name) - )); + let artist_dir = artist_dir.context("downloaded track has no artist directory")?; + std::fs::create_dir_all(artist_dir)?; + let path = artist_dir.join(format!("artist.{extension}")); // Write only if the artist actually lacks an image, to avoid litter. if library.artist_image_missing(artist_name)? { std::fs::write(&path, bytes)?; @@ -1960,6 +2003,16 @@ fn save_artist_image( Ok(()) } +fn save_release_cover(audio_path: &Path, bytes: &[u8], extension: &str) -> Result<()> { + let directory = audio_path + .parent() + .context("downloaded track has no release directory")?; + std::fs::create_dir_all(directory)?; + let path = directory.join(format!("cover.{extension}")); + std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + /// Overlays the peer-supplied metadata onto tag-derived import data. Every /// non-empty peer field wins; file tags only fill the gaps. fn apply_remote_metadata(import: &mut crate::library::import::TrackImport, meta: &TrackMetadata) { @@ -2563,6 +2616,37 @@ fn sanitize_file_stem(value: &str) -> String { stem } +fn music_artist_dir(root: &Path, artist: &str) -> PathBuf { + let artist = if artist.trim().is_empty() { + "Unknown Artist" + } else { + artist + }; + root.join(sanitize_file_stem(artist)) +} + +fn music_art_dir(root: &Path, artist: &str, release: Option<&str>) -> PathBuf { + let artist_dir = music_artist_dir(root, artist); + match release.filter(|release| !release.trim().is_empty()) { + Some(release) => artist_dir.join(sanitize_file_stem(release)), + None => artist_dir, + } +} + +fn music_release_dir(root: &Path, fed: &FedTrack) -> PathBuf { + let artist = fed + .artist_names + .first() + .map(String::as_str) + .unwrap_or("Unknown Artist"); + let release = fed + .release_title + .as_deref() + .filter(|release| !release.trim().is_empty()) + .unwrap_or("Unknown Release"); + music_art_dir(root, artist, Some(release)) +} + fn download_stem(fed: &FedTrack) -> String { let artists = fed.artist_line(); if artists.is_empty() { diff --git a/src/library/mod.rs b/src/library/mod.rs index c483e10..c506cbc 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -11,6 +11,7 @@ pub mod import; pub mod models; use std::collections::{HashMap, HashSet}; +use std::io::Write as _; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -288,6 +289,12 @@ pub struct LocalLibraryStats { pub database_bytes: u64, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct MusicRelocationStats { + pub tracks: usize, + pub images: usize, +} + impl LocalLibraryStats { pub fn total_bytes(&self) -> u64 { self.audio_bytes @@ -330,6 +337,258 @@ impl Library { &self.covers_dir } + /// Verifies that `path` can actually be used for durable downloads. The + /// returned path is absolute/canonical, so the persisted setting does not + /// later depend on Furumi's working directory. + pub fn validate_music_directory(path: &Path) -> Result { + anyhow::ensure!(!path.as_os_str().is_empty(), "music directory is empty"); + std::fs::create_dir_all(path) + .with_context(|| format!("creating music directory {}", path.display()))?; + let path = std::fs::canonicalize(path) + .with_context(|| format!("resolving music directory {}", path.display()))?; + anyhow::ensure!(path.is_dir(), "{} is not a directory", path.display()); + + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let probe = path.join(format!( + ".furumi-write-test-{}-{unique}", + std::process::id() + )); + let result = (|| -> Result<()> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe) + .with_context(|| format!("no write access to {}", path.display()))?; + file.write_all(b"furumi")?; + file.sync_all()?; + Ok(()) + })(); + let _ = std::fs::remove_file(&probe); + result?; + Ok(path) + } + + /// Copies Furumi-managed audio and its managed artwork into a portable + /// `Artist/Release` tree, atomically switches SQLite paths, then removes + /// the old copies. User-imported audio outside `old_root` is untouched. + pub fn relocate_managed_music( + &self, + old_root: &Path, + new_root: &Path, + ) -> Result { + let new_root = Self::validate_music_directory(new_root)?; + let old_root = std::fs::canonicalize(old_root) + .with_context(|| format!("resolving old music directory {}", old_root.display()))?; + anyhow::ensure!( + old_root != new_root, + "the new music directory is the current directory" + ); + anyhow::ensure!( + !old_root.starts_with(&new_root) && !new_root.starts_with(&old_root), + "choose a directory outside the current music directory" + ); + + #[derive(Debug)] + struct Row { + track_id: i64, + track_path: String, + release_id: i64, + release_title: String, + cover_path: Option, + artist_id: Option, + artist_name: String, + image_path: Option, + } + + let rows = { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT t.id, t.file_path, r.id, r.title, r.cover_path, + a.id, COALESCE(a.name, 'Unknown Artist'), a.image_path + FROM tracks t + JOIN releases r ON r.id = t.release_id + LEFT JOIN track_artists ta + ON ta.track_id = t.id AND ta.role = 'main' AND ta.position = 0 + LEFT JOIN artists a ON a.id = ta.artist_id + ORDER BY t.id", + )?; + statement + .query_map([], |row| { + Ok(Row { + track_id: row.get(0)?, + track_path: row.get(1)?, + release_id: row.get(2)?, + release_title: row.get(3)?, + cover_path: row.get(4)?, + artist_id: row.get(5)?, + artist_name: row.get(6)?, + image_path: row.get(7)?, + }) + })? + .collect::>>()? + }; + + #[derive(Debug, Clone)] + struct CopyOp { + source: PathBuf, + destination: PathBuf, + } + let managed_art = + |path: &Path| path.starts_with(&old_root) || path.starts_with(&self.covers_dir); + let mut reserved = HashSet::::new(); + let mut copies = Vec::::new(); + let mut track_updates = Vec::<(i64, String, String)>::new(); + let mut release_updates = HashMap::::new(); + let mut artist_updates = HashMap::::new(); + + for row in rows { + let source = PathBuf::from(&row.track_path); + if !source.is_file() || !source.starts_with(&old_root) { + continue; + } + let artist_dir = new_root.join(storage_name(&row.artist_name, "Unknown Artist")); + let release_dir = artist_dir.join(storage_name(&row.release_title, "Unknown Release")); + let filename = source + .file_name() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| std::ffi::OsStr::new("track")); + let destination = + unique_destination(release_dir.join(filename), row.track_id, &mut reserved); + copies.push(CopyOp { + source: source.clone(), + destination: destination.clone(), + }); + track_updates.push(( + row.track_id, + row.track_path, + destination.to_string_lossy().into_owned(), + )); + + if let Some(cover) = row.cover_path { + let cover_source = PathBuf::from(&cover); + if cover_source.is_file() + && managed_art(&cover_source) + && !release_updates.contains_key(&row.release_id) + { + let extension = cover_source.extension().unwrap_or_default(); + let mut destination = release_dir.join("cover"); + destination.set_extension(extension); + let destination = + unique_destination(destination, row.release_id, &mut reserved); + copies.push(CopyOp { + source: cover_source, + destination: destination.clone(), + }); + release_updates.insert( + row.release_id, + (cover, destination.to_string_lossy().into_owned()), + ); + } + } + + if let (Some(artist_id), Some(image)) = (row.artist_id, row.image_path) { + let image_source = PathBuf::from(&image); + if image_source.is_file() + && managed_art(&image_source) + && !artist_updates.contains_key(&artist_id) + { + let extension = image_source.extension().unwrap_or_default(); + let mut destination = artist_dir.join("artist"); + destination.set_extension(extension); + let destination = unique_destination(destination, artist_id, &mut reserved); + copies.push(CopyOp { + source: image_source, + destination: destination.clone(), + }); + artist_updates.insert( + artist_id, + (image, destination.to_string_lossy().into_owned()), + ); + } + } + } + + let mut created = Vec::::new(); + let copy_result = (|| -> Result<()> { + for op in &copies { + let parent = op + .destination + .parent() + .context("music destination has no parent")?; + std::fs::create_dir_all(parent)?; + copy_file_exclusive(&op.source, &op.destination)?; + created.push(op.destination.clone()); + } + Ok(()) + })(); + if let Err(error) = copy_result { + for path in created.iter().rev() { + let _ = std::fs::remove_file(path); + } + return Err(error.context("copying the existing music library")); + } + + let database_result = (|| -> Result<()> { + let mut conn = self.lock(); + let transaction = conn.transaction()?; + for (id, old, new) in &track_updates { + anyhow::ensure!( + transaction.execute( + "UPDATE tracks SET file_path = ?3 WHERE id = ?1 AND file_path = ?2", + params![id, old, new], + )? == 1, + "track {id} changed while the music library was moving" + ); + } + for (id, (old, new)) in &release_updates { + anyhow::ensure!( + transaction.execute( + "UPDATE releases SET cover_path = ?3 WHERE id = ?1 AND cover_path = ?2", + params![id, old, new], + )? == 1, + "release {id} changed while the music library was moving" + ); + } + for (id, (old, new)) in &artist_updates { + anyhow::ensure!( + transaction.execute( + "UPDATE artists SET image_path = ?3 WHERE id = ?1 AND image_path = ?2", + params![id, old, new], + )? == 1, + "artist {id} changed while the music library was moving" + ); + } + transaction.commit()?; + Ok(()) + })(); + if let Err(error) = database_result { + for path in created.iter().rev() { + let _ = std::fs::remove_file(path); + } + return Err(error.context("updating music paths in the library")); + } + + let mut removed = HashSet::new(); + for op in &copies { + if removed.insert(op.source.clone()) + && let Err(error) = std::fs::remove_file(&op.source) + { + // The committed destination is authoritative. A failed old + // delete only leaves a recoverable duplicate. + tracing::warn!(path = %op.source.display(), %error, "old music copy was not removed"); + } + } + remove_empty_directories(&old_root); + + Ok(MusicRelocationStats { + tracks: track_updates.len(), + images: release_updates.len() + artist_updates.len(), + }) + } + pub fn local_stats(&self) -> Result { let (artist_count, release_count, track_count, audio_bytes, tracks_without_size) = { let conn = self.lock(); @@ -2498,6 +2757,84 @@ impl Library { } } +fn storage_name(value: &str, fallback: &str) -> String { + let cleaned: String = value + .chars() + .map(|character| match character { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + character if character.is_control() => '_', + character => character, + }) + .collect(); + let cleaned = cleaned.trim().trim_matches('.'); + let shortened: String = cleaned.chars().take(120).collect(); + if shortened.is_empty() { + fallback.to_string() + } else { + shortened + } +} + +fn unique_destination(requested: PathBuf, id: i64, reserved: &mut HashSet) -> PathBuf { + if !requested.exists() && reserved.insert(requested.clone()) { + return requested; + } + let stem = requested + .file_stem() + .unwrap_or_else(|| std::ffi::OsStr::new("file")) + .to_string_lossy(); + let extension = requested.extension().map(|value| value.to_os_string()); + let mut candidate = requested.with_file_name(format!("{stem}-{id}")); + if let Some(extension) = extension { + candidate.set_extension(extension); + } + let mut suffix = 2usize; + while candidate.exists() || !reserved.insert(candidate.clone()) { + candidate = requested.with_file_name(format!("{stem}-{id}-{suffix}")); + if let Some(extension) = requested.extension() { + candidate.set_extension(extension); + } + suffix += 1; + } + candidate +} + +fn copy_file_exclusive(source: &Path, destination: &Path) -> Result<()> { + let mut input = + std::fs::File::open(source).with_context(|| format!("opening {}", source.display()))?; + let mut output = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination) + .with_context(|| format!("creating {}", destination.display()))?; + let result = std::io::copy(&mut input, &mut output) + .with_context(|| format!("copying {}", source.display())) + .and_then(|_| { + output + .sync_all() + .with_context(|| format!("syncing {}", destination.display())) + }); + if let Err(error) = result { + drop(output); + let _ = std::fs::remove_file(destination); + return Err(error); + } + Ok(()) +} + +fn remove_empty_directories(root: &Path) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + remove_empty_directories(&path); + } + } + let _ = std::fs::remove_dir(root); +} + fn cleanup_empty_releases(tx: &rusqlite::Transaction) -> rusqlite::Result<()> { tx.execute( "DELETE FROM releases WHERE id NOT IN (SELECT release_id FROM tracks)", diff --git a/src/library/tests.rs b/src/library/tests.rs index dd923de..573417d 100644 --- a/src/library/tests.rs +++ b/src/library/tests.rs @@ -60,6 +60,130 @@ fn artist_filters(hide_featured_only: bool) -> crate::config::settings::LibraryF } } +fn unique_test_dir(label: &str) -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("furumi-{label}-{}-{unique}", std::process::id())) +} + +#[test] +fn managed_music_relocation_builds_artist_release_tree_and_updates_paths() { + let root = unique_test_dir("music-relocation"); + let old = root.join("old"); + let new = root.join("new"); + let covers = root.join("covers"); + std::fs::create_dir_all(&old).unwrap(); + std::fs::create_dir_all(&covers).unwrap(); + let audio = old.join("legacy.flac"); + let cover = covers.join("release.jpg"); + let artist_image = covers.join("artist.png"); + std::fs::write(&audio, b"audio").unwrap(); + std::fs::write(&cover, b"cover").unwrap(); + std::fs::write(&artist_image, b"artist").unwrap(); + + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + register_norm_function(&conn).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + let library = Library { + conn: Mutex::new(conn), + db_path: root.join("library.db"), + covers_dir: covers, + }; + let track_id = import::upsert_track( + &library, + &import::TrackImport { + file_path: audio.to_string_lossy().into_owned(), + title: "Song".into(), + artists: vec!["Artist".into()], + featured_artists: vec![], + album_artists: vec!["Artist".into()], + release_title: "Release".into(), + release_type: Some("album".into()), + year: Some(2026), + track_number: Some(1), + disc_number: Some(1), + duration_seconds: 1.0, + audio_format: Some("flac".into()), + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: Some(5), + cover: None, + }, + ) + .unwrap() + .0; + let (release_id, artist_id): (i64, i64) = library + .lock() + .query_row( + "SELECT t.release_id, ta.artist_id + FROM tracks t JOIN track_artists ta ON ta.track_id = t.id + WHERE t.id = ?1 AND ta.role = 'main'", + [track_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + library + .lock() + .execute( + "UPDATE releases SET cover_path = ?2 WHERE id = ?1", + params![release_id, cover.to_string_lossy()], + ) + .unwrap(); + library + .lock() + .execute( + "UPDATE artists SET image_path = ?2 WHERE id = ?1", + params![artist_id, artist_image.to_string_lossy()], + ) + .unwrap(); + + let stats = library.relocate_managed_music(&old, &new).unwrap(); + assert_eq!(stats.tracks, 1); + assert_eq!(stats.images, 2); + let track = library.tracks_by_ids(&[track_id]).unwrap().remove(0); + assert_eq!( + track.file_path, + new.join("Artist/Release/legacy.flac").to_string_lossy() + ); + assert_eq!( + track.cover_path.as_deref(), + Some( + new.join("Artist/Release/cover.jpg") + .to_string_lossy() + .as_ref() + ) + ); + let image: String = library + .lock() + .query_row( + "SELECT image_path FROM artists WHERE id = ?1", + [artist_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(image, new.join("Artist/artist.png").to_string_lossy()); + assert!(!audio.exists()); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn music_directory_validation_rejects_a_file_without_touching_it() { + let root = unique_test_dir("music-validation"); + std::fs::create_dir_all(&root).unwrap(); + let file = root.join("not-a-directory"); + std::fs::write(&file, b"keep").unwrap(); + + assert!(Library::validate_music_directory(&file).is_err()); + assert_eq!(std::fs::read(&file).unwrap(), b"keep"); + + std::fs::remove_dir_all(root).unwrap(); +} + #[test] fn local_stats_counts_library_rows_and_audio_bytes() { let lib = test_library(); diff --git a/src/ui/federation.rs b/src/ui/federation.rs index 02239ae..fa574f8 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -69,6 +69,25 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { let mut y = area.y; let mut cursor = 0usize; + draw_section(frame, area, state, &mut y, "Library"); + draw_row( + frame, + area, + state, + &mut y, + cursor, + state.settings_cursor, + "Music save directory", + if state.music_dir_changing { + format!("{} moving…", state.spinner()) + } else { + state.music_dir.to_string_lossy().into_owned() + }, + ); + cursor += 1; + + y = y.saturating_add(1); + draw_section(frame, area, state, &mut y, "Federation"); for row in FedRow::ALL { let (label, value) = match row { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 731b604..3de9f71 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -258,7 +258,7 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { let player = &state.player; let block = Block::bordered() .title(format!( - " Queue — {} tracks · Mode: {} · enter: play · d: remove · shift-v: select · :clear ", + " Queue — {} tracks · Mode: {} · enter: play · alt-j/k: move · d: remove · shift-v: select · :clear ", player.queue.len(), state.global.filters.source_mode.label() )) diff --git a/src/ui/popup.rs b/src/ui/popup.rs index 6006114..e1dbc53 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -42,8 +42,15 @@ pub fn draw(frame: &mut Frame, state: &AppState) { draw_fed_input(frame, state, field.title(), field.help(), input) } Some(Popup::FedText { title, text }) => draw_fed_text(frame, state, title, text), - Some(Popup::FedCopyText { title, text, help }) => { - draw_fed_copy_text(frame, state, title, text, help) + Some(Popup::FedCopyText { + title, + text, + help, + cursor, + }) => draw_fed_copy_text(frame, state, title, text, help, *cursor), + Some(Popup::PlainText { .. }) => {} + Some(Popup::ConfirmMusicDirectory { path }) => { + draw_music_directory_confirmation(frame, state, path) } Some(Popup::FederationStatusDetails { focus, @@ -93,6 +100,34 @@ pub fn draw(frame: &mut Frame, state: &AppState) { } } +fn draw_music_directory_confirmation(frame: &mut Frame, state: &AppState, path: &std::path::Path) { + let area = centered(frame.area(), 76, 9); + let block = Block::bordered() + .title(" Move existing music? ") + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + frame.render_widget( + Paragraph::new(vec![ + Line::raw("The destination is writable:"), + Line::styled( + path.to_string_lossy().into_owned(), + theme::accent_for(state), + ), + Line::raw(""), + Line::raw("Move music and artwork previously saved by Furumi there?"), + Line::styled( + "enter/y move · n keep existing files where they are · esc cancel", + theme::dim(), + ), + ]) + .wrap(Wrap { trim: false }), + inner, + ); +} + fn draw_listen_history(frame: &mut Frame, state: &AppState, cursor: usize) { let area = centered( frame.area(), @@ -862,7 +897,14 @@ fn draw_fed_text(frame: &mut Frame, state: &AppState, title: &str, text: &str) { } /// Wrapped text with an explicit copy-and-close action. -fn draw_fed_copy_text(frame: &mut Frame, state: &AppState, title: &str, text: &str, help: &str) { +fn draw_fed_copy_text( + frame: &mut Frame, + state: &AppState, + title: &str, + text: &str, + help: &str, + cursor: usize, +) { let width = frame.area().width.saturating_sub(8).clamp(36, 96); let text_width = usize::from(width.saturating_sub(2)); let text_lines = (text.chars().count() / text_width.max(1) + 1) as u16; @@ -894,17 +936,28 @@ fn draw_fed_copy_text(frame: &mut Frame, state: &AppState, title: &str, text: &s Paragraph::new(text.to_string()).wrap(Wrap { trim: false }), body_area, ); + let button = |label: &str, selected: bool| { + if selected { + Span::styled(format!(" {label} "), theme::tab_active_for(state)) + } else { + Span::styled(format!(" {label} "), theme::dim()) + } + }; frame.render_widget( - Paragraph::new(Line::styled( - " Copy to clipboard and close ", - theme::tab_active_for(state), - )) + Paragraph::new(Line::from(vec![ + button("Copy to clipboard", cursor == 0), + Span::raw(" "), + button("Show as plain terminal line", cursor == 1), + ])) .alignment(Alignment::Center), button_area, ); frame.render_widget( - Paragraph::new(Line::styled("enter/c copy · esc close", theme::dim())) - .alignment(Alignment::Center), + Paragraph::new(Line::styled( + "left/right choose · enter apply · c copy · p plain line · esc close", + theme::dim(), + )) + .alignment(Alignment::Center), footer_area, ); }