From 9aefaa364e98fae4961ff079435b222768a1730c Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Sun, 26 Jul 2026 02:41:08 +0300 Subject: [PATCH] Updated readme --- AGENTS.md | 241 +++++++++++++ ARCHITECTURE.md | 56 +++ LICENSE | 13 + README.md | 4 +- src/app/update_tests.rs | 750 ++++++++++++++++++++++++++++++++++++++++ src/devices/tests.rs | 506 +++++++++++++++++++++++++++ src/federation/tests.rs | 95 +++++ src/library/tests.rs | 509 +++++++++++++++++++++++++++ 8 files changed, 2173 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 src/app/update_tests.rs create mode 100644 src/devices/tests.rs create mode 100644 src/federation/tests.rs create mode 100644 src/library/tests.rs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8950b6e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,241 @@ +# AGENTS.md + +This file describes how to work safely in the Furumi repository. It applies to +the entire project. + +## Project intent + +Furumi is a cross-platform, federated P2P player for personal music libraries. +Every node must remain a complete local player without network access. Other +nodes add discovery, availability, trusted-device synchronization, and direct +track transfer; they are never a prerequisite for using the local library. + +Read `ARCHITECTURE.md` before changing federation, device sync, playback +handoff, persistence, or runtime boundaries. + +Preserve these architectural invariants: + +1. Local import, browsing, playback, playlists, likes, and history work + offline. +2. No central Furumi service becomes required for discovery, playback, or + synchronization. +3. Federation membership does not imply trusted-device membership. +4. Remote state is merged, replicated, or cached locally; it is not treated as + an always-available database. +5. Network, database, filesystem, image, and audio preparation work stays out + of the interactive UI path. +6. Losing peers may reduce remote availability but must not invalidate local + state. + +## Toolchain and checks + +The crate uses Rust edition 2024 and Rust 1.88 or newer. + +Run the checks relevant to every code change: + +```bash +cargo fmt --all -- --check +cargo check --all-targets +cargo test --all-targets +cargo clippy --all-targets +``` + +`cargo clippy --all-targets` currently reports known warnings. Do not hide +them with broad `allow` attributes. Do not claim `-D warnings` passes unless +the existing warning set has actually been resolved. + +Use `cargo fmt --all` after editing Rust. Keep `Cargo.lock` committed and +update it only when dependencies change. + +## Source boundaries + +- `src/main.rs` owns process startup, terminal restoration, Tokio setup, and + platform media-loop integration. +- `src/app/state.rs` owns UI/navigation state. +- `src/app/action.rs` and `src/app/event.rs` define input intent and runtime + results. +- `src/app/update.rs` performs state transitions and requests effects. It must + not perform blocking I/O. +- `src/app/mod.rs` owns runtime orchestration and effect execution. +- `src/library/` owns SQLite library queries, models, imports, and migrations. +- `src/player/` owns rodio playback and audio analysis. +- `src/federation/` owns DHT discovery, peer catalogs, audio exchange, and + federation caches. +- `src/devices.rs` owns trusted-device replication, membership, and playback + coordination. +- `src/ui/` renders `AppState`; rendering code must not start background work + or mutate persistence. +- `src/media.rs` owns OS media controls. +- `src/visualizer.rs` and `src/visualizations/` own the Rhai visualization + host and bundled scripts. + +Prefer adding a focused module when a responsibility has a clear boundary. +Do not split files solely to reduce line count if doing so introduces leaky +APIs or circular ownership. + +Keep large test suites in their existing adjacent files: + +- `src/app/update_tests.rs` +- `src/devices/tests.rs` +- `src/federation/tests.rs` +- `src/library/tests.rs` + +## Application state and concurrency + +`AppState` is the single source of truth for the TUI. The normal flow is: + +```text +external event -> AppEvent -> Action/update -> Effect -> runtime -> AppEvent +``` + +Follow these rules: + +- Keep update logic deterministic wherever practical. +- Return an `Effect` for work that requires runtime services. +- Report asynchronous results through `AppEvent`. +- Use `tokio::task::spawn_blocking` for blocking SQLite, hashing, tag parsing, + or filesystem-heavy work invoked from async code. +- Do not hold a mutex guard across `.await`. +- Do not call terminal rendering APIs from background tasks. +- Preserve event sequence or request identifiers where they prevent stale + search, download, or synchronization results from overwriting newer state. + +## Library and SQLite + +`library::Library` is the authority for local catalog persistence. Keep SQL in +the library/device persistence layers rather than spreading it through the UI +or runtime. + +When changing schema or stored data: + +- provide an upgrade path for existing databases; +- make migrations repeatable and safe on partially upgraded databases; +- preserve foreign-key and uniqueness invariants; +- distinguish durable user data from replaceable federation/cache data; +- test both the new behavior and migration-sensitive queries; +- never solve a schema problem by deleting or recreating a user's database. + +Content identifiers are the bridge between independent libraries. Avoid +assuming that local numeric row IDs identify the same track on another node. + +## Federation and wire protocols + +Discovery and transfer are separate concerns: + +- the DHT provides distributed discovery; +- peer catalog streams provide richer metadata; +- audio streams transfer content; +- the device-sync protocol replicates trusted personal state. + +Treat protocol and serialized-data changes as compatibility changes: + +- preserve existing ALPN values unless intentionally introducing a new + protocol version; +- retain backward-compatible `serde` defaults for fields added to wire types; +- tolerate peers with older or partial metadata; +- bound incoming messages and validate untrusted lengths/identifiers; +- keep catalog/federation authority separate from trusted-device authority; +- assume peers can disappear between discovery and transfer; +- keep retries and fallback sources idempotent. + +Do not introduce a required coordinator, registry, account server, or canonical +network database. + +## Trusted-device synchronization + +Device sync is offline-first. Operations may arrive late, more than once, or +in a different order. + +When changing it: + +- preserve operation-ID deduplication; +- use the existing hybrid logical time ordering rules consistently; +- ensure deletes survive offline replicas through tombstones; +- compact tombstones only after the required acknowledgements; +- keep snapshots able to repair peers that missed older operations; +- treat membership and revocation as replicated durable state; +- keep playback commands targeted and deduplicated; +- test merge behavior from at least two operation orders. + +Do not replace eventual reconciliation with assumptions about a continuously +connected leader. + +## Playback and remote resolution + +The application owns the logical queue; `player::Controller` owns physical +audio playback. + +Remote and local tracks should continue through the same queue and UI model. +When a track is unavailable locally, resolve it asynchronously and replace or +materialize the pending entry without blocking the event loop. + +Preserve: + +- queue position and prefetch indexes when inserting or removing tracks; +- shuffle/repeat behavior; +- pause and seek state during device handoff; +- fallback to another advertised source when a peer disappears; +- the distinction between cached audio and content imported into the durable + library. + +## UI and key bindings + +The TUI is keyboard-first and cross-platform. + +- Keep rendering pure over `&AppState`. +- Use semantic `Action` values rather than checking raw keys inside views. +- Add default bindings in `src/config/default_keymap.toml`. +- Preserve user overrides and context-specific bindings. +- Account for narrow terminals, Unicode display width, and empty/loading/error + states. +- Restore raw mode, alternate screen, bracketed paste, and keyboard + enhancements on exit and panic. + +Do not print to stdout/stderr while the alternate-screen UI is active; use +`tracing` and visible application status instead. + +## Cross-platform work + +Furumi supports Linux, macOS, and Windows. + +- Keep OS-specific code behind narrow `cfg` boundaries. +- Do not introduce shell-only behavior into portable paths. +- Use platform data/config/cache directories through the existing config + helpers. +- Consider path encoding, separators, and non-UTF-8 filesystem values. +- Changes to media controls, terminal setup, clipboard behavior, or audio + devices require explicit review of all three platforms. + +If only one target can be exercised locally, state which platform-specific +paths remain unverified. + +## Tests + +Add tests at the closest stable boundary: + +- pure state transitions in `app/update_tests.rs`; +- SQLite behavior and migrations in `library/tests.rs`; +- operation merge, tombstone, membership, and playback sync behavior in + `devices/tests.rs`; +- search/ranking/federation conversion in `federation/tests.rs`; +- protocol-specific tests beside `federation/audio.rs` or + `federation/catalog.rs` when appropriate. + +Prefer in-memory SQLite databases and deterministic fixtures. Temporary files +must use unique names and must not depend on a developer's music library, +configuration, home directory, network peers, or audio hardware. + +Do not remove a test merely because a refactor makes it inconvenient. Update +it to assert the preserved behavior. + +## Documentation and releases + +Keep public documentation aligned with the decentralized product model. +README content should explain user value and setup; `ARCHITECTURE.md` should +explain architectural decisions and invariants rather than restating source +code. + +Release archives are produced by `.github/workflows/release.yml` and must +include the binary, `README.md`, and `LICENSE`. + +The project is licensed under WTFPL version 2. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e3988c2..6d835ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -212,6 +212,62 @@ The audio engine is similarly isolated. The application owns the logical queue, while `player::Controller` owns rodio playback and receives explicit commands. Prefetching prepares the next source before the current item ends. +## Scripted visualizations + +Visualizations are an extension boundary rather than hard-coded rendering +paths. Rust owns audio sampling, script execution, validation, and terminal +drawing; Rhai scripts own the visual composition. + +```text +rodio source + │ + v +audio analyzer ──> normalized features + scope samples + │ + v + Rhai render(input) + │ + v + validated draw commands + │ + v + ratatui frame buffer +``` + +The player analyzer derives a bounded, renderer-independent input model: +energy, bass, mid, treble, beat strength, waveform samples, playback progress, +volume, pause state, track metadata, time, and terminal dimensions. Each +script implements `render(input)` and returns declarative commands such as +clear, cell, line, rectangle, trace, and text. Scripts never receive the +ratatui frame or audio engine directly. + +This command boundary is intentional: + +- scripts remain independent of Rust UI internals; +- the host validates command shapes, colors, coordinates, and arrays; +- drawing is clipped to the current terminal area; +- script failures become an in-UI visualizer error instead of corrupting the + terminal or stopping playback. + +Rhai files live in the user's visualization directory. The runtime discovers +them dynamically, compiles the selected script, caches its AST, and recompiles +it when the file modification time changes. A visualization can therefore be +created or edited while Furumi is running without rebuilding or restarting the +application. Bundled scripts use the same path and contract as user scripts, +so built-in and custom visualizations exercise the same runtime. + +The Rhai engine is configured as a sandboxed computation environment. Module +loading through `import` and `export` is disabled, no filesystem or network API +is exposed to scripts, and execution is bounded by limits on operations, call +depth, variables, functions, expression depth, and collection/string sizes. +Only the input map, Rhai language primitives, and a small set of mathematical +helpers are available. + +The sandbox protects responsiveness and keeps visualization code in its +intended role: transforming current audio features into drawing commands. It +is not a plugin mechanism for accessing the library, network, or player +controls. + ## Persistence boundaries Furumi stores different kinds of state according to their lifetime: diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c93f45 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/README.md b/README.md index 5d375c7..2a48b29 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ inspecting connected devices, and configuring federation. The interface supports keyboard-driven navigation, multi-key combinations, context-aware bindings, and user-defined rebinding through TOML. Built-in audio visualizations, OS media controls, gapless queue playback, and local -library management are included. +library management are included. Visualizations are runtime-loadable Rhai +scripts executed in a resource-limited sandbox, so they can be added or edited +without rebuilding the player. ## Install diff --git a/src/app/update_tests.rs b/src/app/update_tests.rs new file mode 100644 index 0000000..9d00a14 --- /dev/null +++ b/src/app/update_tests.rs @@ -0,0 +1,750 @@ +use super::*; +use crate::library::models::{ArtistCard, ArtistDetail, TrackItem}; + +fn with_artists(n: usize) -> AppState { + let mut state = AppState::default(); + state.global.artists = (0..n) + .map(|i| ArtistCard { + id: i as i64, + name: format!("artist {i}"), + image_path: None, + release_count: 1, + track_count: 2, + availability: crate::library::models::Availability::Local, + }) + .collect(); + state +} + +fn test_track(id: i64) -> TrackItem { + TrackItem { + id, + title: format!("t{id}"), + track_number: None, + disc_number: None, + duration_seconds: 1.0, + artists: vec![], + featured_artists: vec![], + release_id: 1, + release_title: "r".into(), + release_year: None, + cover_path: None, + file_path: format!("/s/{id}"), + content_id: Some(format!("b3:{id:064x}")), + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: None, + play_count: 0, + fed: None, + } +} + +#[test] +fn quit_needs_double_press() { + let mut state = AppState::default(); + update(&mut state, Action::Quit); + assert!(!state.should_quit); + assert_eq!(state.status_message.as_deref(), Some(QUIT_CONFIRM_HINT)); + update(&mut state, Action::Quit); + assert!(state.should_quit); +} + +#[test] +fn other_action_disarms_quit() { + let mut state = AppState::default(); + update(&mut state, Action::Quit); + update(&mut state, Action::NextTab); + update(&mut state, Action::Quit); + assert!(!state.should_quit); +} + +#[test] +fn expired_quit_confirmation_rearms() { + let mut state = AppState::default(); + update(&mut state, Action::Quit); + state.quit_armed_until = Some(Instant::now() - Duration::from_secs(1)); + update(&mut state, Action::Quit); + assert!(!state.should_quit); + assert_eq!(state.status_message.as_deref(), Some(QUIT_CONFIRM_HINT)); +} + +#[test] +fn tab_cycling_wraps() { + let mut state = AppState::default(); + update(&mut state, Action::PrevTab); + assert_eq!(state.active_tab, Tab::Logs); + update(&mut state, Action::NextTab); + assert_eq!(state.active_tab, Tab::Global); +} + +#[test] +fn volume_clamps() { + let mut state = AppState::default(); + for _ in 0..30 { + update(&mut state, Action::VolumeUp); + } + assert_eq!(state.player.volume, 100); + for _ in 0..30 { + update(&mut state, Action::VolumeDown); + } + assert_eq!(state.player.volume, 0); +} + +#[test] +fn library_filters_popup_opens_on_library_screens() { + 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!(matches!( + state.popup, + Some(crate::app::state::Popup::LibraryFilters { .. }) + )); + + state.popup = None; + state.active_tab = Tab::Queue; + update(&mut state, Action::OpenLibraryFilters); + assert!(state.popup.is_none()); +} + +#[test] +fn back_closes_help_first() { + let mut state = AppState::default(); + update(&mut state, Action::ToggleHelp); + assert!(state.help_visible); + update(&mut state, Action::Back); + assert!(!state.help_visible); +} + +#[test] +fn grid_movement_clamps_and_wraps_rows() { + let mut state = with_artists(10); + let cols = grid_columns(); + update(&mut state, Action::MoveDown); + assert_eq!(state.global.selected, cols.min(9)); + update(&mut state, Action::MoveUp); + assert_eq!(state.global.selected, 0); + update(&mut state, Action::MoveLeft); + assert_eq!(state.global.selected, 0); + update(&mut state, Action::MoveRight); + assert_eq!(state.global.selected, 1); +} + +#[test] +fn table_mode_moves_one_row() { + let mut state = with_artists(10); + state.global.view = ViewMode::Table; + update(&mut state, Action::MoveDown); + assert_eq!(state.global.selected, 1); + // Left/right are meaningless in the table. + update(&mut state, Action::MoveRight); + assert_eq!(state.global.selected, 1); +} + +#[test] +fn page_down_moves_a_page_and_clamps() { + let mut state = with_artists(200); + let cols = grid_columns() as isize; + let expected = (page_step(&state) * cols).min(199) as usize; + update(&mut state, Action::PageDown); + assert_eq!(state.global.selected, expected); + update(&mut state, Action::PageUp); + assert_eq!(state.global.selected, 0); + + state.global.view = ViewMode::Table; + update(&mut state, Action::PageDown); + assert_eq!(state.global.selected, page_step(&state).min(199) as usize); +} + +#[test] +fn jump_first_last() { + let mut state = with_artists(10); + update(&mut state, Action::SelectLast); + assert_eq!(state.global.selected, 9); + update(&mut state, Action::SelectFirst); + assert_eq!(state.global.selected, 0); +} + +#[test] +fn artist_tiles_move_by_visual_rows_across_groups() { + use crate::library::models::{ArtistDetail, ReleaseCard}; + + let release = |id: i64, kind: &str| ReleaseCard { + id, + title: format!("r{id}"), + release_type: kind.to_string(), + year: None, + cover_path: None, + track_count: 1, + availability: crate::library::models::Availability::Local, + }; + let columns = grid_columns(); + // The terminal size can be visible to tests. Build enough albums to + // force a short second album row for whichever width this run has. + let detail = ArtistDetail { + id: 1, + name: "a".into(), + image_path: None, + total_track_count: 0, + total_play_count: 0, + top_tracks: vec![], + featured_tracks: vec![], + releases: (0..=columns) + .map(|index| release(10 + index as i64, "album")) + .chain((0..2).map(|index| release(100 + index, "compilation"))) + .collect(), + }; + let mut state = AppState::default(); + state.artist_views.insert(1, Loadable::Ready(detail)); + state.global.stack.push(GlobalView::Artist { + id: 1, + cursor: columns + 1, + }); + + // Up from the first compilation lands on the album row directly + // above, not one flat grid-width jump back. + update(&mut state, Action::MoveUp); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Artist { + id: 1, + cursor: columns + }) + ); + // And back down returns to the compilation row, same column. + update(&mut state, Action::MoveDown); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Artist { + id: 1, + cursor: columns + 1 + }) + ); + // Up from the second compilation clamps to the single tile above. + state.global.stack.pop(); + state.global.stack.push(GlobalView::Artist { + id: 1, + cursor: columns + 2, + }); + update(&mut state, Action::MoveUp); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Artist { + id: 1, + cursor: columns + }) + ); +} + +#[test] +fn select_opens_artist_and_back_returns() { + let mut state = with_artists(3); + state.global.selected = 2; + update(&mut state, Action::Select); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Artist { id: 2, cursor: 0 }) + ); + update(&mut state, Action::Back); + assert!(state.global.stack.is_empty()); + assert_eq!(state.global.selected, 2); +} + +#[test] +fn back_from_search_resets_search_state() { + let mut state = AppState::default(); + state.global.stack.push(GlobalView::Search { cursor: 0 }); + state.search.query = "abc".to_string(); + update(&mut state, Action::Back); + assert!(state.global.stack.is_empty()); + assert!(state.search.query.is_empty()); +} + +#[test] +fn queue_advances_and_respects_repeat() { + use crate::app::state::RepeatMode; + use crate::library::models::TrackItem; + + let track = |id: i64| TrackItem { + id, + title: format!("t{id}"), + track_number: None, + disc_number: None, + duration_seconds: 1.0, + artists: vec![], + featured_artists: vec![], + release_id: 1, + release_title: "r".into(), + release_year: None, + cover_path: None, + file_path: format!("/api/player/stream/{id}"), + content_id: None, + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: None, + play_count: 0, + fed: None, + }; + let mut state = AppState::default(); + state.player.queue = vec![track(1), track(2)]; + state.player.playing = true; + + // Track 1 finishes → play track 2. + assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent)); + assert_eq!(state.player.queue_pos, 1); + // Last track, repeat off → stop. + assert_eq!(advance_after_finish(&mut state), Some(Effect::StopPlayback)); + assert!(!state.player.playing); + // Repeat all wraps to the start. + state.player.playing = true; + state.player.repeat = RepeatMode::All; + assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent)); + assert_eq!(state.player.queue_pos, 0); + // Repeat one replays the same position. + state.player.repeat = RepeatMode::One; + assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent)); + assert_eq!(state.player.queue_pos, 0); +} + +#[test] +fn same_tab_number_resets_to_root() { + let mut state = with_artists(3); + update(&mut state, Action::Select); + assert!(!state.global.stack.is_empty()); + update(&mut state, Action::GoToTab(0)); + assert!(state.global.stack.is_empty()); + + state.playlists.opened = Some(OpenedPlaylist { + id: crate::app::state::LIKES_PLAYLIST_ID, + cursor: 0, + }); + update(&mut state, Action::GoToTab(1)); + assert_eq!(state.active_tab, Tab::Playlists); + assert!(state.playlists.opened.is_some()); + update(&mut state, Action::GoToTab(1)); + assert!(state.playlists.opened.is_none()); +} + +#[test] +fn queue_tab_select_and_clear() { + use crate::library::models::TrackItem; + let track = |id: i64| TrackItem { + id, + title: format!("t{id}"), + track_number: None, + disc_number: None, + duration_seconds: 1.0, + artists: vec![], + featured_artists: vec![], + release_id: 1, + release_title: "r".into(), + release_year: None, + cover_path: None, + file_path: format!("/s/{id}"), + content_id: None, + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: None, + play_count: 0, + fed: None, + }; + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = vec![track(1), track(2), track(3)]; + state.player.queue_pos = 2; + + // Cursor moves independently; enter rewinds playback to that track + // without dropping anything from the queue. + update(&mut state, Action::MoveUp); + update(&mut state, Action::MoveUp); + assert_eq!(state.queue_tab.cursor, 0); + assert_eq!( + update(&mut state, Action::Select), + Some(Effect::PlayCurrent) + ); + assert_eq!(state.player.queue_pos, 0); + assert_eq!(state.player.queue.len(), 3); + + assert_eq!( + update(&mut state, Action::ClearQueue), + Some(Effect::StopPlayback) + ); + assert!(state.player.queue.is_empty()); + assert!(!state.player.playing); +} + +#[test] +fn current_track_info_uses_now_playing_track() { + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = vec![test_track(1), test_track(2)]; + state.queue_tab.cursor = 0; + state.player.current = Some(test_track(2)); + + assert_eq!(update(&mut state, Action::OpenCurrentTrackInfo), None); + match &state.popup { + Some(crate::app::state::Popup::TrackInfo { tracks, .. }) => { + assert_eq!( + tracks.iter().map(|track| track.id).collect::>(), + vec![2] + ); + } + other => panic!("expected track info popup, got {other:?}"), + } +} + +#[test] +fn visualizer_requires_current_track() { + let mut state = AppState::default(); + + assert_eq!(update(&mut state, Action::ToggleVisualizer), None); + + assert!(!state.visualizer.active); + assert_eq!( + state.status_message.as_deref(), + Some("nothing playing — start a track first") + ); +} + +#[test] +fn visualizer_toggles_and_back_closes_it() { + let mut state = AppState::default(); + state.player.current = Some(test_track(7)); + state.help_visible = true; + + assert_eq!(update(&mut state, Action::ToggleVisualizer), None); + assert!(state.visualizer.active); + assert!(state.visualizer.started_at.is_some()); + assert!(!state.help_visible); + + assert_eq!(update(&mut state, Action::Back), None); + assert!(!state.visualizer.active); + assert!(state.visualizer.started_at.is_none()); +} + +#[test] +fn add_to_playlist_from_release_carries_selected_track() { + use crate::app::state::{PlaylistAddTarget, Popup}; + use crate::library::models::ReleaseDetail; + + let mut state = AppState::default(); + state + .global + .stack + .push(GlobalView::Release { id: 1, cursor: 1 }); + state.release_views.insert( + 1, + Loadable::Ready(ReleaseDetail { + id: 1, + title: "r".into(), + release_type: "album".into(), + year: None, + cover_path: None, + artists: vec![], + tracks: vec![test_track(1), test_track(2)], + }), + ); + + assert_eq!(update(&mut state, Action::AddToPlaylist), None); + match &state.popup { + Some(Popup::AddToPlaylist { + target: PlaylistAddTarget::Local(tracks), + .. + }) => { + assert_eq!( + tracks.iter().map(|track| track.id).collect::>(), + vec![2] + ); + } + other => panic!("expected add-to-playlist popup with selected track, got {other:?}"), + } +} + +#[test] +fn add_to_playlist_from_non_track_view_uses_current_track() { + use crate::app::state::{PlaylistAddTarget, Popup}; + + let mut state = AppState { + active_tab: Tab::Logs, + ..AppState::default() + }; + state.player.current = Some(test_track(7)); + + assert_eq!(update(&mut state, Action::AddToPlaylist), None); + match &state.popup { + Some(Popup::AddToPlaylist { + target: PlaylistAddTarget::Local(tracks), + .. + }) => { + assert_eq!( + tracks.iter().map(|track| track.id).collect::>(), + vec![7] + ); + } + other => panic!("expected add-to-playlist popup with current track, got {other:?}"), + } +} + +#[test] +fn visual_selection_removes_queue_range() { + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = (1..=4).map(test_track).collect(); + state.queue_tab.cursor = 1; + + assert_eq!(update(&mut state, Action::ToggleTrackSelection), None); + update(&mut state, Action::MoveDown); + + let selected: Vec = selected_tracks(&state) + .into_iter() + .map(|track| track.id) + .collect(); + assert_eq!(selected, vec![2, 3]); + assert_eq!( + update(&mut state, Action::RemoveFromQueue), + Some(Effect::RemoveQueueIndices { + indices: vec![1, 2], + restart_paused: None, + stop: false, + }) + ); + let remaining: Vec = state.player.queue.iter().map(|track| track.id).collect(); + assert_eq!(remaining, vec![1, 4]); + assert!(!state.track_selection.is_active()); +} + +#[test] +fn artist_top_track_selection_queues_all_selected_tracks() { + let mut state = AppState::default(); + state + .global + .stack + .push(GlobalView::Artist { id: 9, cursor: 0 }); + state.artist_views.insert( + 9, + Loadable::Ready(ArtistDetail { + id: 9, + name: "artist".into(), + image_path: None, + total_track_count: 3, + total_play_count: 0, + top_tracks: (1..=3).map(test_track).collect(), + releases: vec![], + featured_tracks: vec![], + }), + ); + + update(&mut state, Action::ToggleTrackSelection); + update(&mut state, Action::MoveDown); + assert_eq!( + update(&mut state, Action::QueueAddLast), + Some(Effect::PlaybackQueueChanged), + ); + 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 removing_current_queue_track_requests_paused_restart() { + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = (1..=3).map(test_track).collect(); + state.player.queue_pos = 1; + state.queue_tab.cursor = 1; + state.player.current = Some(test_track(2)); + state.player.playing = true; + state.player.paused = true; + + assert_eq!( + update(&mut state, Action::RemoveFromQueue), + Some(Effect::RemoveQueueIndices { + indices: vec![1], + restart_paused: Some(true), + stop: false, + }) + ); + let remaining: Vec = state.player.queue.iter().map(|track| track.id).collect(); + assert_eq!(remaining, vec![1, 3]); + assert_eq!(state.player.queue_pos, 1); + assert_eq!(state.player.current.as_ref().map(|track| track.id), Some(3)); +} + +#[test] +fn bulk_like_targets_only_tracks_that_need_toggle() { + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = (1..=3).map(test_track).collect(); + state.likes.insert(format!("b3:{:064x}", 1)); + + update(&mut state, Action::ToggleTrackSelection); + update(&mut state, Action::SelectLast); + assert_eq!( + update(&mut state, Action::ToggleLike), + Some(Effect::ToggleLikes { + track_ids: vec![2, 3], + fed_tracks: vec![], + }) + ); + + state.likes = [1, 2, 3] + .into_iter() + .map(|id| format!("b3:{id:064x}")) + .collect(); + assert_eq!( + update(&mut state, Action::ToggleLike), + Some(Effect::ToggleLikes { + track_ids: vec![1, 2, 3], + fed_tracks: vec![], + }) + ); +} + +#[test] +fn shuffle_reorders_tail_and_restores() { + use crate::library::models::TrackItem; + let track = |id: i64| TrackItem { + id, + title: format!("t{id}"), + track_number: None, + disc_number: None, + duration_seconds: 1.0, + artists: vec![], + featured_artists: vec![], + release_id: 1, + release_title: "r".into(), + release_year: None, + cover_path: None, + file_path: format!("/s/{id}"), + content_id: None, + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: None, + play_count: 0, + fed: None, + }; + let mut state = AppState::default(); + state.player.queue = (1..=8).map(track).collect(); + state.player.queue_pos = 2; + state.player.current = Some(track(3)); + + update(&mut state, Action::ToggleShuffle); + assert!(state.player.shuffle); + // Played part and the current track stay in place. + let ids: Vec = state.player.queue.iter().map(|t| t.id).collect(); + assert_eq!(&ids[..3], &[1, 2, 3]); + // The tail is a permutation of the original tail. + let mut tail = ids[3..].to_vec(); + tail.sort_unstable(); + assert_eq!(tail, vec![4, 5, 6, 7, 8]); + + update(&mut state, Action::ToggleShuffle); + assert!(!state.player.shuffle); + let restored: Vec = state.player.queue.iter().map(|t| t.id).collect(); + assert_eq!(restored, vec![1, 2, 3, 4, 5, 6, 7, 8]); + assert!(state.player.original_order.is_none()); +} + +#[test] +fn shift_j_opens_release_from_queue() { + use crate::library::models::{ReleaseDetail, TrackItem}; + let track = |id: i64, release_id: i64| TrackItem { + id, + title: format!("t{id}"), + track_number: None, + disc_number: None, + duration_seconds: 1.0, + artists: vec![], + featured_artists: vec![], + release_id, + release_title: "r".into(), + release_year: None, + cover_path: None, + file_path: format!("/s/{id}"), + content_id: None, + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: None, + play_count: 0, + fed: None, + }; + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = vec![track(1, 7), track(2, 7)]; + state.queue_tab.cursor = 1; + + // Release not loaded yet → jump queued as pending focus. + update(&mut state, Action::GoToRelease); + assert_eq!(state.active_tab, Tab::Global); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Release { id: 7, cursor: 0 }) + ); + assert_eq!(state.pending_release_focus, Some((7, 2))); + + // Esc returns to the origin tab, not to the Global grid. + update(&mut state, Action::Back); + assert_eq!(state.active_tab, Tab::Queue); + assert!(state.global.stack.is_empty()); + assert!(state.jump_origin.is_none()); + + // With the release cached, the cursor lands on the track directly. + state.global.stack.clear(); + state.pending_release_focus = None; + state.release_views.insert( + 7, + Loadable::Ready(ReleaseDetail { + id: 7, + title: "r".into(), + release_type: "album".into(), + year: None, + cover_path: None, + artists: vec![], + tracks: vec![track(1, 7), track(2, 7)], + }), + ); + state.active_tab = Tab::Queue; + update(&mut state, Action::GoToRelease); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Release { id: 7, cursor: 1 }) + ); + assert!(state.pending_release_focus.is_none()); +} + +#[test] +fn view_toggle() { + let mut state = AppState::default(); + update(&mut state, Action::ToggleViewMode); + assert_eq!(state.global.view, ViewMode::Table); + update(&mut state, Action::ToggleViewMode); + assert_eq!(state.global.view, ViewMode::Tiles); +} diff --git a/src/devices/tests.rs b/src/devices/tests.rs new file mode 100644 index 0000000..001f25d --- /dev/null +++ b/src/devices/tests.rs @@ -0,0 +1,506 @@ +use super::*; + +static NEXT_TEST_DB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn test_sync() -> DeviceSync { + let conn = Connection::open_in_memory().unwrap(); + init_schema(&conn).unwrap(); + let unique = NEXT_TEST_DB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let library_path = std::env::temp_dir().join(format!( + "furumi-devices-test-{}-{}-{}.sqlite3", + std::process::id(), + now_ms(), + unique + )); + let sync = DeviceSync { + conn: Arc::new(std::sync::Mutex::new(conn)), + library: Arc::new(Library::open(&library_path).unwrap()), + event_tx: Arc::new(std::sync::Mutex::new(None)), + playback: Arc::new(std::sync::Mutex::new(PlaybackShared::default())), + }; + sync.ensure_identity().unwrap(); + sync +} + +fn device_revoked(sync: &DeviceSync, device_id: &str) -> bool { + let conn = lock(&sync.conn); + conn.query_row( + "SELECT revoked_at_ms IS NOT NULL + FROM sync_devices + WHERE device_id = ?1", + [device_id], + |row| row.get::<_, i64>(0), + ) + .optional() + .unwrap() + .unwrap_or(0) + != 0 +} + +fn device_known(sync: &DeviceSync, device_id: &str) -> bool { + let conn = lock(&sync.conn); + conn.query_row( + "SELECT 1 FROM sync_devices WHERE device_id = ?1", + [device_id], + |row| row.get::<_, i64>(0), + ) + .optional() + .unwrap() + .is_some() +} + +fn test_fed_track(content_id: &str) -> crate::federation::FedTrack { + crate::federation::FedTrack { + item_id: "fed_item_1".to_string(), + owner: "fed_owner_1".to_string(), + own: false, + title: "Remote Song".to_string(), + artist_names: vec!["Remote Artist".to_string()], + featured_artist_names: Vec::new(), + year: Some(2026), + duration_seconds: Some(123), + content_id: Some(content_id.to_string()), + release_title: Some("Remote Release".to_string()), + track_number: Some(1), + disc_number: Some(1), + } +} + +#[test] +fn base64url_round_trip_without_padding() { + for input in [b"".as_slice(), b"a", b"ab", b"abc", b"abcdef"] { + let encoded = base64url_encode(input); + assert!(!encoded.contains('=')); + assert_eq!(base64url_decode(&encoded).unwrap(), input); + } +} + +#[test] +fn tombstone_detection() { + assert!( + SyncOpPayload::TrackLikeSet { + content_id: "b3:0".into(), + liked: false, + fed: None, + } + .is_tombstone() + ); + assert!( + !SyncOpPayload::TrackLikeSet { + content_id: "b3:0".into(), + liked: true, + fed: None, + } + .is_tombstone() + ); +} + +#[test] +fn playback_tracks_do_not_sync_device_local_paths() { + let source = TrackItem { + id: 7, + title: "Local Song".to_string(), + track_number: Some(1), + disc_number: Some(1), + duration_seconds: 180.0, + artists: vec![ArtistRef { + id: 1, + name: "Local Artist".to_string(), + }], + featured_artists: Vec::new(), + release_id: 2, + release_title: "Local Release".to_string(), + release_year: Some(2026), + file_path: r"C:\Users\me\Music\song.mp3".to_string(), + content_id: Some(format!("b3:{}", "a".repeat(64))), + cover_path: None, + audio_format: Some("mp3".to_string()), + audio_bitrate: Some(320), + audio_sample_rate: Some(44_100), + audio_bit_depth: None, + file_size_bytes: Some(123_456), + play_count: 3, + fed: None, + }; + + let wire = PlaybackTrack::from_track(&source); + assert!(wire.file_path.is_empty()); + + let mut legacy_wire = wire.clone(); + legacy_wire.file_path = "/Users/me/Music/song.mp3".to_string(); + let restored = legacy_wire.to_track_item(); + assert!(restored.id < 0); + assert_ne!(restored.id, source.id); + assert!(restored.file_path.is_empty()); + assert_eq!(restored.content_id, source.content_id); +} + +#[test] +fn compacted_device_revoke_removes_device_row() { + let sync = test_sync(); + let device_id = "dev_old"; + + sync.apply_device_trusted(device_id, 10).unwrap(); + assert!(device_known(&sync, device_id)); + + sync.revoke_device(device_id).unwrap(); + assert!(!device_known(&sync, device_id)); +} + +#[test] +fn leave_group_self_revokes_then_resets_to_new_group() { + let sync = test_sync(); + let identity = sync.ensure_identity().unwrap(); + let old_group = identity.group_id.clone(); + sync.apply_device_trusted("dev_peer", 10).unwrap(); + + let op_id = sync.record_leave_group_revoke().unwrap(); + assert!(device_revoked(&sync, &identity.device_id)); + { + let conn = lock(&sync.conn); + let payload_json: String = conn + .query_row( + "SELECT payload_json FROM sync_ops WHERE op_id = ?1", + [&op_id], + |row| row.get(0), + ) + .unwrap(); + let payload: SyncOpPayload = serde_json::from_str(&payload_json).unwrap(); + match payload { + SyncOpPayload::DeviceRevoked { + target_device_id, + target_max_seq_seen, + } => { + assert_eq!(target_device_id, identity.device_id); + assert_eq!(target_max_seq_seen, 1); + } + other => panic!("unexpected payload: {other:?}"), + } + } + + let new_group = sync.finish_leave_group_reset().unwrap(); + assert_ne!(old_group, new_group); + let status = sync.status(); + assert_eq!(status.group_id, new_group); + assert_eq!(status.active_devices, 1); + assert_eq!(status.devices.len(), 1); + assert!(status.devices[0].is_self); + assert!(!status.devices[0].revoked); + assert_eq!(status.ops_total, 0); + assert_eq!(status.outbox_ops, 0); + assert!(!device_known(&sync, "dev_peer")); +} + +#[test] +fn playback_command_is_targeted_and_deduplicated() { + let sync = test_sync(); + let identity = sync.ensure_identity().unwrap(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + sync.set_event_tx(tx); + let command = PlaybackCommand::SetState { + state: PlaybackStateWire { + queue: Vec::new(), + queue_pos: 0, + playing: false, + paused: false, + idle_since_ms: None, + position_secs: 0.0, + volume: 42, + shuffle: false, + repeat: PlaybackRepeat::Off, + }, + seek: false, + }; + + sync.apply_playback_command("dev_other", &command, "op_other") + .unwrap(); + assert!(rx.try_recv().is_err()); + + sync.apply_playback_command(&identity.device_id, &command, "op_1") + .unwrap(); + assert!(matches!( + rx.try_recv().unwrap(), + crate::app::event::AppEvent::PlaybackCommand(_) + )); + + sync.apply_playback_command(&identity.device_id, &command, "op_1") + .unwrap(); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn newer_device_trust_reactivates_revoked_device() { + let sync = test_sync(); + let device_id = "dev_readd"; + + sync.apply_device_trusted(device_id, 10).unwrap(); + assert!(!device_revoked(&sync, device_id)); + + sync.apply_device_revoked(device_id, 20, "dev_owner", 0) + .unwrap(); + assert!(device_revoked(&sync, device_id)); + + sync.apply_device_trusted(device_id, 30).unwrap(); + assert!(!device_revoked(&sync, device_id)); + + sync.apply_device_revoked(device_id, 25, "dev_owner", 0) + .unwrap(); + assert!(!device_revoked(&sync, device_id)); + + sync.apply_device_profile( + &DeviceProfileWire { + device_id: device_id.to_string(), + name: "readded".to_string(), + client_version: CLIENT_VERSION.to_string(), + protocol_version: PROTOCOL_VERSION, + endpoint_id: String::new(), + endpoint_ticket: String::new(), + revoked: true, + revoke_cutoff_seq: Some(0), + updated_at_ms: 20, + }, + false, + ) + .unwrap(); + assert!(!device_revoked(&sync, device_id)); +} + +#[test] +fn tombstone_gc_waits_for_every_active_remote_ack() { + let sync = test_sync(); + let origin = sync.ensure_identity().unwrap().device_id; + sync.apply_device_trusted("dev_a", 1).unwrap(); + sync.apply_device_trusted("dev_b", 1).unwrap(); + + sync.record_local_op(SyncOpPayload::PlaylistDeleted { + playlist_id: "pl_deleted".to_string(), + }) + .unwrap(); + { + let conn = lock(&sync.conn); + let tombstones: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tombstones, 1); + } + + let ack = BTreeMap::from([(origin, 1)]); + sync.note_peer_vector("dev_a", &ack).unwrap(); + sync.gc_tombstones().unwrap(); + { + let conn = lock(&sync.conn); + let tombstones: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tombstones, 1); + } + + sync.note_peer_vector("dev_b", &ack).unwrap(); + sync.gc_tombstones().unwrap(); + let conn = lock(&sync.conn); + let tombstones: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tombstones, 0); +} + +#[test] +fn snapshot_carries_deleted_playlists_to_repair_stale_peers() { + let source = test_sync(); + let source_playlist = source.library.create_playlist("Gone").unwrap(); + let playlist_sync_id = source + .library + .ensure_playlist_sync_id(source_playlist.id) + .unwrap(); + source + .apply_playlist_state(&playlist_sync_id, "Gone", false, 10, "dev_remote:1") + .unwrap(); + source + .apply_playlist_state(&playlist_sync_id, "", true, 20, "dev_remote:2") + .unwrap(); + let snapshot = source.snapshot().unwrap(); + assert!( + snapshot + .deleted_playlists + .iter() + .any(|playlist| playlist.playlist_id == playlist_sync_id) + ); + + let peer = test_sync(); + let peer_playlist = peer + .library + .upsert_synced_playlist(&playlist_sync_id, "Gone") + .unwrap(); + assert!(peer.library.playlist(peer_playlist).is_ok()); + + peer.apply_snapshot(snapshot).unwrap(); + assert!( + !peer + .library + .playlists() + .unwrap() + .iter() + .any(|playlist| playlist.title == "Gone") + ); +} + +#[test] +fn synced_fed_like_metadata_repairs_existing_like_state() { + let sync = test_sync(); + let content_id = format!("b3:{}", "a".repeat(64)); + let fed = test_fed_track(&content_id); + let synced = SyncedFedTrack::from_fed(&fed).unwrap(); + + assert!( + sync.apply_like_state(&content_id, true, None, 10, "dev_remote:1") + .unwrap() + ); + assert!(sync.library.fed_like_ids().unwrap().is_empty()); + + assert!( + sync.apply_like_state(&content_id, true, Some(&synced), 10, "dev_remote:1") + .unwrap() + ); + let keys = sync.library.fed_like_ids().unwrap(); + assert!(keys.contains(&fed.item_id)); + assert!(keys.contains(&content_id)); + + assert!( + sync.apply_like_state(&content_id, false, None, 11, "dev_remote:2") + .unwrap() + ); + assert!(sync.library.fed_like_ids().unwrap().is_empty()); +} + +#[test] +fn synced_fed_likes_are_ordered_by_hlc_not_receive_time() { + let sync = test_sync(); + let old_content_id = format!("b3:{}", "c".repeat(64)); + let new_content_id = format!("b3:{}", "d".repeat(64)); + let mut old_fed = test_fed_track(&old_content_id); + old_fed.item_id = "fed_old".to_string(); + old_fed.title = "Old Fed".to_string(); + let mut new_fed = test_fed_track(&new_content_id); + new_fed.item_id = "fed_new".to_string(); + new_fed.title = "New Fed".to_string(); + + let new_synced = SyncedFedTrack::from_fed(&new_fed).unwrap(); + let old_synced = SyncedFedTrack::from_fed(&old_fed).unwrap(); + sync.apply_like_state(&new_content_id, true, Some(&new_synced), 20, "dev_remote:2") + .unwrap(); + sync.apply_like_state(&old_content_id, true, Some(&old_synced), 10, "dev_remote:1") + .unwrap(); + + let titles: Vec = sync + .library + .playlist(crate::library::LIKES_PLAYLIST_ID) + .unwrap() + .tracks + .into_iter() + .map(|track| track.title) + .collect(); + assert_eq!(titles, vec!["New Fed", "Old Fed"]); +} + +#[test] +fn synced_playlist_item_metadata_creates_pending_fed_track() { + let sync = test_sync(); + let playlist = sync.library.create_playlist("Remote Mix").unwrap(); + let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap(); + let content_id = format!("b3:{}", "b".repeat(64)); + let fed = test_fed_track(&content_id); + let synced = SyncedFedTrack::from_fed(&fed).unwrap(); + + assert!( + sync.apply_playlist_item_state( + &playlist_sync_id, + &content_id, + true, + 3, + Some(&synced), + 10, + "dev_remote:2", + ) + .unwrap() + ); + + let detail = sync.library.playlist(playlist.id).unwrap(); + assert_eq!(detail.tracks.len(), 1); + assert!(detail.tracks[0].is_fed_pending()); + assert_eq!(detail.tracks[0].title, fed.title); + + let conn = lock(&sync.conn); + assert_eq!( + sync.unresolved_playlist_item_count_with_conn(&conn) + .unwrap(), + 0 + ); + drop(conn); + + assert!( + sync.apply_playlist_item_state( + &playlist_sync_id, + &content_id, + false, + 0, + None, + 11, + "dev_remote:3", + ) + .unwrap() + ); + assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0); +} + +#[test] +fn stale_synced_playlist_item_metadata_repairs_pending_fed_track() { + let sync = test_sync(); + let playlist = sync.library.create_playlist("Remote Mix").unwrap(); + let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap(); + let content_id = format!("b3:{}", "e".repeat(64)); + let fed = test_fed_track(&content_id); + let synced = SyncedFedTrack::from_fed(&fed).unwrap(); + + assert!( + sync.apply_playlist_item_state( + &playlist_sync_id, + &content_id, + true, + 7, + None, + 10, + "dev_remote:2", + ) + .unwrap() + ); + assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0); + + assert!( + sync.apply_playlist_item_state( + &playlist_sync_id, + &content_id, + true, + 7, + Some(&synced), + 10, + "dev_remote:2", + ) + .unwrap() + ); + let detail = sync.library.playlist(playlist.id).unwrap(); + assert_eq!(detail.tracks.len(), 1); + assert!(detail.tracks[0].is_fed_pending()); + assert_eq!(detail.tracks[0].title, fed.title); +} diff --git a/src/federation/tests.rs b/src/federation/tests.rs new file mode 100644 index 0000000..c98985c --- /dev/null +++ b/src/federation/tests.rs @@ -0,0 +1,95 @@ +use super::*; + +fn test_owner() -> EndpointId { + music_dht::SecretKey::from_bytes(&[7; 32]).public() +} + +fn dht_track(main: &[&str], featured: &[&str]) -> LibraryItem { + let owner = test_owner(); + LibraryItem { + id: music_dht::ItemId::derive(&owner, ItemKind::Track, "track:1"), + owner, + kind: ItemKind::Track, + name: "Guest Verse".into(), + normalized_name: music_dht::normalize_name("Guest Verse"), + artist_names: main.iter().map(|name| name.to_string()).collect(), + featured_artist_names: featured.iter().map(|name| name.to_string()).collect(), + year: Some(2024), + release_type: Some("album".into()), + release_title: Some("Host Album".into()), + track_number: Some(2), + disc_number: Some(1), + duration_seconds: Some(180.0), + content_id: Some( + "b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), + ), + revision: 1, + deleted: false, + updated_at_ms: 0, + } +} + +#[test] +fn dht_appearance_requires_explicit_featured_artist() { + let normalized = music_dht::normalize_name("Guest"); + assert!(dht_appearance_hit(&dht_track(&["Guest"], &[]), &normalized, "Guest").is_none()); + + let hit = dht_appearance_hit(&dht_track(&["Host"], &["Guest"]), &normalized, "Guest").unwrap(); + assert_eq!(hit.release_title, "Host Album"); + assert_eq!(hit.release_type, "album"); + assert_eq!(hit.year, Some(2024)); + assert_eq!(hit.track.artists, vec!["Host"]); + assert_eq!(hit.track.featured_artists, vec!["Guest"]); + assert_eq!(hit.track.track_number, Some(2)); + assert_eq!(hit.track.disc_number, Some(1)); +} + +#[test] +fn federation_search_ranks_exact_names_first() { + let normalized = music_dht::normalize_name("ежемесячные"); + let mut artists = vec![ + FedArtistHit { + name: "Booker".into(), + peers: 3, + }, + FedArtistHit { + name: "Ежемесячные".into(), + peers: 1, + }, + ]; + let mut tracks = vec![ + FedTrack { + item_id: "a".into(), + owner: "peer-a".into(), + own: false, + title: "Гость".into(), + artist_names: vec!["Other".into()], + featured_artist_names: vec!["Ежемесячные".into()], + year: None, + duration_seconds: None, + content_id: None, + release_title: None, + track_number: None, + disc_number: None, + }, + FedTrack { + item_id: "b".into(), + owner: "peer-b".into(), + own: false, + title: "Ежемесячные".into(), + artist_names: vec!["Other".into()], + featured_artist_names: Vec::new(), + year: None, + duration_seconds: None, + content_id: None, + release_title: None, + track_number: None, + disc_number: None, + }, + ]; + + rank_fed_search_results(&mut artists, &mut tracks, &normalized); + + assert_eq!(artists[0].name, "Ежемесячные"); + assert_eq!(tracks[0].title, "Ежемесячные"); +} diff --git a/src/library/tests.rs b/src/library/tests.rs new file mode 100644 index 0000000..958ca18 --- /dev/null +++ b/src/library/tests.rs @@ -0,0 +1,509 @@ +use super::*; + +fn test_library() -> Library { + 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(); + Library { + conn: Mutex::new(conn), + db_path: std::env::temp_dir().join("furumi-test-library.db"), + covers_dir: std::env::temp_dir().join("furumi-test-covers-unused"), + } +} + +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 { + release_type: None, + file_path: format!("/music/{artist}/{album}/{title}.mp3"), + title: title.to_string(), + artists: vec![artist.to_string()], + featured_artists: featured.iter().map(|name| (*name).to_string()).collect(), + album_artists: vec![artist.to_string()], + release_title: album.to_string(), + year: Some(2020), + track_number: None, + disc_number: None, + duration_seconds: 60.0, + audio_format: Some("mp3".into()), + audio_bitrate: Some(320), + audio_sample_rate: Some(44100), + audio_bit_depth: None, + file_size_bytes: Some(1), + cover: None, + }; + let id = import::upsert_track(lib, &import).unwrap().0; + let content_id = format!("b3:{}", blake3::hash(import.file_path.as_bytes()).to_hex()); + lib.lock() + .execute( + "UPDATE tracks SET content_id = ?2 WHERE id = ?1", + params![id, content_id], + ) + .unwrap(); + id +} + +fn artist_filters(hide_featured_only: bool) -> crate::config::settings::LibraryFilters { + crate::config::settings::LibraryFilters { + hide_featured_only, + ..Default::default() + } +} + +#[test] +fn local_stats_counts_library_rows_and_audio_bytes() { + let lib = test_library(); + add_track(&lib, "One", "Artist", "First"); + add_track(&lib, "Two", "Artist", "Second"); + + let stats = lib.local_stats().unwrap(); + assert_eq!(stats.artist_count, 1); + assert_eq!(stats.release_count, 2); + assert_eq!(stats.track_count, 2); + assert_eq!(stats.audio_bytes, 2); + assert_eq!(stats.tracks_without_size, 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, artist_filters(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, artist_filters(true)).unwrap(); + assert!(filtered.items.iter().all(|artist| artist.release_count > 0)); + assert!(!filtered.items.iter().any(|artist| artist.name == "Guest")); +} + +#[test] +fn network_artist_image_hint_becomes_local_image_after_fetch() { + let lib = test_library(); + let artist_key = music_dht::normalize_name("Remote Artist"); + lib.replace_network_artist_cache( + "peer-a", + "personal", + &[NetworkArtistPreview { + artist_key: artist_key.clone(), + name: "Remote Artist".into(), + image_path: Some("peer-local/image.jpg".into()), + release_count: 1, + track_count: 3, + }], + true, + ) + .unwrap(); + + let filters = crate::config::settings::LibraryFilters { + source_mode: crate::config::settings::LibrarySourceMode::My, + ..Default::default() + }; + let page = lib.artists(1, 10, filters).unwrap(); + assert_eq!(page.items[0].image_path, None); + + let requests = lib + .network_artist_image_requests(filters, &["Remote Artist".into()], 8) + .unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].source_id, "peer-a"); + assert_eq!(requests[0].artist_key, artist_key); + + lib.set_network_artist_image("peer-a", &artist_key, "/tmp/remote-artist.jpg") + .unwrap(); + let page = lib.artists(1, 10, filters).unwrap(); + assert_eq!( + page.items[0].image_path.as_deref(), + Some("/tmp/remote-artist.jpg") + ); +} + +#[test] +fn import_creates_artist_release_track() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); + assert_eq!(page.total, 1); + assert_eq!(page.items[0].name, "Artist"); + assert_eq!(page.items[0].track_count, 1); + + let detail = lib.artist(page.items[0].id).unwrap(); + assert_eq!(detail.releases.len(), 1); + assert_eq!(detail.top_tracks.len(), 1); + + let release = lib.release(detail.releases[0].id).unwrap(); + assert_eq!(release.tracks.len(), 1); + assert_eq!(release.tracks[0].id, track_id); + assert_eq!(release.tracks[0].artists[0].name, "Artist"); +} + +#[test] +fn reimport_updates_instead_of_duplicating() { + let lib = test_library(); + let first = add_track(&lib, "Song", "Artist", "Album"); + let second = add_track(&lib, "Song", "Artist", "Album"); + assert_eq!(first, second); + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); + assert_eq!(page.items[0].track_count, 1); +} + +#[test] +fn content_id_backfill_hashes_missing_track_ids() { + let lib = test_library(); + let path = std::env::temp_dir().join(format!( + "furumi-content-id-test-{}-{}.bin", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, b"portable content id").unwrap(); + let file_path = path.to_string_lossy().into_owned(); + let import = import::TrackImport { + release_type: None, + file_path: file_path.clone(), + title: "Portable".to_string(), + artists: vec!["Artist".to_string()], + featured_artists: Vec::new(), + album_artists: vec!["Artist".to_string()], + release_title: "Album".to_string(), + year: Some(2026), + track_number: None, + disc_number: None, + duration_seconds: 60.0, + audio_format: Some("bin".into()), + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: Some(19), + cover: None, + }; + let track_id = import::upsert_track(&lib, &import).unwrap().0; + let expected = audio_content_id(&file_path).unwrap(); + { + let conn = lib.lock(); + conn.execute( + "UPDATE tracks SET content_id = NULL WHERE id = ?1", + [track_id], + ) + .unwrap(); + } + + let stats = lib.backfill_missing_content_ids().unwrap(); + assert_eq!(stats.hashed, 1); + assert_eq!(stats.updated(), 1); + assert_eq!( + lib.track_content_id_by_id(track_id).unwrap().as_deref(), + Some(expected.as_str()) + ); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn search_finds_all_kinds() { + let lib = test_library(); + add_track(&lib, "Neon Lights", "Neon Artist", "Neon Album"); + let results = lib.search("neon", 10).unwrap(); + assert_eq!(results.artists.len(), 1); + assert_eq!(results.releases.len(), 1); + assert_eq!(results.tracks.len(), 1); + // LIKE wildcards in the query must not match everything. + assert_eq!(lib.search("%", 10).unwrap().len(), 0); +} + +#[test] +fn search_ranks_exact_names_first() { + let lib = test_library(); + add_track(&lib, "A Needle", "A Needle Artist", "A Needle Album"); + add_track(&lib, "Needle", "Needle", "Needle"); + + let results = lib.search("needle", 10).unwrap(); + assert_eq!(results.artists[0].name, "Needle"); + assert_eq!(results.releases[0].title, "Needle"); + assert_eq!(results.tracks[0].title, "Needle"); +} + +#[test] +fn search_folds_case_beyond_ascii() { + let lib = test_library(); + add_track(&lib, "Nothing Else Matters", "Металлика", "Чёрный альбом"); + // SQLite's LIKE/NOCASE only fold ASCII; norm() folds every script. + assert_eq!(lib.search("металлика", 10).unwrap().artists.len(), 1); + assert_eq!(lib.search("МЕТАЛЛИКА", 10).unwrap().artists.len(), 1); + assert_eq!(lib.search("чёрный", 10).unwrap().releases.len(), 1); + assert_eq!(lib.search("matters", 10).unwrap().tracks.len(), 1); +} + +#[test] +fn playlists_and_likes_round_trip() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + let playlist = lib.create_playlist("Mix").unwrap(); + lib.add_tracks_to_playlist(playlist.id, &[track_id]) + .unwrap(); + assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1); + + let content_id = lib.track_content_id_by_id(track_id).unwrap().unwrap(); + assert!(lib.toggle_like_by_content_id(&content_id).unwrap()); + assert_eq!(lib.liked_content_ids().unwrap(), vec![content_id.clone()]); + assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1); + assert!(!lib.toggle_like_by_content_id(&content_id).unwrap()); + + lib.remove_tracks_from_playlist(playlist.id, &[track_id]) + .unwrap(); + assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0); + lib.delete_playlist(playlist.id).unwrap(); + // Only the virtual Likes playlist remains. + assert_eq!(lib.playlists().unwrap().len(), 1); +} + +#[test] +fn likes_playlist_orders_local_and_federated_by_liked_at() { + let lib = test_library(); + let old_id = add_track(&lib, "Old Local", "Artist", "Album"); + let new_id = add_track(&lib, "New Local", "Artist", "Album"); + let old_content_id = lib.track_content_id_by_id(old_id).unwrap().unwrap(); + let new_content_id = lib.track_content_id_by_id(new_id).unwrap().unwrap(); + let content_id = format!("b3:{}", "c".repeat(64)); + let fed = crate::federation::FedTrack { + item_id: "fed_item_order".to_string(), + owner: "fed_owner_order".to_string(), + own: false, + title: "Middle Fed".to_string(), + artist_names: vec!["Remote Artist".to_string()], + featured_artist_names: Vec::new(), + year: Some(2026), + duration_seconds: Some(123), + content_id: Some(content_id), + release_title: Some("Remote Release".to_string()), + track_number: Some(1), + disc_number: Some(1), + }; + + assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap()); + assert!(lib.toggle_like_by_content_id(&new_content_id).unwrap()); + assert!(lib.toggle_fed_like(&fed).unwrap()); + { + let conn = lib.lock(); + conn.execute( + "UPDATE likes SET liked_at = ?2 WHERE track_id = ?1", + params![old_id, "2026-01-01 00:00:00"], + ) + .unwrap(); + conn.execute( + "UPDATE likes SET liked_at = ?2 WHERE track_id = ?1", + params![new_id, "2026-01-02 00:00:00"], + ) + .unwrap(); + conn.execute( + "UPDATE fed_likes SET liked_at = ?2 WHERE item_id = ?1", + params![fed.item_id, "2026-01-03 00:00:00"], + ) + .unwrap(); + } + + let titles: Vec = lib + .playlist(LIKES_PLAYLIST_ID) + .unwrap() + .tracks + .into_iter() + .map(|track| track.title) + .collect(); + assert_eq!(titles, vec!["Middle Fed", "New Local", "Old Local"]); + + assert!(!lib.toggle_like_by_content_id(&old_content_id).unwrap()); + assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap()); + { + let conn = lib.lock(); + conn.execute( + "UPDATE likes SET liked_at = ?2 WHERE track_id = ?1", + params![old_id, "2026-01-04 00:00:00"], + ) + .unwrap(); + } + let titles: Vec = lib + .playlist(LIKES_PLAYLIST_ID) + .unwrap() + .tracks + .into_iter() + .map(|track| track.title) + .collect(); + assert_eq!(titles, vec!["Old Local", "Middle Fed", "New Local"]); +} + +#[test] +fn synced_playlist_can_show_federated_pending_tracks() { + let lib = test_library(); + let playlist = lib.create_playlist("Remote Mix").unwrap(); + let sync_id = lib.ensure_playlist_sync_id(playlist.id).unwrap(); + let content_id = format!("b3:{}", "a".repeat(64)); + let fed = crate::federation::FedTrack { + item_id: "fed_item_1".to_string(), + owner: "fed_owner_1".to_string(), + own: false, + title: "Remote Song".to_string(), + artist_names: vec!["Remote Artist".to_string()], + featured_artist_names: vec!["Remote Guest".to_string()], + year: Some(2026), + duration_seconds: Some(123), + content_id: Some(content_id.clone()), + release_title: Some("Remote Release".to_string()), + track_number: Some(2), + disc_number: Some(1), + }; + + assert!(lib.upsert_fed_playlist_track(&sync_id, &fed, 4).unwrap()); + assert!( + lib.has_playlist_content_reference(&sync_id, &content_id) + .unwrap() + ); + + let detail = lib.playlist(playlist.id).unwrap(); + assert_eq!(detail.tracks.len(), 1); + let track = &detail.tracks[0]; + assert!(track.is_fed_pending()); + assert_eq!(track.title, "Remote Song"); + assert_eq!(track.artist_line(), "Remote Artist feat. Remote Guest"); + assert_eq!(track.release_title, "Remote Release"); + assert_eq!(track.content_id.as_deref(), Some(content_id.as_str())); + + let card = lib + .playlists() + .unwrap() + .into_iter() + .find(|card| card.id == playlist.id) + .unwrap(); + assert_eq!(card.track_count, 1); + + lib.remove_content_ids_from_playlist(playlist.id, std::slice::from_ref(&content_id)) + .unwrap(); + assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0); + assert!( + lib.fed_playlist_track_by_content_id(&sync_id, &content_id) + .unwrap() + .is_none() + ); +} + +#[test] +fn add_federated_pending_track_to_playlist_records_position() { + let lib = test_library(); + let local_id = add_track(&lib, "Local Song", "Artist", "Album"); + let playlist = lib.create_playlist("Remote Mix").unwrap(); + let content_id = format!("b3:{}", "b".repeat(64)); + let fed = crate::federation::FedTrack { + item_id: "fed_item_2".to_string(), + owner: "fed_owner_2".to_string(), + own: false, + title: "Remote Song".to_string(), + artist_names: vec!["Remote Artist".to_string()], + featured_artist_names: Vec::new(), + year: Some(2026), + duration_seconds: Some(123), + content_id: Some(content_id.clone()), + release_title: Some("Remote Release".to_string()), + track_number: Some(2), + disc_number: Some(1), + }; + + lib.add_tracks_to_playlist(playlist.id, &[local_id]) + .unwrap(); + lib.add_fed_tracks_to_playlist(playlist.id, std::slice::from_ref(&fed)) + .unwrap(); + + let position = lib + .playlist_content_position(playlist.id, &content_id) + .unwrap(); + assert_eq!(position, Some(1)); + let detail = lib.playlist(playlist.id).unwrap(); + assert_eq!( + detail + .tracks + .into_iter() + .map(|track| track.title) + .collect::>(), + vec!["Local Song", "Remote Song"] + ); +} + +#[test] +fn track_edit_relinks_artists() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + lib.update_track( + track_id, + &TrackEdit { + title: "Renamed".into(), + artists: vec!["Other".into()], + featured_artists: vec!["Guest".into()], + track_number: Some(2), + disc_number: None, + cover_path: None, + }, + ) + .unwrap(); + let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0); + assert_eq!(track.title, "Renamed"); + assert_eq!(track.artists[0].name, "Other"); + assert_eq!(track.featured_artists[0].name, "Guest"); + assert_eq!(track.track_number, Some(2)); +} + +#[test] +fn deleting_artist_cleans_up_own_content() { + let lib = test_library(); + add_track(&lib, "Song", "Solo", "Solo Album"); + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); + lib.delete_artist(page.items[0].id).unwrap(); + assert_eq!(lib.artists(1, 10, artist_filters(false)).unwrap().total, 0); + assert_eq!(lib.search("Song", 10).unwrap().len(), 0); +} + +#[test] +fn delete_track_drops_empty_release() { + let lib = test_library(); + let track_id = add_track(&lib, "Only", "Artist", "Album"); + lib.delete_track(track_id).unwrap(); + let detail = lib + .artist(lib.artists(1, 10, artist_filters(false)).unwrap().items[0].id) + .unwrap(); + assert!(detail.releases.is_empty()); +} + +#[test] +fn history_counts_completed_plays() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + lib.add_history(track_id, None, 60, true).unwrap(); + lib.add_history(track_id, None, 10, false).unwrap(); + let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0); + assert_eq!(track.play_count, 1); +}