Updated readme
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
queue, while `player::Controller` owns rodio playback and receives explicit
|
||||||
commands. Prefetching prepares the next source before the current item ends.
|
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
|
## Persistence boundaries
|
||||||
|
|
||||||
Furumi stores different kinds of state according to their lifetime:
|
Furumi stores different kinds of state according to their lifetime:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -65,7 +65,9 @@ inspecting connected devices, and configuring federation.
|
|||||||
The interface supports keyboard-driven navigation, multi-key combinations,
|
The interface supports keyboard-driven navigation, multi-key combinations,
|
||||||
context-aware bindings, and user-defined rebinding through TOML. Built-in
|
context-aware bindings, and user-defined rebinding through TOML. Built-in
|
||||||
audio visualizations, OS media controls, gapless queue playback, and local
|
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
|
## Install
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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, "Ежемесячные");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user