From 3556120e109e888543cf828240ebc6fdd85b4071 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Sun, 26 Jul 2026 02:07:07 +0300 Subject: [PATCH] Updated readme --- .github/workflows/release.yml | 1 + ARCHITECTURE.md | 338 ++++++--------- Cargo.toml | 3 + README.md | 260 +++++------- src/app/update.rs | 754 +--------------------------------- src/devices.rs | 510 +---------------------- src/federation/mod.rs | 100 +---- src/library/mod.rs | 513 +---------------------- 8 files changed, 234 insertions(+), 2245 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fbd94b..7cecf86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,7 @@ jobs: mkdir -p "$package_dir" cp "target/release/${{ matrix.binary_name }}" "$package_dir/" cp README.md "$package_dir/" + cp LICENSE "$package_dir/" if [[ "${{ runner.os }}" == "Windows" ]]; then (cd dist && 7z a "../${archive_name}" "${{ matrix.asset_name }}") diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e386d1b..c3f388d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,240 +1,148 @@ -# furumi_cli — Architecture +# furumi architecture -Cross-platform terminal client (cmus-style TUI) for the furumusic backend. -Targets: macOS, Linux (ALSA/Pulse/PipeWire), Windows (WASAPI, Windows Terminal). +`furumi` is a single Rust binary organized around an Elm-style state/update +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 - -Evaluated: **ratatui**, cursive, tui-realm, iocraft. - -- **ratatui 0.30.x** — the de-facto standard (gitui, yazi, spotify-player all use it). - 0.30 split the project into workspace crates (`ratatui-core`, `ratatui-widgets`, - `ratatui-crossterm`) with a stable core API. Stock widgets cover everything we - need: `Tabs`, `List`, `Table`, nested `Layout` for tile grids. -- cursive — maintenance mode since 2024, rejected. -- tui-realm — viable framework on top of ratatui (termusic uses it), but a - single-maintainer abstraction layer; we prefer plain ratatui with our own - thin component layer. -- iocraft — too young, optimized for inline CLI output rather than fullscreen apps. -- termion — Unix-only, eliminated (we need Windows). - -crossterm is the only backend that covers Windows. Caveats to handle: -- Enable kitty keyboard enhancement flags only when - `supports_keyboard_enhancement()` returns true; always pop flags on exit. -- 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; // TrackEnded, Failed, ... -} +```text +terminal/media/player/network events + | + v + app::event::AppEvent + | + v + input/keymap -> Action + | + v + app::update() + mutates AppState and + requests an Effect + | + v + app runtime performs I/O + | + +----> new AppEvent ``` -Gapless-ish playback: pre-open the `stream-download` source and decoder for the -next queue item and append it to the rodio `Player` before the current track -ends. (True gapless is impossible for AAC/M4A anyway — symphonia has no AAC -gapless trim.) +`AppState` is the single UI source of truth. Rendering modules receive shared +state and do not own background tasks. Blocking database and file operations +run outside the terminal event loop. -### Async runtime: tokio +## Module layout -Needed for: crossterm `EventStream`, reqwest, stream-download, device-sync -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 { 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) - -``` +```text src/ - main.rs // setup: terminal guard, tokio, channels, run loop - config/ // Config + keymap loading (figment or manual TOML merge) - api/ // typed client for /api/player/* - client.rs // reqwest wrapper: base_url, bearer auth, retries - auth.rs // password login, token store, auto-refresh (15min TTL) - models.rs // ArtistCard, Release, TrackItem, PlaylistCard, ... - player/ // playback engine - backend.rs // AudioBackend trait - rodio_backend.rs // rodio Player + stream-download sources - queue.rs // queue, shuffle, repeat_mode, next-track prefetch - sync/ // connected devices: heartbeat/poll loop, command handling - app/ // AppState, Action, Event, update() - ui/ // ratatui rendering - views/ // library_grid, artist, release, playlists, search, - // queue, devices, now_playing bar, popups - theme.rs + main.rs process, terminal, Tokio, and OS-media setup + app/ + state.rs UI and navigation state + action.rs semantic user actions + event.rs runtime-to-UI events + update.rs pure state transitions and requested effects + update_tests.rs update/selection/queue behavior tests + mod.rs runtime orchestration and effect execution + popup.rs popup submission behavior + input.rs editable text input + command.rs command model + cmdline.rs command-line execution + library/ + mod.rs SQLite-backed library operations + import.rs tags, audio metadata, and directory import + 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 -shared core for furumi_macos/android ever makes sense, those modules extract -into workspace crates without surgery. +Large orchestration modules are intentionally separated from their tests. +When they are split further, boundaries should follow services rather than +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 -now-playing/status bar at the bottom (track, position gauge, volume, shuffle/ -repeat, active device indicator). +`library::Library` owns a mutex-protected SQLite connection. It is the only +layer that issues library SQL and returns typed models to the rest of the +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`). - `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. +## Playback -Navigation state is `Vec` per tab; a `Route` is an enum -(`ArtistGrid { page }`, `Artist { id }`, `Release { id }`, ...). Views cache -their loaded data in `AppState` keyed by route so Back is instant. +`player::Controller` owns the rodio audio thread. The application maintains +the logical queue and playback state, while the controller receives play, +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 -bordered block with artist name (cover art rendering in-terminal is a later, -optional feature — e.g. ratatui-image with kitty/sixel detection, never a -hard dependency). +OS media commands enter through `media.rs`; current metadata and position are +published back to the platform now-playing surface. -## 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 - (60 days). Client stores tokens at `~/.config/furumi/credentials.json` - (0600) and refreshes proactively via `POST /api/auth/refresh`. All API calls - go through one client that retries once on 401 after refreshing. -- **Streaming**: `GET /api/player/stream/{track_id}` with `Accept-Ranges: - 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. +- the local library publishes metadata-only item specifications; +- search merges DHT records, peer catalogs, and cached metadata; +- catalog requests provide richer artist/release views; +- audio requests stream content from peers; +- downloads may remain cached or be imported into the local library. -## 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 - 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. +## Trusted-device sync -## 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 -[dependencies] -ratatui = "0.30" -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" -``` +Pairing uses short-lived invites. Device-local file paths are deliberately +excluded from synchronized playback tracks; receiving devices resolve them +through content IDs, their own library, or federation metadata. -## 7. Build order (milestones) +## Configuration and persistence -1. Skeleton: terminal guard, event loop, tab bar, status bar, keymap with defaults. -2. `api` crate-module: auth + artists/releases/tracks; Library grid → Artist → Release navigation. -3. Playback: rodio backend + stream-download, queue, now-playing bar, seek/volume. -4. Likes, playlists, search, history reporting. -5. Device sync: heartbeat/poll, transfer playback, remote-control mode. -6. Polish: server-side state restore, last.fm, config file, themes, optional cover art. +The `directories` crate selects platform-standard config, data, and cache +locations. Settings, keymaps, device identity, and federation configuration +are separate files. SQLite databases and downloaded covers/audio are stored +under application data/cache directories rather than the repository. + +## 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. diff --git a/Cargo.toml b/Cargo.toml index d1cadf2..2d246a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,9 @@ name = "furumi_tui" version = "0.1.6" edition = "2024" +rust-version = "1.88" +description = "A federated P2P player for personal music libraries" +license = "WTFPL" [[bin]] name = "furumi" diff --git a/README.md b/README.md index c975ee9..527b117 100644 --- a/README.md +++ b/README.md @@ -1,188 +1,126 @@ # furumi -![furumi TUI screenshot](furumi.png) +![furumi](furumi.png) -`furumi` is a cross-platform terminal client for a furumusic server. It -provides a fast TUI for browsing the library, playing music, managing the -queue and playlists, controlling devices, and inspecting logs without leaving -the terminal. +**Your music. Your devices. Your network.** -## 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. -- Open artist pages, releases, and track lists from inside the TUI. -- Search artists, releases, and tracks with `/`. -- Play local audio with seek, volume, shuffle, repeat, and like controls. -- 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. +Connect Furumi on your desktop, laptop, or another device and they become one +personal music network. Playback, likes, playlists, and library state can move +between your devices, while missing tracks can be requested directly from +another player. -## 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 -cargo build --release +cargo build --release --locked ./target/release/furumi ``` -The release binary is named `furumi`: +On Debian or Ubuntu, install the Linux audio build dependencies first: ```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 - -# 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 -`zbus`, and image/audio decoding is provided by Rust crates. +Equivalent ALSA development packages are required on other Linux +distributions. macOS and Windows require no additional system packages. -### macOS and Windows - -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: +Import a music directory from Furumi's command line: ```text -:q -: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 +:import /path/to/music ``` -## Configuration - -`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 -``` +Federation, trusted-device pairing, and key bindings are configured directly +inside the player. ## Architecture -At a glance: +Furumi is a Rust application built with: -- UI: `ratatui` + `crossterm`. -- Runtime: `tokio`. -- HTTP: `reqwest` + `rustls`. -- Audio: `rodio` + `stream-download`. -- Keymap config: `crokey` + TOML. -- State model: one `AppState`, events, and an update loop. +- `ratatui` and `crossterm` for the cross-platform TUI; +- `rodio` for local audio playback; +- SQLite for the personal library and synchronization state; +- a dedicated DHT for decentralized discovery; +- iroh-based P2P streams for client-to-client communication; +- 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). diff --git a/src/app/update.rs b/src/app/update.rs index dd9a47a..4e13f59 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2814,755 +2814,5 @@ fn not_yet(state: &mut AppState, what: &str) { } #[cfg(test)] -mod tests { - use super::*; - use crate::library::models::{ArtistCard, ArtistDetail, TrackItem}; - - fn with_artists(n: usize) -> AppState { - let mut state = AppState::default(); - state.global.artists = (0..n) - .map(|i| ArtistCard { - id: i as i64, - name: format!("artist {i}"), - image_path: None, - release_count: 1, - track_count: 2, - availability: crate::library::models::Availability::Local, - }) - .collect(); - state - } - - fn test_track(id: i64) -> TrackItem { - TrackItem { - id, - title: format!("t{id}"), - track_number: None, - disc_number: None, - duration_seconds: 1.0, - artists: vec![], - featured_artists: vec![], - release_id: 1, - release_title: "r".into(), - release_year: None, - cover_path: None, - file_path: format!("/s/{id}"), - content_id: Some(format!("b3:{id:064x}")), - audio_format: None, - audio_bitrate: None, - audio_sample_rate: None, - audio_bit_depth: None, - file_size_bytes: None, - play_count: 0, - fed: None, - } - } - - #[test] - fn quit_needs_double_press() { - let mut state = AppState::default(); - update(&mut state, Action::Quit); - assert!(!state.should_quit); - assert_eq!(state.status_message.as_deref(), Some(QUIT_CONFIRM_HINT)); - update(&mut state, Action::Quit); - assert!(state.should_quit); - } - - #[test] - fn other_action_disarms_quit() { - let mut state = AppState::default(); - update(&mut state, Action::Quit); - update(&mut state, Action::NextTab); - update(&mut state, Action::Quit); - assert!(!state.should_quit); - } - - #[test] - fn expired_quit_confirmation_rearms() { - let mut state = AppState::default(); - update(&mut state, Action::Quit); - state.quit_armed_until = Some(Instant::now() - Duration::from_secs(1)); - update(&mut state, Action::Quit); - assert!(!state.should_quit); - assert_eq!(state.status_message.as_deref(), Some(QUIT_CONFIRM_HINT)); - } - - #[test] - fn tab_cycling_wraps() { - let mut state = AppState::default(); - update(&mut state, Action::PrevTab); - assert_eq!(state.active_tab, Tab::Logs); - update(&mut state, Action::NextTab); - assert_eq!(state.active_tab, Tab::Global); - } - - #[test] - fn volume_clamps() { - let mut state = AppState::default(); - for _ in 0..30 { - update(&mut state, Action::VolumeUp); - } - assert_eq!(state.player.volume, 100); - for _ in 0..30 { - update(&mut state, Action::VolumeDown); - } - assert_eq!(state.player.volume, 0); - } - - #[test] - fn library_filters_popup_opens_on_library_screens() { - let mut state = AppState::default(); - update(&mut state, Action::OpenLibraryFilters); - assert!(matches!( - state.popup, - Some(crate::app::state::Popup::LibraryFilters { .. }) - )); - - state.popup = None; - state.global.stack.push(GlobalView::Search { cursor: 0 }); - update(&mut state, Action::OpenLibraryFilters); - assert!(matches!( - state.popup, - Some(crate::app::state::Popup::LibraryFilters { .. }) - )); - - state.popup = None; - state.active_tab = Tab::Queue; - update(&mut state, Action::OpenLibraryFilters); - assert!(state.popup.is_none()); - } - - #[test] - fn back_closes_help_first() { - let mut state = AppState::default(); - update(&mut state, Action::ToggleHelp); - assert!(state.help_visible); - update(&mut state, Action::Back); - assert!(!state.help_visible); - } - - #[test] - fn grid_movement_clamps_and_wraps_rows() { - let mut state = with_artists(10); - let cols = grid_columns(); - update(&mut state, Action::MoveDown); - assert_eq!(state.global.selected, cols.min(9)); - update(&mut state, Action::MoveUp); - assert_eq!(state.global.selected, 0); - update(&mut state, Action::MoveLeft); - assert_eq!(state.global.selected, 0); - update(&mut state, Action::MoveRight); - assert_eq!(state.global.selected, 1); - } - - #[test] - fn table_mode_moves_one_row() { - let mut state = with_artists(10); - state.global.view = ViewMode::Table; - update(&mut state, Action::MoveDown); - assert_eq!(state.global.selected, 1); - // Left/right are meaningless in the table. - update(&mut state, Action::MoveRight); - assert_eq!(state.global.selected, 1); - } - - #[test] - fn page_down_moves_a_page_and_clamps() { - let mut state = with_artists(200); - let cols = grid_columns() as isize; - let expected = (page_step(&state) * cols).min(199) as usize; - update(&mut state, Action::PageDown); - assert_eq!(state.global.selected, expected); - update(&mut state, Action::PageUp); - assert_eq!(state.global.selected, 0); - - state.global.view = ViewMode::Table; - update(&mut state, Action::PageDown); - assert_eq!(state.global.selected, page_step(&state).min(199) as usize); - } - - #[test] - fn jump_first_last() { - let mut state = with_artists(10); - update(&mut state, Action::SelectLast); - assert_eq!(state.global.selected, 9); - update(&mut state, Action::SelectFirst); - assert_eq!(state.global.selected, 0); - } - - #[test] - fn artist_tiles_move_by_visual_rows_across_groups() { - use crate::library::models::{ArtistDetail, ReleaseCard}; - - let release = |id: i64, kind: &str| ReleaseCard { - id, - title: format!("r{id}"), - release_type: kind.to_string(), - year: None, - cover_path: None, - track_count: 1, - availability: crate::library::models::Availability::Local, - }; - let columns = grid_columns(); - // The terminal size can be visible to tests. Build enough albums to - // force a short second album row for whichever width this run has. - let detail = ArtistDetail { - id: 1, - name: "a".into(), - image_path: None, - total_track_count: 0, - total_play_count: 0, - top_tracks: vec![], - featured_tracks: vec![], - releases: (0..=columns) - .map(|index| release(10 + index as i64, "album")) - .chain((0..2).map(|index| release(100 + index, "compilation"))) - .collect(), - }; - let mut state = AppState::default(); - state.artist_views.insert(1, Loadable::Ready(detail)); - state.global.stack.push(GlobalView::Artist { - id: 1, - cursor: columns + 1, - }); - - // Up from the first compilation lands on the album row directly - // above, not one flat grid-width jump back. - update(&mut state, Action::MoveUp); - assert_eq!( - state.global.stack.last(), - Some(&GlobalView::Artist { - id: 1, - cursor: columns - }) - ); - // And back down returns to the compilation row, same column. - update(&mut state, Action::MoveDown); - assert_eq!( - state.global.stack.last(), - Some(&GlobalView::Artist { - id: 1, - cursor: columns + 1 - }) - ); - // Up from the second compilation clamps to the single tile above. - state.global.stack.pop(); - state.global.stack.push(GlobalView::Artist { - id: 1, - cursor: columns + 2, - }); - update(&mut state, Action::MoveUp); - assert_eq!( - state.global.stack.last(), - Some(&GlobalView::Artist { - id: 1, - cursor: columns - }) - ); - } - - #[test] - fn select_opens_artist_and_back_returns() { - let mut state = with_artists(3); - state.global.selected = 2; - update(&mut state, Action::Select); - assert_eq!( - state.global.stack.last(), - Some(&GlobalView::Artist { id: 2, cursor: 0 }) - ); - update(&mut state, Action::Back); - assert!(state.global.stack.is_empty()); - assert_eq!(state.global.selected, 2); - } - - #[test] - fn back_from_search_resets_search_state() { - let mut state = AppState::default(); - state.global.stack.push(GlobalView::Search { cursor: 0 }); - state.search.query = "abc".to_string(); - update(&mut state, Action::Back); - assert!(state.global.stack.is_empty()); - assert!(state.search.query.is_empty()); - } - - #[test] - fn queue_advances_and_respects_repeat() { - use crate::app::state::RepeatMode; - use crate::library::models::TrackItem; - - let track = |id: i64| TrackItem { - id, - title: format!("t{id}"), - track_number: None, - disc_number: None, - duration_seconds: 1.0, - artists: vec![], - featured_artists: vec![], - release_id: 1, - release_title: "r".into(), - release_year: None, - cover_path: None, - file_path: format!("/api/player/stream/{id}"), - content_id: None, - audio_format: None, - audio_bitrate: None, - audio_sample_rate: None, - audio_bit_depth: None, - file_size_bytes: None, - play_count: 0, - fed: None, - }; - let mut state = AppState::default(); - state.player.queue = vec![track(1), track(2)]; - state.player.playing = true; - - // Track 1 finishes → play track 2. - assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent)); - assert_eq!(state.player.queue_pos, 1); - // Last track, repeat off → stop. - assert_eq!(advance_after_finish(&mut state), Some(Effect::StopPlayback)); - assert!(!state.player.playing); - // Repeat all wraps to the start. - state.player.playing = true; - state.player.repeat = RepeatMode::All; - assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent)); - assert_eq!(state.player.queue_pos, 0); - // Repeat one replays the same position. - state.player.repeat = RepeatMode::One; - assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent)); - assert_eq!(state.player.queue_pos, 0); - } - - #[test] - fn same_tab_number_resets_to_root() { - let mut state = with_artists(3); - update(&mut state, Action::Select); - assert!(!state.global.stack.is_empty()); - update(&mut state, Action::GoToTab(0)); - assert!(state.global.stack.is_empty()); - - state.playlists.opened = Some(OpenedPlaylist { - id: crate::app::state::LIKES_PLAYLIST_ID, - cursor: 0, - }); - update(&mut state, Action::GoToTab(1)); - assert_eq!(state.active_tab, Tab::Playlists); - assert!(state.playlists.opened.is_some()); - update(&mut state, Action::GoToTab(1)); - assert!(state.playlists.opened.is_none()); - } - - #[test] - fn queue_tab_select_and_clear() { - use crate::library::models::TrackItem; - let track = |id: i64| TrackItem { - id, - title: format!("t{id}"), - track_number: None, - disc_number: None, - duration_seconds: 1.0, - artists: vec![], - featured_artists: vec![], - release_id: 1, - release_title: "r".into(), - release_year: None, - cover_path: None, - file_path: format!("/s/{id}"), - content_id: None, - audio_format: None, - audio_bitrate: None, - audio_sample_rate: None, - audio_bit_depth: None, - file_size_bytes: None, - play_count: 0, - fed: None, - }; - let mut state = AppState { - active_tab: Tab::Queue, - ..AppState::default() - }; - state.player.queue = vec![track(1), track(2), track(3)]; - state.player.queue_pos = 2; - - // Cursor moves independently; enter rewinds playback to that track - // without dropping anything from the queue. - update(&mut state, Action::MoveUp); - update(&mut state, Action::MoveUp); - assert_eq!(state.queue_tab.cursor, 0); - assert_eq!( - update(&mut state, Action::Select), - Some(Effect::PlayCurrent) - ); - assert_eq!(state.player.queue_pos, 0); - assert_eq!(state.player.queue.len(), 3); - - assert_eq!( - update(&mut state, Action::ClearQueue), - Some(Effect::StopPlayback) - ); - assert!(state.player.queue.is_empty()); - assert!(!state.player.playing); - } - - #[test] - fn current_track_info_uses_now_playing_track() { - let mut state = AppState { - active_tab: Tab::Queue, - ..AppState::default() - }; - state.player.queue = vec![test_track(1), test_track(2)]; - state.queue_tab.cursor = 0; - state.player.current = Some(test_track(2)); - - assert_eq!(update(&mut state, Action::OpenCurrentTrackInfo), None); - match &state.popup { - Some(crate::app::state::Popup::TrackInfo { tracks, .. }) => { - assert_eq!( - tracks.iter().map(|track| track.id).collect::>(), - vec![2] - ); - } - other => panic!("expected track info popup, got {other:?}"), - } - } - - #[test] - fn visualizer_requires_current_track() { - let mut state = AppState::default(); - - assert_eq!(update(&mut state, Action::ToggleVisualizer), None); - - assert!(!state.visualizer.active); - assert_eq!( - state.status_message.as_deref(), - Some("nothing playing — start a track first") - ); - } - - #[test] - fn visualizer_toggles_and_back_closes_it() { - let mut state = AppState::default(); - state.player.current = Some(test_track(7)); - state.help_visible = true; - - assert_eq!(update(&mut state, Action::ToggleVisualizer), None); - assert!(state.visualizer.active); - assert!(state.visualizer.started_at.is_some()); - assert!(!state.help_visible); - - assert_eq!(update(&mut state, Action::Back), None); - assert!(!state.visualizer.active); - assert!(state.visualizer.started_at.is_none()); - } - - #[test] - fn add_to_playlist_from_release_carries_selected_track() { - use crate::app::state::{PlaylistAddTarget, Popup}; - use crate::library::models::ReleaseDetail; - - let mut state = AppState::default(); - state - .global - .stack - .push(GlobalView::Release { id: 1, cursor: 1 }); - state.release_views.insert( - 1, - Loadable::Ready(ReleaseDetail { - id: 1, - title: "r".into(), - release_type: "album".into(), - year: None, - cover_path: None, - artists: vec![], - tracks: vec![test_track(1), test_track(2)], - }), - ); - - assert_eq!(update(&mut state, Action::AddToPlaylist), None); - match &state.popup { - Some(Popup::AddToPlaylist { - target: PlaylistAddTarget::Local(tracks), - .. - }) => { - assert_eq!( - tracks.iter().map(|track| track.id).collect::>(), - vec![2] - ); - } - other => panic!("expected add-to-playlist popup with selected track, got {other:?}"), - } - } - - #[test] - fn add_to_playlist_from_non_track_view_uses_current_track() { - use crate::app::state::{PlaylistAddTarget, Popup}; - - let mut state = AppState { - active_tab: Tab::Logs, - ..AppState::default() - }; - state.player.current = Some(test_track(7)); - - assert_eq!(update(&mut state, Action::AddToPlaylist), None); - match &state.popup { - Some(Popup::AddToPlaylist { - target: PlaylistAddTarget::Local(tracks), - .. - }) => { - assert_eq!( - tracks.iter().map(|track| track.id).collect::>(), - vec![7] - ); - } - other => panic!("expected add-to-playlist popup with current track, got {other:?}"), - } - } - - #[test] - fn visual_selection_removes_queue_range() { - let mut state = AppState { - active_tab: Tab::Queue, - ..AppState::default() - }; - state.player.queue = (1..=4).map(test_track).collect(); - state.queue_tab.cursor = 1; - - assert_eq!(update(&mut state, Action::ToggleTrackSelection), None); - update(&mut state, Action::MoveDown); - - let selected: Vec = selected_tracks(&state) - .into_iter() - .map(|track| track.id) - .collect(); - assert_eq!(selected, vec![2, 3]); - assert_eq!( - update(&mut state, Action::RemoveFromQueue), - Some(Effect::RemoveQueueIndices { - indices: vec![1, 2], - restart_paused: None, - stop: false, - }) - ); - let remaining: Vec = state.player.queue.iter().map(|track| track.id).collect(); - assert_eq!(remaining, vec![1, 4]); - assert!(!state.track_selection.is_active()); - } - - #[test] - fn artist_top_track_selection_queues_all_selected_tracks() { - let mut state = AppState::default(); - state - .global - .stack - .push(GlobalView::Artist { id: 9, cursor: 0 }); - state.artist_views.insert( - 9, - Loadable::Ready(ArtistDetail { - id: 9, - name: "artist".into(), - image_path: None, - total_track_count: 3, - total_play_count: 0, - top_tracks: (1..=3).map(test_track).collect(), - releases: vec![], - featured_tracks: vec![], - }), - ); - - update(&mut state, Action::ToggleTrackSelection); - update(&mut state, Action::MoveDown); - assert_eq!( - update(&mut state, Action::QueueAddLast), - Some(Effect::PlaybackQueueChanged), - ); - let queued: Vec = state.player.queue.iter().map(|track| track.id).collect(); - assert_eq!(queued, vec![1, 2]); - assert!(!state.track_selection.is_active()); - } - - #[test] - fn removing_current_queue_track_requests_paused_restart() { - let mut state = AppState { - active_tab: Tab::Queue, - ..AppState::default() - }; - state.player.queue = (1..=3).map(test_track).collect(); - state.player.queue_pos = 1; - state.queue_tab.cursor = 1; - state.player.current = Some(test_track(2)); - state.player.playing = true; - state.player.paused = true; - - assert_eq!( - update(&mut state, Action::RemoveFromQueue), - Some(Effect::RemoveQueueIndices { - indices: vec![1], - restart_paused: Some(true), - stop: false, - }) - ); - let remaining: Vec = state.player.queue.iter().map(|track| track.id).collect(); - assert_eq!(remaining, vec![1, 3]); - assert_eq!(state.player.queue_pos, 1); - assert_eq!(state.player.current.as_ref().map(|track| track.id), Some(3)); - } - - #[test] - fn bulk_like_targets_only_tracks_that_need_toggle() { - let mut state = AppState { - active_tab: Tab::Queue, - ..AppState::default() - }; - state.player.queue = (1..=3).map(test_track).collect(); - state.likes.insert(format!("b3:{:064x}", 1)); - - update(&mut state, Action::ToggleTrackSelection); - update(&mut state, Action::SelectLast); - assert_eq!( - update(&mut state, Action::ToggleLike), - Some(Effect::ToggleLikes { - track_ids: vec![2, 3], - fed_tracks: vec![], - }) - ); - - state.likes = [1, 2, 3] - .into_iter() - .map(|id| format!("b3:{id:064x}")) - .collect(); - assert_eq!( - update(&mut state, Action::ToggleLike), - Some(Effect::ToggleLikes { - track_ids: vec![1, 2, 3], - fed_tracks: vec![], - }) - ); - } - - #[test] - fn shuffle_reorders_tail_and_restores() { - use crate::library::models::TrackItem; - let track = |id: i64| TrackItem { - id, - title: format!("t{id}"), - track_number: None, - disc_number: None, - duration_seconds: 1.0, - artists: vec![], - featured_artists: vec![], - release_id: 1, - release_title: "r".into(), - release_year: None, - cover_path: None, - file_path: format!("/s/{id}"), - content_id: None, - audio_format: None, - audio_bitrate: None, - audio_sample_rate: None, - audio_bit_depth: None, - file_size_bytes: None, - play_count: 0, - fed: None, - }; - let mut state = AppState::default(); - state.player.queue = (1..=8).map(track).collect(); - state.player.queue_pos = 2; - state.player.current = Some(track(3)); - - update(&mut state, Action::ToggleShuffle); - assert!(state.player.shuffle); - // Played part and the current track stay in place. - let ids: Vec = state.player.queue.iter().map(|t| t.id).collect(); - assert_eq!(&ids[..3], &[1, 2, 3]); - // The tail is a permutation of the original tail. - let mut tail = ids[3..].to_vec(); - tail.sort_unstable(); - assert_eq!(tail, vec![4, 5, 6, 7, 8]); - - update(&mut state, Action::ToggleShuffle); - assert!(!state.player.shuffle); - let restored: Vec = state.player.queue.iter().map(|t| t.id).collect(); - assert_eq!(restored, vec![1, 2, 3, 4, 5, 6, 7, 8]); - assert!(state.player.original_order.is_none()); - } - - #[test] - fn shift_j_opens_release_from_queue() { - use crate::library::models::{ReleaseDetail, TrackItem}; - let track = |id: i64, release_id: i64| TrackItem { - id, - title: format!("t{id}"), - track_number: None, - disc_number: None, - duration_seconds: 1.0, - artists: vec![], - featured_artists: vec![], - release_id, - release_title: "r".into(), - release_year: None, - cover_path: None, - file_path: format!("/s/{id}"), - content_id: None, - audio_format: None, - audio_bitrate: None, - audio_sample_rate: None, - audio_bit_depth: None, - file_size_bytes: None, - play_count: 0, - fed: None, - }; - let mut state = AppState { - active_tab: Tab::Queue, - ..AppState::default() - }; - state.player.queue = vec![track(1, 7), track(2, 7)]; - state.queue_tab.cursor = 1; - - // Release not loaded yet → jump queued as pending focus. - update(&mut state, Action::GoToRelease); - assert_eq!(state.active_tab, Tab::Global); - assert_eq!( - state.global.stack.last(), - Some(&GlobalView::Release { id: 7, cursor: 0 }) - ); - assert_eq!(state.pending_release_focus, Some((7, 2))); - - // Esc returns to the origin tab, not to the Global grid. - update(&mut state, Action::Back); - assert_eq!(state.active_tab, Tab::Queue); - assert!(state.global.stack.is_empty()); - assert!(state.jump_origin.is_none()); - - // With the release cached, the cursor lands on the track directly. - state.global.stack.clear(); - state.pending_release_focus = None; - state.release_views.insert( - 7, - Loadable::Ready(ReleaseDetail { - id: 7, - title: "r".into(), - release_type: "album".into(), - year: None, - cover_path: None, - artists: vec![], - tracks: vec![track(1, 7), track(2, 7)], - }), - ); - state.active_tab = Tab::Queue; - update(&mut state, Action::GoToRelease); - assert_eq!( - state.global.stack.last(), - Some(&GlobalView::Release { id: 7, cursor: 1 }) - ); - assert!(state.pending_release_focus.is_none()); - } - - #[test] - fn view_toggle() { - let mut state = AppState::default(); - update(&mut state, Action::ToggleViewMode); - assert_eq!(state.global.view, ViewMode::Table); - update(&mut state, Action::ToggleViewMode); - assert_eq!(state.global.view, ViewMode::Tiles); - } -} +#[path = "update_tests.rs"] +mod tests; diff --git a/src/devices.rs b/src/devices.rs index e64c49d..7fca063 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -3538,511 +3538,5 @@ fn base64url_decode(value: &str) -> Result> { } #[cfg(test)] -mod tests { - use super::*; - - static NEXT_TEST_DB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - - fn test_sync() -> DeviceSync { - let conn = Connection::open_in_memory().unwrap(); - init_schema(&conn).unwrap(); - let unique = NEXT_TEST_DB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let library_path = std::env::temp_dir().join(format!( - "furumi-devices-test-{}-{}-{}.sqlite3", - std::process::id(), - now_ms(), - unique - )); - let sync = DeviceSync { - conn: Arc::new(std::sync::Mutex::new(conn)), - library: Arc::new(Library::open(&library_path).unwrap()), - event_tx: Arc::new(std::sync::Mutex::new(None)), - playback: Arc::new(std::sync::Mutex::new(PlaybackShared::default())), - }; - sync.ensure_identity().unwrap(); - sync - } - - fn device_revoked(sync: &DeviceSync, device_id: &str) -> bool { - let conn = lock(&sync.conn); - conn.query_row( - "SELECT revoked_at_ms IS NOT NULL - FROM sync_devices - WHERE device_id = ?1", - [device_id], - |row| row.get::<_, i64>(0), - ) - .optional() - .unwrap() - .unwrap_or(0) - != 0 - } - - fn device_known(sync: &DeviceSync, device_id: &str) -> bool { - let conn = lock(&sync.conn); - conn.query_row( - "SELECT 1 FROM sync_devices WHERE device_id = ?1", - [device_id], - |row| row.get::<_, i64>(0), - ) - .optional() - .unwrap() - .is_some() - } - - fn test_fed_track(content_id: &str) -> crate::federation::FedTrack { - crate::federation::FedTrack { - item_id: "fed_item_1".to_string(), - owner: "fed_owner_1".to_string(), - own: false, - title: "Remote Song".to_string(), - artist_names: vec!["Remote Artist".to_string()], - featured_artist_names: Vec::new(), - year: Some(2026), - duration_seconds: Some(123), - content_id: Some(content_id.to_string()), - release_title: Some("Remote Release".to_string()), - track_number: Some(1), - disc_number: Some(1), - } - } - - #[test] - fn base64url_round_trip_without_padding() { - for input in [b"".as_slice(), b"a", b"ab", b"abc", b"abcdef"] { - let encoded = base64url_encode(input); - assert!(!encoded.contains('=')); - assert_eq!(base64url_decode(&encoded).unwrap(), input); - } - } - - #[test] - fn tombstone_detection() { - assert!( - SyncOpPayload::TrackLikeSet { - content_id: "b3:0".into(), - liked: false, - fed: None, - } - .is_tombstone() - ); - assert!( - !SyncOpPayload::TrackLikeSet { - content_id: "b3:0".into(), - liked: true, - fed: None, - } - .is_tombstone() - ); - } - - #[test] - fn playback_tracks_do_not_sync_device_local_paths() { - let source = TrackItem { - id: 7, - title: "Local Song".to_string(), - track_number: Some(1), - disc_number: Some(1), - duration_seconds: 180.0, - artists: vec![ArtistRef { - id: 1, - name: "Local Artist".to_string(), - }], - featured_artists: Vec::new(), - release_id: 2, - release_title: "Local Release".to_string(), - release_year: Some(2026), - file_path: r"C:\Users\me\Music\song.mp3".to_string(), - content_id: Some(format!("b3:{}", "a".repeat(64))), - cover_path: None, - audio_format: Some("mp3".to_string()), - audio_bitrate: Some(320), - audio_sample_rate: Some(44_100), - audio_bit_depth: None, - file_size_bytes: Some(123_456), - play_count: 3, - fed: None, - }; - - let wire = PlaybackTrack::from_track(&source); - assert!(wire.file_path.is_empty()); - - let mut legacy_wire = wire.clone(); - legacy_wire.file_path = "/Users/me/Music/song.mp3".to_string(); - let restored = legacy_wire.to_track_item(); - assert!(restored.id < 0); - assert_ne!(restored.id, source.id); - assert!(restored.file_path.is_empty()); - assert_eq!(restored.content_id, source.content_id); - } - - #[test] - fn compacted_device_revoke_removes_device_row() { - let sync = test_sync(); - let device_id = "dev_old"; - - sync.apply_device_trusted(device_id, 10).unwrap(); - assert!(device_known(&sync, device_id)); - - sync.revoke_device(device_id).unwrap(); - assert!(!device_known(&sync, device_id)); - } - - #[test] - fn leave_group_self_revokes_then_resets_to_new_group() { - let sync = test_sync(); - let identity = sync.ensure_identity().unwrap(); - let old_group = identity.group_id.clone(); - sync.apply_device_trusted("dev_peer", 10).unwrap(); - - let op_id = sync.record_leave_group_revoke().unwrap(); - assert!(device_revoked(&sync, &identity.device_id)); - { - let conn = lock(&sync.conn); - let payload_json: String = conn - .query_row( - "SELECT payload_json FROM sync_ops WHERE op_id = ?1", - [&op_id], - |row| row.get(0), - ) - .unwrap(); - let payload: SyncOpPayload = serde_json::from_str(&payload_json).unwrap(); - match payload { - SyncOpPayload::DeviceRevoked { - target_device_id, - target_max_seq_seen, - } => { - assert_eq!(target_device_id, identity.device_id); - assert_eq!(target_max_seq_seen, 1); - } - other => panic!("unexpected payload: {other:?}"), - } - } - - let new_group = sync.finish_leave_group_reset().unwrap(); - assert_ne!(old_group, new_group); - let status = sync.status(); - assert_eq!(status.group_id, new_group); - assert_eq!(status.active_devices, 1); - assert_eq!(status.devices.len(), 1); - assert!(status.devices[0].is_self); - assert!(!status.devices[0].revoked); - assert_eq!(status.ops_total, 0); - assert_eq!(status.outbox_ops, 0); - assert!(!device_known(&sync, "dev_peer")); - } - - #[test] - fn playback_command_is_targeted_and_deduplicated() { - let sync = test_sync(); - let identity = sync.ensure_identity().unwrap(); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - sync.set_event_tx(tx); - let command = PlaybackCommand::SetState { - state: PlaybackStateWire { - queue: Vec::new(), - queue_pos: 0, - playing: false, - paused: false, - idle_since_ms: None, - position_secs: 0.0, - volume: 42, - shuffle: false, - repeat: PlaybackRepeat::Off, - }, - seek: false, - }; - - sync.apply_playback_command("dev_other", &command, "op_other") - .unwrap(); - assert!(rx.try_recv().is_err()); - - sync.apply_playback_command(&identity.device_id, &command, "op_1") - .unwrap(); - assert!(matches!( - rx.try_recv().unwrap(), - crate::app::event::AppEvent::PlaybackCommand(_) - )); - - sync.apply_playback_command(&identity.device_id, &command, "op_1") - .unwrap(); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn newer_device_trust_reactivates_revoked_device() { - let sync = test_sync(); - let device_id = "dev_readd"; - - sync.apply_device_trusted(device_id, 10).unwrap(); - assert!(!device_revoked(&sync, device_id)); - - sync.apply_device_revoked(device_id, 20, "dev_owner", 0) - .unwrap(); - assert!(device_revoked(&sync, device_id)); - - sync.apply_device_trusted(device_id, 30).unwrap(); - assert!(!device_revoked(&sync, device_id)); - - sync.apply_device_revoked(device_id, 25, "dev_owner", 0) - .unwrap(); - assert!(!device_revoked(&sync, device_id)); - - sync.apply_device_profile( - &DeviceProfileWire { - device_id: device_id.to_string(), - name: "readded".to_string(), - client_version: CLIENT_VERSION.to_string(), - protocol_version: PROTOCOL_VERSION, - endpoint_id: String::new(), - endpoint_ticket: String::new(), - revoked: true, - revoke_cutoff_seq: Some(0), - updated_at_ms: 20, - }, - false, - ) - .unwrap(); - assert!(!device_revoked(&sync, device_id)); - } - - #[test] - fn tombstone_gc_waits_for_every_active_remote_ack() { - let sync = test_sync(); - let origin = sync.ensure_identity().unwrap().device_id; - sync.apply_device_trusted("dev_a", 1).unwrap(); - sync.apply_device_trusted("dev_b", 1).unwrap(); - - sync.record_local_op(SyncOpPayload::PlaylistDeleted { - playlist_id: "pl_deleted".to_string(), - }) - .unwrap(); - { - let conn = lock(&sync.conn); - let tombstones: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(tombstones, 1); - } - - let ack = BTreeMap::from([(origin, 1)]); - sync.note_peer_vector("dev_a", &ack).unwrap(); - sync.gc_tombstones().unwrap(); - { - let conn = lock(&sync.conn); - let tombstones: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(tombstones, 1); - } - - sync.note_peer_vector("dev_b", &ack).unwrap(); - sync.gc_tombstones().unwrap(); - let conn = lock(&sync.conn); - let tombstones: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(tombstones, 0); - } - - #[test] - fn snapshot_carries_deleted_playlists_to_repair_stale_peers() { - let source = test_sync(); - let source_playlist = source.library.create_playlist("Gone").unwrap(); - let playlist_sync_id = source - .library - .ensure_playlist_sync_id(source_playlist.id) - .unwrap(); - source - .apply_playlist_state(&playlist_sync_id, "Gone", false, 10, "dev_remote:1") - .unwrap(); - source - .apply_playlist_state(&playlist_sync_id, "", true, 20, "dev_remote:2") - .unwrap(); - let snapshot = source.snapshot().unwrap(); - assert!( - snapshot - .deleted_playlists - .iter() - .any(|playlist| playlist.playlist_id == playlist_sync_id) - ); - - let peer = test_sync(); - let peer_playlist = peer - .library - .upsert_synced_playlist(&playlist_sync_id, "Gone") - .unwrap(); - assert!(peer.library.playlist(peer_playlist).is_ok()); - - peer.apply_snapshot(snapshot).unwrap(); - assert!( - !peer - .library - .playlists() - .unwrap() - .iter() - .any(|playlist| playlist.title == "Gone") - ); - } - - #[test] - fn synced_fed_like_metadata_repairs_existing_like_state() { - let sync = test_sync(); - let content_id = format!("b3:{}", "a".repeat(64)); - let fed = test_fed_track(&content_id); - let synced = SyncedFedTrack::from_fed(&fed).unwrap(); - - assert!( - sync.apply_like_state(&content_id, true, None, 10, "dev_remote:1") - .unwrap() - ); - assert!(sync.library.fed_like_ids().unwrap().is_empty()); - - assert!( - sync.apply_like_state(&content_id, true, Some(&synced), 10, "dev_remote:1") - .unwrap() - ); - let keys = sync.library.fed_like_ids().unwrap(); - assert!(keys.contains(&fed.item_id)); - assert!(keys.contains(&content_id)); - - assert!( - sync.apply_like_state(&content_id, false, None, 11, "dev_remote:2") - .unwrap() - ); - assert!(sync.library.fed_like_ids().unwrap().is_empty()); - } - - #[test] - fn synced_fed_likes_are_ordered_by_hlc_not_receive_time() { - let sync = test_sync(); - let old_content_id = format!("b3:{}", "c".repeat(64)); - let new_content_id = format!("b3:{}", "d".repeat(64)); - let mut old_fed = test_fed_track(&old_content_id); - old_fed.item_id = "fed_old".to_string(); - old_fed.title = "Old Fed".to_string(); - let mut new_fed = test_fed_track(&new_content_id); - new_fed.item_id = "fed_new".to_string(); - new_fed.title = "New Fed".to_string(); - - let new_synced = SyncedFedTrack::from_fed(&new_fed).unwrap(); - let old_synced = SyncedFedTrack::from_fed(&old_fed).unwrap(); - sync.apply_like_state(&new_content_id, true, Some(&new_synced), 20, "dev_remote:2") - .unwrap(); - sync.apply_like_state(&old_content_id, true, Some(&old_synced), 10, "dev_remote:1") - .unwrap(); - - let titles: Vec = sync - .library - .playlist(crate::library::LIKES_PLAYLIST_ID) - .unwrap() - .tracks - .into_iter() - .map(|track| track.title) - .collect(); - assert_eq!(titles, vec!["New Fed", "Old Fed"]); - } - - #[test] - fn synced_playlist_item_metadata_creates_pending_fed_track() { - let sync = test_sync(); - let playlist = sync.library.create_playlist("Remote Mix").unwrap(); - let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap(); - let content_id = format!("b3:{}", "b".repeat(64)); - let fed = test_fed_track(&content_id); - let synced = SyncedFedTrack::from_fed(&fed).unwrap(); - - assert!( - sync.apply_playlist_item_state( - &playlist_sync_id, - &content_id, - true, - 3, - Some(&synced), - 10, - "dev_remote:2", - ) - .unwrap() - ); - - let detail = sync.library.playlist(playlist.id).unwrap(); - assert_eq!(detail.tracks.len(), 1); - assert!(detail.tracks[0].is_fed_pending()); - assert_eq!(detail.tracks[0].title, fed.title); - - let conn = lock(&sync.conn); - assert_eq!( - sync.unresolved_playlist_item_count_with_conn(&conn) - .unwrap(), - 0 - ); - drop(conn); - - assert!( - sync.apply_playlist_item_state( - &playlist_sync_id, - &content_id, - false, - 0, - None, - 11, - "dev_remote:3", - ) - .unwrap() - ); - assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0); - } - - #[test] - fn stale_synced_playlist_item_metadata_repairs_pending_fed_track() { - let sync = test_sync(); - let playlist = sync.library.create_playlist("Remote Mix").unwrap(); - let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap(); - let content_id = format!("b3:{}", "e".repeat(64)); - let fed = test_fed_track(&content_id); - let synced = SyncedFedTrack::from_fed(&fed).unwrap(); - - assert!( - sync.apply_playlist_item_state( - &playlist_sync_id, - &content_id, - true, - 7, - None, - 10, - "dev_remote:2", - ) - .unwrap() - ); - assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0); - - assert!( - sync.apply_playlist_item_state( - &playlist_sync_id, - &content_id, - true, - 7, - Some(&synced), - 10, - "dev_remote:2", - ) - .unwrap() - ); - let detail = sync.library.playlist(playlist.id).unwrap(); - assert_eq!(detail.tracks.len(), 1); - assert!(detail.tracks[0].is_fed_pending()); - assert_eq!(detail.tracks[0].title, fed.title); - } -} +#[path = "devices/tests.rs"] +mod tests; diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 09711f0..e86fbb1 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -2628,101 +2628,5 @@ fn ephemeral_track( } #[cfg(test)] -mod tests { - use super::*; - - fn test_owner() -> EndpointId { - music_dht::SecretKey::from_bytes(&[7; 32]).public() - } - - fn dht_track(main: &[&str], featured: &[&str]) -> LibraryItem { - let owner = test_owner(); - LibraryItem { - id: music_dht::ItemId::derive(&owner, ItemKind::Track, "track:1"), - owner, - kind: ItemKind::Track, - name: "Guest Verse".into(), - normalized_name: music_dht::normalize_name("Guest Verse"), - artist_names: main.iter().map(|name| name.to_string()).collect(), - featured_artist_names: featured.iter().map(|name| name.to_string()).collect(), - year: Some(2024), - release_type: Some("album".into()), - release_title: Some("Host Album".into()), - track_number: Some(2), - disc_number: Some(1), - duration_seconds: Some(180.0), - content_id: Some( - "b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), - ), - revision: 1, - deleted: false, - updated_at_ms: 0, - } - } - - #[test] - fn dht_appearance_requires_explicit_featured_artist() { - let normalized = music_dht::normalize_name("Guest"); - assert!(dht_appearance_hit(&dht_track(&["Guest"], &[]), &normalized, "Guest").is_none()); - - let hit = - dht_appearance_hit(&dht_track(&["Host"], &["Guest"]), &normalized, "Guest").unwrap(); - assert_eq!(hit.release_title, "Host Album"); - assert_eq!(hit.release_type, "album"); - assert_eq!(hit.year, Some(2024)); - assert_eq!(hit.track.artists, vec!["Host"]); - assert_eq!(hit.track.featured_artists, vec!["Guest"]); - assert_eq!(hit.track.track_number, Some(2)); - assert_eq!(hit.track.disc_number, Some(1)); - } - - #[test] - fn federation_search_ranks_exact_names_first() { - let normalized = music_dht::normalize_name("ежемесячные"); - let mut artists = vec![ - FedArtistHit { - name: "Booker".into(), - peers: 3, - }, - FedArtistHit { - name: "Ежемесячные".into(), - peers: 1, - }, - ]; - let mut tracks = vec![ - FedTrack { - item_id: "a".into(), - owner: "peer-a".into(), - own: false, - title: "Гость".into(), - artist_names: vec!["Other".into()], - featured_artist_names: vec!["Ежемесячные".into()], - year: None, - duration_seconds: None, - content_id: None, - release_title: None, - track_number: None, - disc_number: None, - }, - FedTrack { - item_id: "b".into(), - owner: "peer-b".into(), - own: false, - title: "Ежемесячные".into(), - artist_names: vec!["Other".into()], - featured_artist_names: Vec::new(), - year: None, - duration_seconds: None, - content_id: None, - release_title: None, - track_number: None, - disc_number: None, - }, - ]; - - rank_fed_search_results(&mut artists, &mut tracks, &normalized); - - assert_eq!(artists[0].name, "Ежемесячные"); - assert_eq!(tracks[0].title, "Ежемесячные"); - } -} +#[path = "tests.rs"] +mod tests; diff --git a/src/library/mod.rs b/src/library/mod.rs index 2e82ba3..bf157ba 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -2745,514 +2745,5 @@ fn make_playlist_sync_id(id: i64, title: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn test_library() -> Library { - let conn = Connection::open_in_memory().unwrap(); - conn.pragma_update(None, "foreign_keys", "ON").unwrap(); - register_norm_function(&conn).unwrap(); - conn.execute_batch(SCHEMA).unwrap(); - Library { - conn: Mutex::new(conn), - db_path: std::env::temp_dir().join("furumi-test-library.db"), - covers_dir: std::env::temp_dir().join("furumi-test-covers-unused"), - } - } - - fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 { - add_track_with_featured(lib, title, artist, &[], album) - } - - fn add_track_with_featured( - lib: &Library, - title: &str, - artist: &str, - featured: &[&str], - album: &str, - ) -> i64 { - let import = import::TrackImport { - release_type: None, - file_path: format!("/music/{artist}/{album}/{title}.mp3"), - title: title.to_string(), - artists: vec![artist.to_string()], - featured_artists: featured.iter().map(|name| (*name).to_string()).collect(), - album_artists: vec![artist.to_string()], - release_title: album.to_string(), - year: Some(2020), - track_number: None, - disc_number: None, - duration_seconds: 60.0, - audio_format: Some("mp3".into()), - audio_bitrate: Some(320), - audio_sample_rate: Some(44100), - audio_bit_depth: None, - file_size_bytes: Some(1), - cover: None, - }; - let id = import::upsert_track(lib, &import).unwrap().0; - let content_id = format!("b3:{}", blake3::hash(import.file_path.as_bytes()).to_hex()); - lib.lock() - .execute( - "UPDATE tracks SET content_id = ?2 WHERE id = ?1", - params![id, content_id], - ) - .unwrap(); - id - } - - fn artist_filters(hide_featured_only: bool) -> crate::config::settings::LibraryFilters { - crate::config::settings::LibraryFilters { - hide_featured_only, - ..Default::default() - } - } - - #[test] - fn local_stats_counts_library_rows_and_audio_bytes() { - let lib = test_library(); - add_track(&lib, "One", "Artist", "First"); - add_track(&lib, "Two", "Artist", "Second"); - - let stats = lib.local_stats().unwrap(); - assert_eq!(stats.artist_count, 1); - assert_eq!(stats.release_count, 2); - assert_eq!(stats.track_count, 2); - assert_eq!(stats.audio_bytes, 2); - assert_eq!(stats.tracks_without_size, 0); - } - - #[test] - fn artists_page_prioritizes_releases_then_tracks() { - let lib = test_library(); - add_track(&lib, "Solo", "Zed", "Zed Album"); - add_track_with_featured(&lib, "Guest One", "A Host", &["Guest"], "A Host Album"); - add_track_with_featured(&lib, "Guest Two", "B Host", &["Guest"], "B Host Album"); - - let page = lib.artists(1, 10, artist_filters(false)).unwrap(); - let zed_pos = page - .items - .iter() - .position(|artist| artist.name == "Zed") - .unwrap(); - let guest_pos = page - .items - .iter() - .position(|artist| artist.name == "Guest") - .unwrap(); - let guest = &page.items[guest_pos]; - - assert_eq!(guest.release_count, 0); - assert_eq!(guest.track_count, 2); - assert!(zed_pos < guest_pos); - - let filtered = lib.artists(1, 10, artist_filters(true)).unwrap(); - assert!(filtered.items.iter().all(|artist| artist.release_count > 0)); - assert!(!filtered.items.iter().any(|artist| artist.name == "Guest")); - } - - #[test] - fn network_artist_image_hint_becomes_local_image_after_fetch() { - let lib = test_library(); - let artist_key = music_dht::normalize_name("Remote Artist"); - lib.replace_network_artist_cache( - "peer-a", - "personal", - &[NetworkArtistPreview { - artist_key: artist_key.clone(), - name: "Remote Artist".into(), - image_path: Some("peer-local/image.jpg".into()), - release_count: 1, - track_count: 3, - }], - true, - ) - .unwrap(); - - let filters = crate::config::settings::LibraryFilters { - source_mode: crate::config::settings::LibrarySourceMode::My, - ..Default::default() - }; - let page = lib.artists(1, 10, filters).unwrap(); - assert_eq!(page.items[0].image_path, None); - - let requests = lib - .network_artist_image_requests(filters, &["Remote Artist".into()], 8) - .unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].source_id, "peer-a"); - assert_eq!(requests[0].artist_key, artist_key); - - lib.set_network_artist_image("peer-a", &artist_key, "/tmp/remote-artist.jpg") - .unwrap(); - let page = lib.artists(1, 10, filters).unwrap(); - assert_eq!( - page.items[0].image_path.as_deref(), - Some("/tmp/remote-artist.jpg") - ); - } - - #[test] - fn import_creates_artist_release_track() { - let lib = test_library(); - let track_id = add_track(&lib, "Song", "Artist", "Album"); - let page = lib.artists(1, 10, artist_filters(false)).unwrap(); - assert_eq!(page.total, 1); - assert_eq!(page.items[0].name, "Artist"); - assert_eq!(page.items[0].track_count, 1); - - let detail = lib.artist(page.items[0].id).unwrap(); - assert_eq!(detail.releases.len(), 1); - assert_eq!(detail.top_tracks.len(), 1); - - let release = lib.release(detail.releases[0].id).unwrap(); - assert_eq!(release.tracks.len(), 1); - assert_eq!(release.tracks[0].id, track_id); - assert_eq!(release.tracks[0].artists[0].name, "Artist"); - } - - #[test] - fn reimport_updates_instead_of_duplicating() { - let lib = test_library(); - let first = add_track(&lib, "Song", "Artist", "Album"); - let second = add_track(&lib, "Song", "Artist", "Album"); - assert_eq!(first, second); - let page = lib.artists(1, 10, artist_filters(false)).unwrap(); - assert_eq!(page.items[0].track_count, 1); - } - - #[test] - fn content_id_backfill_hashes_missing_track_ids() { - let lib = test_library(); - let path = std::env::temp_dir().join(format!( - "furumi-content-id-test-{}-{}.bin", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::write(&path, b"portable content id").unwrap(); - let file_path = path.to_string_lossy().into_owned(); - let import = import::TrackImport { - release_type: None, - file_path: file_path.clone(), - title: "Portable".to_string(), - artists: vec!["Artist".to_string()], - featured_artists: Vec::new(), - album_artists: vec!["Artist".to_string()], - release_title: "Album".to_string(), - year: Some(2026), - track_number: None, - disc_number: None, - duration_seconds: 60.0, - audio_format: Some("bin".into()), - audio_bitrate: None, - audio_sample_rate: None, - audio_bit_depth: None, - file_size_bytes: Some(19), - cover: None, - }; - let track_id = import::upsert_track(&lib, &import).unwrap().0; - let expected = audio_content_id(&file_path).unwrap(); - { - let conn = lib.lock(); - conn.execute( - "UPDATE tracks SET content_id = NULL WHERE id = ?1", - [track_id], - ) - .unwrap(); - } - - let stats = lib.backfill_missing_content_ids().unwrap(); - assert_eq!(stats.hashed, 1); - assert_eq!(stats.updated(), 1); - assert_eq!( - lib.track_content_id_by_id(track_id).unwrap().as_deref(), - Some(expected.as_str()) - ); - - let _ = std::fs::remove_file(path); - } - - #[test] - fn search_finds_all_kinds() { - let lib = test_library(); - add_track(&lib, "Neon Lights", "Neon Artist", "Neon Album"); - let results = lib.search("neon", 10).unwrap(); - assert_eq!(results.artists.len(), 1); - assert_eq!(results.releases.len(), 1); - assert_eq!(results.tracks.len(), 1); - // LIKE wildcards in the query must not match everything. - assert_eq!(lib.search("%", 10).unwrap().len(), 0); - } - - #[test] - fn search_ranks_exact_names_first() { - let lib = test_library(); - add_track(&lib, "A Needle", "A Needle Artist", "A Needle Album"); - add_track(&lib, "Needle", "Needle", "Needle"); - - let results = lib.search("needle", 10).unwrap(); - assert_eq!(results.artists[0].name, "Needle"); - assert_eq!(results.releases[0].title, "Needle"); - assert_eq!(results.tracks[0].title, "Needle"); - } - - #[test] - fn search_folds_case_beyond_ascii() { - let lib = test_library(); - add_track(&lib, "Nothing Else Matters", "Металлика", "Чёрный альбом"); - // SQLite's LIKE/NOCASE only fold ASCII; norm() folds every script. - assert_eq!(lib.search("металлика", 10).unwrap().artists.len(), 1); - assert_eq!(lib.search("МЕТАЛЛИКА", 10).unwrap().artists.len(), 1); - assert_eq!(lib.search("чёрный", 10).unwrap().releases.len(), 1); - assert_eq!(lib.search("matters", 10).unwrap().tracks.len(), 1); - } - - #[test] - fn playlists_and_likes_round_trip() { - let lib = test_library(); - let track_id = add_track(&lib, "Song", "Artist", "Album"); - let playlist = lib.create_playlist("Mix").unwrap(); - lib.add_tracks_to_playlist(playlist.id, &[track_id]) - .unwrap(); - assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1); - - let content_id = lib.track_content_id_by_id(track_id).unwrap().unwrap(); - assert!(lib.toggle_like_by_content_id(&content_id).unwrap()); - assert_eq!(lib.liked_content_ids().unwrap(), vec![content_id.clone()]); - assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1); - assert!(!lib.toggle_like_by_content_id(&content_id).unwrap()); - - lib.remove_tracks_from_playlist(playlist.id, &[track_id]) - .unwrap(); - assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0); - lib.delete_playlist(playlist.id).unwrap(); - // Only the virtual Likes playlist remains. - assert_eq!(lib.playlists().unwrap().len(), 1); - } - - #[test] - fn likes_playlist_orders_local_and_federated_by_liked_at() { - let lib = test_library(); - let old_id = add_track(&lib, "Old Local", "Artist", "Album"); - let new_id = add_track(&lib, "New Local", "Artist", "Album"); - let old_content_id = lib.track_content_id_by_id(old_id).unwrap().unwrap(); - let new_content_id = lib.track_content_id_by_id(new_id).unwrap().unwrap(); - let content_id = format!("b3:{}", "c".repeat(64)); - let fed = crate::federation::FedTrack { - item_id: "fed_item_order".to_string(), - owner: "fed_owner_order".to_string(), - own: false, - title: "Middle Fed".to_string(), - artist_names: vec!["Remote Artist".to_string()], - featured_artist_names: Vec::new(), - year: Some(2026), - duration_seconds: Some(123), - content_id: Some(content_id), - release_title: Some("Remote Release".to_string()), - track_number: Some(1), - disc_number: Some(1), - }; - - assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap()); - assert!(lib.toggle_like_by_content_id(&new_content_id).unwrap()); - assert!(lib.toggle_fed_like(&fed).unwrap()); - { - let conn = lib.lock(); - conn.execute( - "UPDATE likes SET liked_at = ?2 WHERE track_id = ?1", - params![old_id, "2026-01-01 00:00:00"], - ) - .unwrap(); - conn.execute( - "UPDATE likes SET liked_at = ?2 WHERE track_id = ?1", - params![new_id, "2026-01-02 00:00:00"], - ) - .unwrap(); - conn.execute( - "UPDATE fed_likes SET liked_at = ?2 WHERE item_id = ?1", - params![fed.item_id, "2026-01-03 00:00:00"], - ) - .unwrap(); - } - - let titles: Vec = lib - .playlist(LIKES_PLAYLIST_ID) - .unwrap() - .tracks - .into_iter() - .map(|track| track.title) - .collect(); - assert_eq!(titles, vec!["Middle Fed", "New Local", "Old Local"]); - - assert!(!lib.toggle_like_by_content_id(&old_content_id).unwrap()); - assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap()); - { - let conn = lib.lock(); - conn.execute( - "UPDATE likes SET liked_at = ?2 WHERE track_id = ?1", - params![old_id, "2026-01-04 00:00:00"], - ) - .unwrap(); - } - let titles: Vec = lib - .playlist(LIKES_PLAYLIST_ID) - .unwrap() - .tracks - .into_iter() - .map(|track| track.title) - .collect(); - assert_eq!(titles, vec!["Old Local", "Middle Fed", "New Local"]); - } - - #[test] - fn synced_playlist_can_show_federated_pending_tracks() { - let lib = test_library(); - let playlist = lib.create_playlist("Remote Mix").unwrap(); - let sync_id = lib.ensure_playlist_sync_id(playlist.id).unwrap(); - let content_id = format!("b3:{}", "a".repeat(64)); - let fed = crate::federation::FedTrack { - item_id: "fed_item_1".to_string(), - owner: "fed_owner_1".to_string(), - own: false, - title: "Remote Song".to_string(), - artist_names: vec!["Remote Artist".to_string()], - featured_artist_names: vec!["Remote Guest".to_string()], - year: Some(2026), - duration_seconds: Some(123), - content_id: Some(content_id.clone()), - release_title: Some("Remote Release".to_string()), - track_number: Some(2), - disc_number: Some(1), - }; - - assert!(lib.upsert_fed_playlist_track(&sync_id, &fed, 4).unwrap()); - assert!( - lib.has_playlist_content_reference(&sync_id, &content_id) - .unwrap() - ); - - let detail = lib.playlist(playlist.id).unwrap(); - assert_eq!(detail.tracks.len(), 1); - let track = &detail.tracks[0]; - assert!(track.is_fed_pending()); - assert_eq!(track.title, "Remote Song"); - assert_eq!(track.artist_line(), "Remote Artist feat. Remote Guest"); - assert_eq!(track.release_title, "Remote Release"); - assert_eq!(track.content_id.as_deref(), Some(content_id.as_str())); - - let card = lib - .playlists() - .unwrap() - .into_iter() - .find(|card| card.id == playlist.id) - .unwrap(); - assert_eq!(card.track_count, 1); - - lib.remove_content_ids_from_playlist(playlist.id, std::slice::from_ref(&content_id)) - .unwrap(); - assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0); - assert!( - lib.fed_playlist_track_by_content_id(&sync_id, &content_id) - .unwrap() - .is_none() - ); - } - - #[test] - fn add_federated_pending_track_to_playlist_records_position() { - let lib = test_library(); - let local_id = add_track(&lib, "Local Song", "Artist", "Album"); - let playlist = lib.create_playlist("Remote Mix").unwrap(); - let content_id = format!("b3:{}", "b".repeat(64)); - let fed = crate::federation::FedTrack { - item_id: "fed_item_2".to_string(), - owner: "fed_owner_2".to_string(), - own: false, - title: "Remote Song".to_string(), - artist_names: vec!["Remote Artist".to_string()], - featured_artist_names: Vec::new(), - year: Some(2026), - duration_seconds: Some(123), - content_id: Some(content_id.clone()), - release_title: Some("Remote Release".to_string()), - track_number: Some(2), - disc_number: Some(1), - }; - - lib.add_tracks_to_playlist(playlist.id, &[local_id]) - .unwrap(); - lib.add_fed_tracks_to_playlist(playlist.id, std::slice::from_ref(&fed)) - .unwrap(); - - let position = lib - .playlist_content_position(playlist.id, &content_id) - .unwrap(); - assert_eq!(position, Some(1)); - let detail = lib.playlist(playlist.id).unwrap(); - assert_eq!( - detail - .tracks - .into_iter() - .map(|track| track.title) - .collect::>(), - vec!["Local Song", "Remote Song"] - ); - } - - #[test] - fn track_edit_relinks_artists() { - let lib = test_library(); - let track_id = add_track(&lib, "Song", "Artist", "Album"); - lib.update_track( - track_id, - &TrackEdit { - title: "Renamed".into(), - artists: vec!["Other".into()], - featured_artists: vec!["Guest".into()], - track_number: Some(2), - disc_number: None, - cover_path: None, - }, - ) - .unwrap(); - let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0); - assert_eq!(track.title, "Renamed"); - assert_eq!(track.artists[0].name, "Other"); - assert_eq!(track.featured_artists[0].name, "Guest"); - assert_eq!(track.track_number, Some(2)); - } - - #[test] - fn deleting_artist_cleans_up_own_content() { - let lib = test_library(); - add_track(&lib, "Song", "Solo", "Solo Album"); - let page = lib.artists(1, 10, artist_filters(false)).unwrap(); - lib.delete_artist(page.items[0].id).unwrap(); - assert_eq!(lib.artists(1, 10, artist_filters(false)).unwrap().total, 0); - assert_eq!(lib.search("Song", 10).unwrap().len(), 0); - } - - #[test] - fn delete_track_drops_empty_release() { - let lib = test_library(); - let track_id = add_track(&lib, "Only", "Artist", "Album"); - lib.delete_track(track_id).unwrap(); - let detail = lib - .artist(lib.artists(1, 10, artist_filters(false)).unwrap().items[0].id) - .unwrap(); - assert!(detail.releases.is_empty()); - } - - #[test] - fn history_counts_completed_plays() { - let lib = test_library(); - let track_id = add_track(&lib, "Song", "Artist", "Album"); - lib.add_history(track_id, None, 60, true).unwrap(); - lib.add_history(track_id, None, 10, false).unwrap(); - let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0); - assert_eq!(track.play_count, 1); - } -} +#[path = "tests.rs"] +mod tests;