Updated readme

This commit is contained in:
Ultradesu
2026-07-26 02:41:08 +03:00
parent c069ed9135
commit 9aefaa364e
8 changed files with 2173 additions and 1 deletions
+241
View File
@@ -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.
+56
View File
@@ -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:
+13
View File
@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
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.
+3 -1
View File
@@ -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
+750
View File
@@ -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<_>>(),
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<_>>(),
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<_>>(),
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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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);
}
+506
View File
@@ -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<String> = 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);
}
+95
View File
@@ -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, "Ежемесячные");
}
+509
View File
@@ -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<String> = 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<String> = 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<_>>(),
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);
}