Updated readme
This commit is contained in:
@@ -57,6 +57,7 @@ jobs:
|
|||||||
mkdir -p "$package_dir"
|
mkdir -p "$package_dir"
|
||||||
cp "target/release/${{ matrix.binary_name }}" "$package_dir/"
|
cp "target/release/${{ matrix.binary_name }}" "$package_dir/"
|
||||||
cp README.md "$package_dir/"
|
cp README.md "$package_dir/"
|
||||||
|
cp LICENSE "$package_dir/"
|
||||||
|
|
||||||
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||||
(cd dist && 7z a "../${archive_name}" "${{ matrix.asset_name }}")
|
(cd dist && 7z a "../${archive_name}" "${{ matrix.asset_name }}")
|
||||||
|
|||||||
+123
-215
@@ -1,240 +1,148 @@
|
|||||||
# furumi_cli — Architecture
|
# furumi architecture
|
||||||
|
|
||||||
Cross-platform terminal client (cmus-style TUI) for the furumusic backend.
|
`furumi` is a single Rust binary organized around an Elm-style state/update
|
||||||
Targets: macOS, Linux (ALSA/Pulse/PipeWire), Windows (WASAPI, Windows Terminal).
|
loop. The UI state remains synchronous and deterministic; filesystem, SQLite,
|
||||||
|
audio, networking, artwork, and media-control work is performed by runtime
|
||||||
|
services and reported back as application events.
|
||||||
|
|
||||||
## 1. Technology choices
|
## Runtime flow
|
||||||
|
|
||||||
### TUI: ratatui 0.30 + crossterm 0.29
|
```text
|
||||||
|
terminal/media/player/network events
|
||||||
Evaluated: **ratatui**, cursive, tui-realm, iocraft.
|
|
|
||||||
|
v
|
||||||
- **ratatui 0.30.x** — the de-facto standard (gitui, yazi, spotify-player all use it).
|
app::event::AppEvent
|
||||||
0.30 split the project into workspace crates (`ratatui-core`, `ratatui-widgets`,
|
|
|
||||||
`ratatui-crossterm`) with a stable core API. Stock widgets cover everything we
|
v
|
||||||
need: `Tabs`, `List`, `Table`, nested `Layout` for tile grids.
|
input/keymap -> Action
|
||||||
- cursive — maintenance mode since 2024, rejected.
|
|
|
||||||
- tui-realm — viable framework on top of ratatui (termusic uses it), but a
|
v
|
||||||
single-maintainer abstraction layer; we prefer plain ratatui with our own
|
app::update()
|
||||||
thin component layer.
|
mutates AppState and
|
||||||
- iocraft — too young, optimized for inline CLI output rather than fullscreen apps.
|
requests an Effect
|
||||||
- termion — Unix-only, eliminated (we need Windows).
|
|
|
||||||
|
v
|
||||||
crossterm is the only backend that covers Windows. Caveats to handle:
|
app runtime performs I/O
|
||||||
- Enable kitty keyboard enhancement flags only when
|
|
|
||||||
`supports_keyboard_enhancement()` returns true; always pop flags on exit.
|
+----> new AppEvent
|
||||||
- Filter key events to `KeyEventKind::Press` (Windows and kitty-enhanced
|
|
||||||
terminals also deliver Repeat/Release — otherwise bindings double-fire).
|
|
||||||
- Restore the terminal on panic (panic hook) — a TUI that corrupts the shell
|
|
||||||
is the #1 reliability complaint.
|
|
||||||
|
|
||||||
### Keybindings: crokey + TOML keymap
|
|
||||||
|
|
||||||
- **crokey 1.4** — parses/formats key combos (`ctrl-a`, `g`), serde support, used
|
|
||||||
for the config file format.
|
|
||||||
- Keymap model copied from spotify-player: a `[[keymaps]]` TOML table mapping a
|
|
||||||
*key sequence* (space-separated chords, e.g. `"g g"`, `"C-c x"`) to a
|
|
||||||
`Command` enum, optionally parameterized (`{ SeekForward = { seconds = 10 } }`).
|
|
||||||
- A small chord state machine resolves sequences; bindings are layered:
|
|
||||||
built-in defaults ← user config (`~/.config/furumi/keymap.toml`).
|
|
||||||
- Bindings resolve per *input context* (Global, LibraryGrid, TrackList,
|
|
||||||
TextInput, Popup) so the same key can mean different things per view.
|
|
||||||
|
|
||||||
### Audio: rodio 0.22 + stream-download, behind a backend trait
|
|
||||||
|
|
||||||
Evaluated: **rodio**, kira, raw cpal+symphonia, gstreamer-rs, libmpv.
|
|
||||||
|
|
||||||
- **rodio 0.22** (`Player` / `DeviceSinkBuilder` API — note the 0.21/0.22 renames;
|
|
||||||
most older tutorials are outdated). Symphonia is the default decoder; enable
|
|
||||||
the `aac`, `isomp4`, `alac` features for m4a support. Pure Rust → trivial
|
|
||||||
cross-compilation; cpal covers CoreAudio / ALSA / WASAPI.
|
|
||||||
- **stream-download 0.24** bridges HTTP to rodio: background download exposing
|
|
||||||
blocking `Read + Seek`, built on reqwest (shares our authenticated client,
|
|
||||||
auth headers included), seek into undownloaded regions via HTTP Range
|
|
||||||
(the backend's `/stream/{id}` supports Range), temp-file storage, retries.
|
|
||||||
- kira — game-audio oriented, no network story, rejected.
|
|
||||||
- gstreamer / libmpv — best playback quality but heavy system dependencies;
|
|
||||||
not acceptable as the only backend for a portable CLI.
|
|
||||||
|
|
||||||
Playback lives behind a trait so backends can be added later (termusic ships
|
|
||||||
rodio + mpv + gstreamer this way):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
trait AudioBackend {
|
|
||||||
fn play(&mut self, source: TrackSource) -> Result<()>;
|
|
||||||
fn pause(&mut self); fn resume(&mut self);
|
|
||||||
fn seek(&mut self, pos: Duration) -> Result<()>;
|
|
||||||
fn set_volume(&mut self, v: f32);
|
|
||||||
fn position(&self) -> Duration;
|
|
||||||
fn events(&self) -> Receiver<PlayerEvent>; // TrackEnded, Failed, ...
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Gapless-ish playback: pre-open the `stream-download` source and decoder for the
|
`AppState` is the single UI source of truth. Rendering modules receive shared
|
||||||
next queue item and append it to the rodio `Player` before the current track
|
state and do not own background tasks. Blocking database and file operations
|
||||||
ends. (True gapless is impossible for AAC/M4A anyway — symphonia has no AAC
|
run outside the terminal event loop.
|
||||||
gapless trim.)
|
|
||||||
|
|
||||||
### Async runtime: tokio
|
## Module layout
|
||||||
|
|
||||||
Needed for: crossterm `EventStream`, reqwest, stream-download, device-sync
|
```text
|
||||||
polling, debounced search. The audio decode thread is rodio's own; everything
|
|
||||||
else is async tasks talking over channels.
|
|
||||||
|
|
||||||
## 2. Application architecture
|
|
||||||
|
|
||||||
Elm-style (TEA) core with a component-per-view UI layer — the pattern from the
|
|
||||||
official ratatui component template and spotify-player.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────────────┐
|
|
||||||
│ main loop │
|
|
||||||
│ recv Event -> keymap -> Action -> update() │
|
|
||||||
│ tick -> draw(&state) │
|
|
||||||
└───────▲──────────────────────────┬──────────┘
|
|
||||||
Event (mpsc) │ │ Command (spawn task / send msg)
|
|
||||||
┌─────────────────────┼──────────────┐ │
|
|
||||||
│ terminal input (crossterm stream) │ ┌───────▼────────┐
|
|
||||||
│ api task results │ │ side effects │
|
|
||||||
│ player events (TrackEnded, ...) │ │ api::Client │
|
|
||||||
│ device-sync poll results/commands │ │ player::Engine │
|
|
||||||
│ tick (render + position updates) │ │ sync::Poller │
|
|
||||||
└────────────────────────────────────┘ └────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Key rules:
|
|
||||||
|
|
||||||
- **Single source of truth**: one `AppState` struct, mutated only in `update()`.
|
|
||||||
Views are pure render functions over `&AppState`.
|
|
||||||
- **No blocking in the UI loop.** All I/O (HTTP, audio open) happens in spawned
|
|
||||||
tasks that report back via the event channel. Every remote list is a
|
|
||||||
`Loadable<T> { NotAsked, Loading, Loaded(T), Failed(Error) }` so views can
|
|
||||||
render spinners and errors honestly.
|
|
||||||
- **Input → Action indirection**: raw key events are translated by the keymap
|
|
||||||
into semantic `Action`s (`PlayPause`, `FocusNextTab`, `Select`, `Back`,
|
|
||||||
`SeekForward(10)`). Views never see raw keys; this is what makes bindings
|
|
||||||
configurable and the app testable.
|
|
||||||
|
|
||||||
### Module layout (single crate now, splittable later)
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
src/
|
||||||
main.rs // setup: terminal guard, tokio, channels, run loop
|
main.rs process, terminal, Tokio, and OS-media setup
|
||||||
config/ // Config + keymap loading (figment or manual TOML merge)
|
app/
|
||||||
api/ // typed client for /api/player/*
|
state.rs UI and navigation state
|
||||||
client.rs // reqwest wrapper: base_url, bearer auth, retries
|
action.rs semantic user actions
|
||||||
auth.rs // password login, token store, auto-refresh (15min TTL)
|
event.rs runtime-to-UI events
|
||||||
models.rs // ArtistCard, Release, TrackItem, PlaylistCard, ...
|
update.rs pure state transitions and requested effects
|
||||||
player/ // playback engine
|
update_tests.rs update/selection/queue behavior tests
|
||||||
backend.rs // AudioBackend trait
|
mod.rs runtime orchestration and effect execution
|
||||||
rodio_backend.rs // rodio Player + stream-download sources
|
popup.rs popup submission behavior
|
||||||
queue.rs // queue, shuffle, repeat_mode, next-track prefetch
|
input.rs editable text input
|
||||||
sync/ // connected devices: heartbeat/poll loop, command handling
|
command.rs command model
|
||||||
app/ // AppState, Action, Event, update()
|
cmdline.rs command-line execution
|
||||||
ui/ // ratatui rendering
|
library/
|
||||||
views/ // library_grid, artist, release, playlists, search,
|
mod.rs SQLite-backed library operations
|
||||||
// queue, devices, now_playing bar, popups
|
import.rs tags, audio metadata, and directory import
|
||||||
theme.rs
|
models.rs library-facing data types
|
||||||
|
tests.rs library integration tests
|
||||||
|
player/
|
||||||
|
mod.rs rodio playback controller
|
||||||
|
analyzer.rs visualization audio analysis
|
||||||
|
federation/
|
||||||
|
mod.rs DHT manager, search, downloads, and caching
|
||||||
|
catalog.rs peer catalog protocol and merge logic
|
||||||
|
audio.rs peer audio transport
|
||||||
|
tests.rs federation ranking/appearance tests
|
||||||
|
devices/
|
||||||
|
tests.rs trusted-device sync tests
|
||||||
|
devices.rs trusted-device operation log and wire protocol
|
||||||
|
ui/ ratatui rendering by screen
|
||||||
|
config/ settings, logging, and keymaps
|
||||||
|
media.rs platform media-key/now-playing integration
|
||||||
|
visualizer.rs Rhai visualization host
|
||||||
|
visualizations/ bundled Rhai scripts
|
||||||
|
art.rs image decode and terminal-cell preparation
|
||||||
|
share.rs share-link parsing and generation
|
||||||
|
streaming.rs growing-file reader used during downloads
|
||||||
```
|
```
|
||||||
|
|
||||||
The `api`, `player`, and `app` layers do not import `ui` or ratatui. If a
|
Large orchestration modules are intentionally separated from their tests.
|
||||||
shared core for furumi_macos/android ever makes sense, those modules extract
|
When they are split further, boundaries should follow services rather than
|
||||||
into workspace crates without surgery.
|
line count: playback coordination, network-library maintenance, device
|
||||||
|
storage, and device transport are the natural seams.
|
||||||
|
|
||||||
## 3. UI model
|
## Local library
|
||||||
|
|
||||||
Persistent layout: a tab bar on top, the active view in the middle, a
|
`library::Library` owns a mutex-protected SQLite connection. It is the only
|
||||||
now-playing/status bar at the bottom (track, position gauge, volume, shuffle/
|
layer that issues library SQL and returns typed models to the rest of the
|
||||||
repeat, active device indicator).
|
application. The schema covers artists, releases, tracks, artist relations,
|
||||||
|
playlists, likes, playback history, federated pending tracks, and cached
|
||||||
|
network artists.
|
||||||
|
|
||||||
Tabs (each owns a navigation stack, like a browser per tab):
|
Imports read tags with `lofty`, inspect audio properties, calculate content
|
||||||
|
identifiers, and upsert normalized library records. File paths remain
|
||||||
|
device-local.
|
||||||
|
|
||||||
1. **Library** — paginated grid of artist tiles (`GET /artists`).
|
## Playback
|
||||||
`Enter` on a tile pushes **Artist view** (`GET /artists/{id}`: metadata,
|
|
||||||
top tracks, releases list). Selecting a release pushes **Release view**
|
|
||||||
(`GET /releases/{id}`: metadata + track list). `Esc`/`Backspace` pops.
|
|
||||||
2. **Search** — debounced `GET /search?q=` with artists/releases/tracks sections.
|
|
||||||
3. **Playlists** — own + saved playlists, likes ("Liked tracks" virtual playlist).
|
|
||||||
4. **Queue** — current play queue, reorder/remove.
|
|
||||||
5. **Devices** — connected devices list, pick active device, transfer playback.
|
|
||||||
|
|
||||||
Navigation state is `Vec<Route>` per tab; a `Route` is an enum
|
`player::Controller` owns the rodio audio thread. The application maintains
|
||||||
(`ArtistGrid { page }`, `Artist { id }`, `Release { id }`, ...). Views cache
|
the logical queue and playback state, while the controller receives play,
|
||||||
their loaded data in `AppState` keyed by route so Back is instant.
|
pause, seek, volume, and prefetch commands. The next source is opened early
|
||||||
|
for gapless transitions. The analyzer publishes levels and scope samples for
|
||||||
|
Rhai visualization scripts.
|
||||||
|
|
||||||
Tile grid: computed from terminal width (`Layout` columns × rows), each tile a
|
OS media commands enter through `media.rs`; current metadata and position are
|
||||||
bordered block with artist name (cover art rendering in-terminal is a later,
|
published back to the platform now-playing surface.
|
||||||
optional feature — e.g. ratatui-image with kitty/sixel detection, never a
|
|
||||||
hard dependency).
|
|
||||||
|
|
||||||
## 4. Backend integration notes
|
## Federation
|
||||||
|
|
||||||
(Verified against the furumusic source; base path `/api/player`.)
|
Federation uses `music-dht` for discovery and byte streams:
|
||||||
|
|
||||||
- **Auth**: `POST /api/auth/password` → access token (15 min) + refresh token
|
- the local library publishes metadata-only item specifications;
|
||||||
(60 days). Client stores tokens at `~/.config/furumi/credentials.json`
|
- search merges DHT records, peer catalogs, and cached metadata;
|
||||||
(0600) and refreshes proactively via `POST /api/auth/refresh`. All API calls
|
- catalog requests provide richer artist/release views;
|
||||||
go through one client that retries once on 401 after refreshing.
|
- audio requests stream content from peers;
|
||||||
- **Streaming**: `GET /api/player/stream/{track_id}` with `Accept-Ranges:
|
- downloads may remain cached or be imported into the local library.
|
||||||
bytes` — exactly what stream-download needs for seek. Original files are
|
|
||||||
served untranscoded (mp3/flac/ogg/m4a/...), hence the symphonia feature set.
|
|
||||||
- **Playback state**: persisted server-side via `PUT /api/player/state`
|
|
||||||
(queue, position, shuffle, repeat, volume). We push throttled updates
|
|
||||||
(on track change + every ~10s while playing) and restore on startup.
|
|
||||||
- **History/scrobbling**: `POST /history` on track completion;
|
|
||||||
`POST /lastfm/now-playing` and `/lastfm/scrobble` if last.fm is connected.
|
|
||||||
- **Connected devices**: *polling, not websockets.* The sync task:
|
|
||||||
- sends `POST /devices/poll` every ~5s while the app runs (device TTL is
|
|
||||||
30s; commands TTL 20s) with our stable `device_id` (generated once,
|
|
||||||
persisted) and current `playback_state`;
|
|
||||||
- applies returned commands (`transfer_state` → load queue/position and
|
|
||||||
start/stop locally; play/pause/seek commands when we are the active
|
|
||||||
device but controlled remotely);
|
|
||||||
- feeds the device list into the Devices tab. Activating another device =
|
|
||||||
`POST /devices/active`; we then stop local audio and become a remote
|
|
||||||
control (UI keeps working, actions are sent via `POST /devices/command`).
|
|
||||||
- **Jams** (collaborative sessions) exist in the API — out of scope for v1,
|
|
||||||
but the sync task's command-handling design must not preclude them.
|
|
||||||
|
|
||||||
## 5. Reliability checklist
|
Federation is disabled until configured by the user. Paths are never
|
||||||
|
published as portable identifiers; content hashes and peer item IDs are used
|
||||||
|
instead.
|
||||||
|
|
||||||
- Terminal guard type + panic hook: raw mode/alternate screen/keyboard flags
|
## Trusted-device sync
|
||||||
always restored, even on panic.
|
|
||||||
- Every spawned task's failure becomes an `Event::TaskFailed` rendered as a
|
|
||||||
status-bar error — no silent hangs, no `unwrap` on I/O.
|
|
||||||
- Token refresh races guarded by a single-flight lock.
|
|
||||||
- Audio device disappearance (headphones unplugged) → backend emits
|
|
||||||
`PlayerEvent::Failed`, engine retries on default device, pauses on repeated
|
|
||||||
failure.
|
|
||||||
- Config/keymap parse errors are reported with line context and fall back to
|
|
||||||
defaults — a typo in keymap.toml must not brick the app.
|
|
||||||
|
|
||||||
## 6. Suggested initial dependencies
|
`devices.rs` implements a separate trusted-device protocol over a dedicated
|
||||||
|
ALPN. Likes, playlists, membership changes, and playback control are
|
||||||
|
represented as an append-only operation log with materialized SQLite tables.
|
||||||
|
Hybrid logical timestamps and acknowledgements make offline merging and
|
||||||
|
tombstone compaction deterministic.
|
||||||
|
|
||||||
```toml
|
Pairing uses short-lived invites. Device-local file paths are deliberately
|
||||||
[dependencies]
|
excluded from synchronized playback tracks; receiving devices resolve them
|
||||||
ratatui = "0.30"
|
through content IDs, their own library, or federation metadata.
|
||||||
crossterm = "0.29"
|
|
||||||
crokey = "1.4"
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
|
||||||
rodio = { version = "0.22", features = ["symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } # check exact feature names
|
|
||||||
stream-download = { version = "0.24", features = ["reqwest"] }
|
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
toml = "0.8"
|
|
||||||
thiserror = "2"
|
|
||||||
anyhow = "1"
|
|
||||||
directories = "6" # config/cache paths per-OS
|
|
||||||
tracing = "0.1" # file-based logging (never stdout — it's the UI)
|
|
||||||
tracing-subscriber = "0.3"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Build order (milestones)
|
## Configuration and persistence
|
||||||
|
|
||||||
1. Skeleton: terminal guard, event loop, tab bar, status bar, keymap with defaults.
|
The `directories` crate selects platform-standard config, data, and cache
|
||||||
2. `api` crate-module: auth + artists/releases/tracks; Library grid → Artist → Release navigation.
|
locations. Settings, keymaps, device identity, and federation configuration
|
||||||
3. Playback: rodio backend + stream-download, queue, now-playing bar, seek/volume.
|
are separate files. SQLite databases and downloaded covers/audio are stored
|
||||||
4. Likes, playlists, search, history reporting.
|
under application data/cache directories rather than the repository.
|
||||||
5. Device sync: heartbeat/poll, transfer playback, remote-control mode.
|
|
||||||
6. Polish: server-side state restore, last.fm, config file, themes, optional cover art.
|
## Reliability rules
|
||||||
|
|
||||||
|
- Terminal raw mode, bracketed paste, and keyboard enhancements are restored
|
||||||
|
on normal exit and panic.
|
||||||
|
- stderr from native audio libraries is captured into tracing so it cannot
|
||||||
|
corrupt the alternate screen.
|
||||||
|
- Blocking work is kept out of the UI loop.
|
||||||
|
- Runtime failures are converted to visible status/events where recovery is
|
||||||
|
possible.
|
||||||
|
- Device paths are not treated as portable network identities.
|
||||||
|
- Formatting, all-target compilation, Clippy, and unit tests should pass
|
||||||
|
before a release tag is pushed.
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version = "1.88"
|
||||||
|
description = "A federated P2P player for personal music libraries"
|
||||||
|
license = "WTFPL"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "furumi"
|
name = "furumi"
|
||||||
|
|||||||
@@ -1,188 +1,126 @@
|
|||||||
# furumi
|
# furumi
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
`furumi` is a cross-platform terminal client for a furumusic server. It
|
**Your music. Your devices. Your network.**
|
||||||
provides a fast TUI for browsing the library, playing music, managing the
|
|
||||||
queue and playlists, controlling devices, and inspecting logs without leaving
|
|
||||||
the terminal.
|
|
||||||
|
|
||||||
## Features
|
Furumi is a federated P2P player for your personal music library. Every
|
||||||
|
running player is a complete, self-sufficient music library: it can import,
|
||||||
|
organize, search, and play your collection without an account, a cloud
|
||||||
|
backend, or a central service.
|
||||||
|
|
||||||
- Browse the full artist library in tile or table view.
|
Connect Furumi on your desktop, laptop, or another device and they become one
|
||||||
- Open artist pages, releases, and track lists from inside the TUI.
|
personal music network. Playback, likes, playlists, and library state can move
|
||||||
- Search artists, releases, and tracks with `/`.
|
between your devices, while missing tracks can be requested directly from
|
||||||
- Play local audio with seek, volume, shuffle, repeat, and like controls.
|
another player.
|
||||||
- Add tracks next in queue, append them to the queue, or clear the queue.
|
|
||||||
- Browse playlists, liked tracks, and add tracks to playlists.
|
|
||||||
- Pick the active playback device and control remote devices.
|
|
||||||
- Use OS media keys through MPRIS/system media controls.
|
|
||||||
- Inspect live in-app logs and a persistent log file.
|
|
||||||
- Customize key bindings with a TOML keymap.
|
|
||||||
|
|
||||||
## Installation
|
Furumi runs on **Linux, macOS, and Windows**.
|
||||||
|
|
||||||
Requires Rust 1.88+.
|
## Why Furumi?
|
||||||
|
|
||||||
|
Music you own should not disappear because a subscription ended, a catalog
|
||||||
|
changed, a service was censored, or a server went offline.
|
||||||
|
|
||||||
|
Furumi is built around a different model:
|
||||||
|
|
||||||
|
- your library remains under your control;
|
||||||
|
- every player works independently;
|
||||||
|
- there is no central account or single point of failure;
|
||||||
|
- connecting more players improves availability instead of creating a new
|
||||||
|
dependency;
|
||||||
|
- federation is optional and simple to configure.
|
||||||
|
|
||||||
|
A single Furumi instance is already useful. A group of instances becomes a
|
||||||
|
resilient network for your music.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Each player maintains its own local library and publishes a searchable view of
|
||||||
|
it into Furumi's DHT network. Discovery does not depend on a central index.
|
||||||
|
|
||||||
|
Clients connect directly over P2P transport built on
|
||||||
|
[iroh](https://www.iroh.computer/). Furumi adds a synchronization protocol on
|
||||||
|
top of that transport to keep trusted devices consistent even when they are
|
||||||
|
not always online.
|
||||||
|
|
||||||
|
In practice:
|
||||||
|
|
||||||
|
1. Import music into any Furumi player.
|
||||||
|
2. Pair your other players or join a federation network.
|
||||||
|
3. Devices discover available libraries through the DHT.
|
||||||
|
4. Likes, playlists, playback state, and library metadata synchronize between
|
||||||
|
trusted clients.
|
||||||
|
5. If a track is missing locally, Furumi can fetch it directly from another
|
||||||
|
client.
|
||||||
|
|
||||||
|
There is no coordination server in the middle. Local players remain usable
|
||||||
|
when peers are offline, and the network becomes more capable as peers appear.
|
||||||
|
|
||||||
|
## The player
|
||||||
|
|
||||||
|
Furumi includes a full-featured terminal interface for browsing artists and
|
||||||
|
releases, searching, managing playlists and the queue, controlling playback,
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Download a prebuilt archive from the project releases, or build Furumi from
|
||||||
|
source with Rust 1.88 or newer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
cargo build --release --locked
|
||||||
./target/release/furumi
|
./target/release/furumi
|
||||||
```
|
```
|
||||||
|
|
||||||
The release binary is named `furumi`:
|
On Debian or Ubuntu, install the Linux audio build dependencies first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release --bin furumi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
Audio output needs the system ALSA library. PipeWire and PulseAudio are used
|
|
||||||
through the ALSA compatibility layer at runtime.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Debian / Ubuntu
|
|
||||||
sudo apt install libasound2-dev pkg-config
|
sudo apt install libasound2-dev pkg-config
|
||||||
|
|
||||||
# Fedora
|
|
||||||
sudo dnf install alsa-lib-devel pkgconf-pkg-config
|
|
||||||
|
|
||||||
# Arch
|
|
||||||
sudo pacman -S alsa-lib pkgconf
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything else is handled by Rust dependencies: TLS uses `rustls`, MPRIS uses
|
Equivalent ALSA development packages are required on other Linux
|
||||||
`zbus`, and image/audio decoding is provided by Rust crates.
|
distributions. macOS and Windows require no additional system packages.
|
||||||
|
|
||||||
### macOS and Windows
|
Import a music directory from Furumi's command line:
|
||||||
|
|
||||||
No extra system packages are required.
|
|
||||||
|
|
||||||
## First Run
|
|
||||||
|
|
||||||
On startup, `furumi` opens the login screen:
|
|
||||||
|
|
||||||
1. Enter your furumusic server URL.
|
|
||||||
2. Sign in with username/password or SSO.
|
|
||||||
3. After a successful login, the session is saved locally.
|
|
||||||
|
|
||||||
The SSO flow opens your browser automatically. If the loopback callback is not
|
|
||||||
available, `furumi` shows the URL and accepts either a pasted `furumi://...`
|
|
||||||
callback link or the short `furu_mx_...` code.
|
|
||||||
|
|
||||||
## Controls
|
|
||||||
|
|
||||||
Common key bindings:
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
| --- | --- |
|
|
||||||
| `?` | Show key binding help |
|
|
||||||
| `q`, `Ctrl-C` | Quit |
|
|
||||||
| `Tab`, `Shift-Tab` | Next / previous tab |
|
|
||||||
| `1`...`4` | Jump to a tab |
|
|
||||||
| `j` / `k`, arrows | Move down / up |
|
|
||||||
| `h` / `l`, arrows | Move left / right |
|
|
||||||
| `Enter` | Open or select item |
|
|
||||||
| `Esc`, `Backspace` | Go back |
|
|
||||||
| `Space` | Play / pause |
|
|
||||||
| `n`, `p` | Next / previous track |
|
|
||||||
| `.`, `,` | Seek 10 seconds forward / backward |
|
|
||||||
| `+`, `-` | Volume up / down |
|
|
||||||
| `s` | Toggle shuffle |
|
|
||||||
| `r` | Cycle repeat mode |
|
|
||||||
| `Shift-L` | Toggle fullscreen visualizer |
|
|
||||||
| `x` | Like / unlike |
|
|
||||||
| `a` | Add track next |
|
|
||||||
| `Shift-A` | Add track to the end of the queue |
|
|
||||||
| `Shift-P` | Add selected/current track(s) to a playlist |
|
|
||||||
| `i`, `Shift-I` | Track info / current track info |
|
|
||||||
| `Shift-D` | Delete selected item |
|
|
||||||
| `v` | Toggle tile/table view |
|
|
||||||
| `/` | Search |
|
|
||||||
| `:` | Open command line |
|
|
||||||
|
|
||||||
Command line examples:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
:q
|
:import /path/to/music
|
||||||
:logout
|
|
||||||
:volume 40
|
|
||||||
:seek +30
|
|
||||||
:seek -10
|
|
||||||
:seek 1:30
|
|
||||||
:shuffle
|
|
||||||
:repeat off
|
|
||||||
:repeat one
|
|
||||||
:repeat all
|
|
||||||
:clear
|
|
||||||
:next
|
|
||||||
:prev
|
|
||||||
:play
|
|
||||||
:pause
|
|
||||||
:devices
|
|
||||||
:logs debug
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
Federation, trusted-device pairing, and key bindings are configured directly
|
||||||
|
inside the player.
|
||||||
`furumi` stores configuration in the platform app config directory:
|
|
||||||
|
|
||||||
- Linux: `~/.config/furumi`
|
|
||||||
- macOS: `~/Library/Application Support/furumi`
|
|
||||||
- Windows: `%APPDATA%\furumi`
|
|
||||||
|
|
||||||
Important files:
|
|
||||||
|
|
||||||
- `credentials.json` - saved login session. On Unix it is written with `0600`
|
|
||||||
permissions.
|
|
||||||
- `device_id` - stable identifier for this TUI client during device sync.
|
|
||||||
- `keymap.toml` - user key binding overrides.
|
|
||||||
|
|
||||||
See [`src/config/default_keymap.toml`](src/config/default_keymap.toml) for the
|
|
||||||
default format. Example:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[[keymaps]]
|
|
||||||
key_sequence = "ctrl-n"
|
|
||||||
command = "NextTrack"
|
|
||||||
|
|
||||||
[[keymaps]]
|
|
||||||
key_sequence = "ctrl-f"
|
|
||||||
command = { SeekForward = { seconds = 30 } }
|
|
||||||
```
|
|
||||||
|
|
||||||
A user binding replaces the default binding with the same key sequence and
|
|
||||||
context.
|
|
||||||
|
|
||||||
## Logs
|
|
||||||
|
|
||||||
The Logs tab shows a live in-memory ring buffer inside the TUI. You can jump
|
|
||||||
to it and set the level filter with:
|
|
||||||
|
|
||||||
```text
|
|
||||||
:logs error
|
|
||||||
:logs warn
|
|
||||||
:logs info
|
|
||||||
:logs debug
|
|
||||||
:logs trace
|
|
||||||
```
|
|
||||||
|
|
||||||
The persistent log file is written to the platform cache directory as
|
|
||||||
`furumi-cli.log`. File logging is filtered by `RUST_LOG`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=furumi_tui=debug cargo run --release --bin furumi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
At a glance:
|
Furumi is a Rust application built with:
|
||||||
|
|
||||||
- UI: `ratatui` + `crossterm`.
|
- `ratatui` and `crossterm` for the cross-platform TUI;
|
||||||
- Runtime: `tokio`.
|
- `rodio` for local audio playback;
|
||||||
- HTTP: `reqwest` + `rustls`.
|
- SQLite for the personal library and synchronization state;
|
||||||
- Audio: `rodio` + `stream-download`.
|
- a dedicated DHT for decentralized discovery;
|
||||||
- Keymap config: `crokey` + TOML.
|
- iroh-based P2P streams for client-to-client communication;
|
||||||
- State model: one `AppState`, events, and an update loop.
|
- an offline-tolerant operation log for trusted-device synchronization;
|
||||||
|
- Rhai for programmable audio visualizations.
|
||||||
|
|
||||||
See [`ARCHITECTURE.md`](ARCHITECTURE.md) for more detail.
|
More detail is available in [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Bug reports, design discussions, and patches are welcome. Before submitting a
|
||||||
|
change, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo check --all-targets
|
||||||
|
cargo test --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Furumi is released under the
|
||||||
|
[Do What The Fuck You Want To Public License, Version 2](LICENSE).
|
||||||
|
|||||||
+2
-752
@@ -2814,755 +2814,5 @@ fn not_yet(state: &mut AppState, what: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "update_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-508
@@ -3538,511 +3538,5 @@ fn base64url_decode(value: &str) -> Result<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "devices/tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-98
@@ -2628,101 +2628,5 @@ fn ephemeral_track(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
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, "Ежемесячные");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-511
@@ -2745,514 +2745,5 @@ fn make_playlist_sync_id(id: i64, title: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
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