74 Commits
Author SHA1 Message Date
Ultradesu 28189bae95 Added playback history window 2026-07-27 23:37:46 +01:00
Ultradesu a7f41ff205 Bump libs 2026-07-27 23:20:21 +01:00
Ultradesu 40c318a1d0 Update frid dependency for listening history 2026-07-27 23:19:45 +01:00
Ultradesu 0d6c89761f Sync listening history across devices 2026-07-27 23:15:57 +01:00
Ultradesu f55571ab34 Fixed status icon background 2026-07-27 17:38:14 +01:00
Ultradesu eb97c6469e Fixed liked track sorting 2026-07-27 17:16:39 +01:00
Ultradesu 7ebf07161e Fixed liked track sorting 2026-07-27 17:09:33 +01:00
Ultradesu d36f55b3f6 Reworked MODE switch 2026-07-27 14:13:56 +01:00
Ultradesu 5722e5aae6 prepared release 2026-07-26 03:51:36 +03:00
Ultradesu 4e1c52c4e6 fix readmi 2026-07-26 03:41:43 +03:00
Ultradesu d64deded3a Updated CI for brew 2026-07-26 03:16:42 +03:00
Ultradesu 864e84b159 Updated pic 2026-07-26 02:51:40 +03:00
Ultradesu 9aefaa364e Updated readme 2026-07-26 02:41:08 +03:00
Ultradesu c069ed9135 Updated readme 2026-07-26 02:21:16 +03:00
Ultradesu 3556120e10 Updated readme 2026-07-26 02:07:07 +03:00
Ultradesu ada18a4583 Reworked statistics 2026-07-26 01:41:16 +03:00
Ultradesu 17eb6a4fee Reworked statistics 2026-07-26 00:45:53 +03:00
Ultradesu 76f67372a1 Improved fiters. reworked Trak wrapper. 2026-07-25 23:57:19 +03:00
Ultradesu f80e10e6ad Improved fiters. reworked Trak wrapper. 2026-07-25 23:57:03 +03:00
Ultradesu c45f2e700b Reworked library. Added local, my, fed traks overview. 2026-07-25 23:24:49 +03:00
Ultradesu 788bc2e01f Added network catalog slices 2026-07-25 22:40:27 +03:00
ab b725ca8d05 Fixed status window 2026-07-25 02:10:20 +03:00
Ultradesu 38e3eb5a12 Added streaming. Improved UI 2026-07-25 01:23:29 +03:00
Ultradesu a516887830 Connected Devices: restyle status 2026-07-24 14:43:15 +03:00
Ultradesu 888038931a Connected Devices: added remote control 2026-07-24 05:42:16 +03:00
Ultradesu 3d3fd20b57 Connected Devices: added remote control 2026-07-24 05:31:29 +03:00
Ultradesu 33fa2346f0 Connected Devices: added remote control 2026-07-24 05:19:28 +03:00
Ultradesu d62c79b50c Connected Devices: added remote control 2026-07-24 05:09:33 +03:00
Ultradesu 8605c72f10 Connected Devices: added remote control 2026-07-24 04:56:26 +03:00
Ultradesu b14781f5a4 Connected Devices: added remote control 2026-07-24 04:49:48 +03:00
Ultradesu 389eacd388 Connected Devices: added remote control 2026-07-24 04:33:50 +03:00
Ultradesu 1065ea5afc Connected Devices: added remote control 2026-07-24 04:20:52 +03:00
Ultradesu 9352b493a1 Connected Devices: added remote control 2026-07-24 04:11:01 +03:00
Ultradesu a737456daf Connected Devices: fixed rplaylist sync 2026-07-24 03:35:42 +03:00
Ultradesu be58933e8d Connected Devices: fixed rplaylist sync 2026-07-24 03:22:18 +03:00
Ultradesu e83f356005 Connected Devices: fixed rplaylist sync 2026-07-24 03:11:33 +03:00
Ultradesu 682794755e Connected Devices: fixed rplaylist sync 2026-07-24 03:06:20 +03:00
Ultradesu 71d31505f9 Connected Devices: fixed rplaylist sync 2026-07-24 02:35:36 +03:00
Ultradesu e08eb71ab7 Connected Devices: fixed UI settings. fixed pairing protocol 2026-07-24 02:21:24 +03:00
Ultradesu fa58b2ce43 Connected Devices: fixed UI settings. fixed revocation 2026-07-24 01:43:56 +03:00
Ultradesu cc3153cc3a Connected Devices: fix stream finish 2026-07-24 01:31:16 +03:00
Ultradesu f85c806b43 Connected Devices: fix stream finish 2026-07-24 01:24:18 +03:00
Ultradesu b47c271ec0 Connected Devices: fix stream finish 2026-07-24 01:18:02 +03:00
Ultradesu adbb76b031 Connected Devices: Added sync feature 2026-07-24 00:54:27 +03:00
Ultradesu 7fe6ddfe05 Added filter. Improved UI. Fixed settings window 2026-07-23 19:43:47 +03:00
Ultradesu 6dbfc83bf6 Fixed imports 2026-07-23 18:54:57 +03:00
Ultradesu 5e346bf535 Added Visualisation engine on rhai 2026-07-23 18:51:32 +03:00
Ultradesu 32a038dba6 Added Visualisation engine on rhai 2026-07-23 18:48:58 +03:00
Ultradesu af2542b668 Improved UI. 2026-07-23 17:35:11 +03:00
Ultradesu 5d54894437 Sync with FRID protocol v5. Improved direct content id search 2026-07-23 16:45:30 +03:00
Ultradesu 2128cd2300 Fixed artist page sorting 2026-07-23 14:23:22 +03:00
Ultradesu 0b2eaa1496 fixed frid 2026-07-21 20:12:31 +03:00
Ultradesu 6c808aea5c Extendex DHT info 2026-07-21 20:10:37 +03:00
Ultradesu da5d2410ac fed fixes 2026-07-21 00:12:45 +03:00
Ultradesu 151e2cf337 Many UI fixes 2026-07-17 15:29:10 +03:00
Ultradesu 70261a3bf4 fixed translate 2026-07-17 01:48:41 +03:00
Ultradesu ff97053156 Improved fed artist page 2026-07-17 01:43:09 +03:00
Ultradesu a723fcc18e Added fed artist page 2026-07-17 01:17:54 +03:00
Ultradesu 73ee4dab9d Fixed track art 2026-07-17 00:00:34 +03:00
Ultradesu eb8ed966f1 fixed art sync 2026-07-16 21:23:01 +03:00
Ultradesu b027db8a09 fixed metadata import 2026-07-16 21:16:15 +03:00
Ultradesu 691abe599b fixed search result nav 2026-07-16 20:49:53 +03:00
Ultradesu 3df41cfc3b fixed build via git 2026-07-16 20:33:42 +03:00
Ultradesu b1f8f4cb01 init federation 2026-07-16 20:29:51 +03:00
Ultradesu 98525718d0 Improved auth stability 2026-06-17 10:34:03 +01:00
Ultradesu cf82b203a9 Improved auth stability 2026-06-16 22:34:01 +01:00
Ultradesu 2da81ecb89 Improved auth stability 2026-06-16 03:55:11 +01:00
Ultradesu 54ba8b4309 Fixed lock 2026-06-15 12:37:27 +01:00
Ultradesu ba5a73816e Added Visual select for multiple tracks, added Queue management. Added info for tracks 2026-06-15 12:28:34 +01:00
Ultradesu 5b624443d5 Fixed token refresh. 2026-06-12 15:05:36 +01:00
ab ec777f956a Fixed ci 2026-06-11 00:56:34 +01:00
ab 654d8ad750 Fixed ci 2026-06-11 00:42:02 +01:00
ab d13b0c8085 Fixed macos runner image 2026-06-11 00:35:17 +01:00
ab e30ad8be36 Fixed readme
Build and Release / Build furumi-linux-x86_64 (push) Failing after 14s
Build and Release / Build furumi-macos-x86_64 (push) Has been cancelled
Build and Release / Build furumi-windows-x86_64 (push) Has been cancelled
Build and Release / Publish release assets (push) Has been cancelled
2026-06-11 00:28:08 +01:00
54 changed files with 31235 additions and 5146 deletions
+104
View File
@@ -0,0 +1,104 @@
name: Build and Release
on:
push:
tags:
- "v*"
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
jobs:
build:
name: Build ${{ matrix.asset_name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
asset_name: furumi-linux-x86_64
archive_ext: tar.gz
binary_name: furumi
- os: macos-latest
asset_name: furumi-macos-aarch64
archive_ext: tar.gz
binary_name: furumi
- os: windows-latest
asset_name: furumi-windows-x86_64
archive_ext: zip
binary_name: furumi.exe
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev pkg-config
- name: Show Rust version
run: rustc --version && cargo --version
- name: Fetch locked dependencies
run: cargo fetch --locked
- name: Build
run: cargo build --release --locked --offline
- name: Package
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
archive_name="${{ matrix.asset_name }}-${version}.${{ matrix.archive_ext }}"
package_dir="dist/${{ matrix.asset_name }}"
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 }}")
else
tar -C dist -czf "${archive_name}" "${{ matrix.asset_name }}"
fi
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: ${{ matrix.asset_name }}-*
if-no-files-found: error
publish:
name: Publish release assets
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create release and upload assets
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
gh release create "$RELEASE_TAG" artifacts/*/* \
--title "furumi ${RELEASE_TAG}" \
--notes "Release ${RELEASE_TAG}" \
--verify-tag
+241
View File
@@ -0,0 +1,241 @@
# AGENTS.md
This file describes how to work safely in the Furumi repository. It applies to
the entire project.
## Project intent
Furumi is a cross-platform, federated P2P player for personal music libraries.
Every node must remain a complete local player without network access. Other
nodes add discovery, availability, trusted-device synchronization, and direct
track transfer; they are never a prerequisite for using the local library.
Read `ARCHITECTURE.md` before changing federation, device sync, playback
handoff, persistence, or runtime boundaries.
Preserve these architectural invariants:
1. Local import, browsing, playback, playlists, likes, and history work
offline.
2. No central Furumi service becomes required for discovery, playback, or
synchronization.
3. Federation membership does not imply trusted-device membership.
4. Remote state is merged, replicated, or cached locally; it is not treated as
an always-available database.
5. Network, database, filesystem, image, and audio preparation work stays out
of the interactive UI path.
6. Losing peers may reduce remote availability but must not invalidate local
state.
## Toolchain and checks
The crate uses Rust edition 2024 and Rust 1.97 or newer.
Run the checks relevant to every code change:
```bash
cargo fmt --all -- --check
cargo check --all-targets
cargo test --all-targets
cargo clippy --all-targets
```
`cargo clippy --all-targets` currently reports known warnings. Do not hide
them with broad `allow` attributes. Do not claim `-D warnings` passes unless
the existing warning set has actually been resolved.
Use `cargo fmt --all` after editing Rust. Keep `Cargo.lock` committed and
update it only when dependencies change.
## Source boundaries
- `src/main.rs` owns process startup, terminal restoration, Tokio setup, and
platform media-loop integration.
- `src/app/state.rs` owns UI/navigation state.
- `src/app/action.rs` and `src/app/event.rs` define input intent and runtime
results.
- `src/app/update.rs` performs state transitions and requests effects. It must
not perform blocking I/O.
- `src/app/mod.rs` owns runtime orchestration and effect execution.
- `src/library/` owns SQLite library queries, models, imports, and migrations.
- `src/player/` owns rodio playback and audio analysis.
- `src/federation/` owns DHT discovery, peer catalogs, audio exchange, and
federation caches.
- `src/devices.rs` owns trusted-device replication, membership, and playback
coordination.
- `src/ui/` renders `AppState`; rendering code must not start background work
or mutate persistence.
- `src/media.rs` owns OS media controls.
- `src/visualizer.rs` and `src/visualizations/` own the Rhai visualization
host and bundled scripts.
Prefer adding a focused module when a responsibility has a clear boundary.
Do not split files solely to reduce line count if doing so introduces leaky
APIs or circular ownership.
Keep large test suites in their existing adjacent files:
- `src/app/update_tests.rs`
- `src/devices/tests.rs`
- `src/federation/tests.rs`
- `src/library/tests.rs`
## Application state and concurrency
`AppState` is the single source of truth for the TUI. The normal flow is:
```text
external event -> AppEvent -> Action/update -> Effect -> runtime -> AppEvent
```
Follow these rules:
- Keep update logic deterministic wherever practical.
- Return an `Effect` for work that requires runtime services.
- Report asynchronous results through `AppEvent`.
- Use `tokio::task::spawn_blocking` for blocking SQLite, hashing, tag parsing,
or filesystem-heavy work invoked from async code.
- Do not hold a mutex guard across `.await`.
- Do not call terminal rendering APIs from background tasks.
- Preserve event sequence or request identifiers where they prevent stale
search, download, or synchronization results from overwriting newer state.
## Library and SQLite
`library::Library` is the authority for local catalog persistence. Keep SQL in
the library/device persistence layers rather than spreading it through the UI
or runtime.
When changing schema or stored data:
- provide an upgrade path for existing databases;
- make migrations repeatable and safe on partially upgraded databases;
- preserve foreign-key and uniqueness invariants;
- distinguish durable user data from replaceable federation/cache data;
- test both the new behavior and migration-sensitive queries;
- never solve a schema problem by deleting or recreating a user's database.
Content identifiers are the bridge between independent libraries. Avoid
assuming that local numeric row IDs identify the same track on another node.
## Federation and wire protocols
Discovery and transfer are separate concerns:
- the DHT provides distributed discovery;
- peer catalog streams provide richer metadata;
- audio streams transfer content;
- the device-sync protocol replicates trusted personal state.
Treat protocol and serialized-data changes as compatibility changes:
- preserve existing ALPN values unless intentionally introducing a new
protocol version;
- retain backward-compatible `serde` defaults for fields added to wire types;
- tolerate peers with older or partial metadata;
- bound incoming messages and validate untrusted lengths/identifiers;
- keep catalog/federation authority separate from trusted-device authority;
- assume peers can disappear between discovery and transfer;
- keep retries and fallback sources idempotent.
Do not introduce a required coordinator, registry, account server, or canonical
network database.
## Trusted-device synchronization
Device sync is offline-first. Operations may arrive late, more than once, or
in a different order.
When changing it:
- preserve operation-ID deduplication;
- use the existing hybrid logical time ordering rules consistently;
- ensure deletes survive offline replicas through tombstones;
- compact tombstones only after the required acknowledgements;
- keep snapshots able to repair peers that missed older operations;
- treat membership and revocation as replicated durable state;
- keep playback commands targeted and deduplicated;
- test merge behavior from at least two operation orders.
Do not replace eventual reconciliation with assumptions about a continuously
connected leader.
## Playback and remote resolution
The application owns the logical queue; `player::Controller` owns physical
audio playback.
Remote and local tracks should continue through the same queue and UI model.
When a track is unavailable locally, resolve it asynchronously and replace or
materialize the pending entry without blocking the event loop.
Preserve:
- queue position and prefetch indexes when inserting or removing tracks;
- shuffle/repeat behavior;
- pause and seek state during device handoff;
- fallback to another advertised source when a peer disappears;
- the distinction between cached audio and content imported into the durable
library.
## UI and key bindings
The TUI is keyboard-first and cross-platform.
- Keep rendering pure over `&AppState`.
- Use semantic `Action` values rather than checking raw keys inside views.
- Add default bindings in `src/config/default_keymap.toml`.
- Preserve user overrides and context-specific bindings.
- Account for narrow terminals, Unicode display width, and empty/loading/error
states.
- Restore raw mode, alternate screen, bracketed paste, and keyboard
enhancements on exit and panic.
Do not print to stdout/stderr while the alternate-screen UI is active; use
`tracing` and visible application status instead.
## Cross-platform work
Furumi supports Linux, macOS, and Windows.
- Keep OS-specific code behind narrow `cfg` boundaries.
- Do not introduce shell-only behavior into portable paths.
- Use platform data/config/cache directories through the existing config
helpers.
- Consider path encoding, separators, and non-UTF-8 filesystem values.
- Changes to media controls, terminal setup, clipboard behavior, or audio
devices require explicit review of all three platforms.
If only one target can be exercised locally, state which platform-specific
paths remain unverified.
## Tests
Add tests at the closest stable boundary:
- pure state transitions in `app/update_tests.rs`;
- SQLite behavior and migrations in `library/tests.rs`;
- operation merge, tombstone, membership, and playback sync behavior in
`devices/tests.rs`;
- search/ranking/federation conversion in `federation/tests.rs`;
- protocol-specific tests beside `federation/audio.rs` or
`federation/catalog.rs` when appropriate.
Prefer in-memory SQLite databases and deterministic fixtures. Temporary files
must use unique names and must not depend on a developer's music library,
configuration, home directory, network peers, or audio hardware.
Do not remove a test merely because a refactor makes it inconvenient. Update
it to assert the preserved behavior.
## Documentation and releases
Keep public documentation aligned with the decentralized product model.
README content should explain user value and setup; `ARCHITECTURE.md` should
explain architectural decisions and invariants rather than restating source
code.
Release archives are produced by `.github/workflows/release.yml` and must
include the binary, `README.md`, and `LICENSE`.
The project is licensed under WTFPL version 2.
+302 -208
View File
@@ -1,240 +1,334 @@
# 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 an autonomous music player that can cooperate with other Furumi
players. The architecture starts from one constraint: **a node must remain a
complete and useful player when every other node is unavailable**.
## 1. Technology choices
Networking therefore extends a local player instead of becoming a prerequisite
for it. There is no control plane, account service, canonical catalog, or
server-owned source of truth.
### TUI: ratatui 0.30 + crossterm 0.29
## Architectural goals
Evaluated: **ratatui**, cursive, tui-realm, iocraft.
The design optimizes for five properties:
- **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).
1. **Local autonomy**importing, browsing, playback, playlists, likes, and
history work entirely on one device.
2. **No central failure domain** — discovery, catalog exchange, streaming, and
synchronization do not depend on a Furumi-operated service.
3. **Offline tolerance** — trusted devices may change state independently and
reconcile after reconnecting.
4. **Incremental federation** — one node is complete; every additional node
increases availability and the amount of discoverable music.
5. **Explicit trust boundaries** — personal-device replication and wider
music federation are different protocols with different authority.
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.
These goals are more important than maintaining a globally identical view of
the network. Furumi prefers useful local progress and eventual reconciliation
over distributed consensus.
### Keybindings: crokey + TOML keymap
## The node model
- **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.
Every running Furumi instance contains the same four capabilities:
### 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, ...
}
```text
┌─────────────────────────────────────────────────────────┐
│ Furumi node │
│ │
│ Local library ── Playback engine ── TUI / media keys │
│ │ │ │
│ ├── Trusted-device replication │
│ │ │
│ └── Federation: discovery, catalog, audio │
└─────────────────────────────────────────────────────────┘
```
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.)
The local SQLite library is authoritative for that node. Network data is
merged into local views or materialized as pending remote content; it does not
replace the local database with a remote database abstraction.
### Async runtime: tokio
This keeps the core behavior predictable:
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.
- disconnecting never makes the local collection unavailable;
- downloaded content can become ordinary local content;
- a peer disappearing reduces availability but does not invalidate local
state;
- nodes may join and leave without electing a leader.
## 2. Application architecture
## Two network layers
Elm-style (TEA) core with a component-per-view UI layer — the pattern from the
official ratatui component template and spotify-player.
Furumi deliberately separates **trusted-device synchronization** from
**federated music exchange**.
```
┌────────────────────────────────────────────┐
│ 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 │
└────────────────────────────────────┘ └────────────────┘
### Trusted-device synchronization
This layer connects devices owned or trusted by the same user. It carries
personal state such as:
- likes and playlist operations;
- device membership and revocation;
- acknowledgements and synchronization progress;
- playback state, commands, and handoff information.
Pairing establishes the trust relationship. After that, changes are replicated
through an append-only operation log and applied to materialized local tables.
### Music federation
Federation connects independent libraries. It provides:
- decentralized discovery through the DHT;
- automatic publication of searchable local catalog metadata;
- richer artist and release catalogs fetched from peers;
- direct audio transfer when a selected track is not available locally.
Federation does not grant another peer authority over personal playlists,
likes, or device membership. A node may participate in federation without
joining another user's trusted-device group.
Keeping these layers separate prevents discovery convenience from silently
becoming a synchronization trust decision.
## Discovery and direct communication
Furumi separates finding content from transferring it.
```text
discovery plane
Local catalog ───────> DHT <─────── Other catalogs
│ peer + content identity
v
direct P2P connection
├── catalog protocol
├── audio protocol
└── device-sync protocol
```
Key rules:
The DHT is the distributed index. Nodes publish compact searchable
descriptions of their local library and query the network without contacting a
central search service.
- **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.
Once a peer is known, communication moves to direct P2P streams provided by
iroh through `music-dht`. Furumi defines separate application protocols for
catalog requests, audio transfer, and trusted-device synchronization. This
keeps discovery traffic small and lets large or private exchanges happen only
between the participating peers.
### Module layout (single crate now, splittable later)
Relay-assisted connectivity may help peers establish a route, but relays do
not become catalog authorities or application-state owners.
```
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
## Identity and content resolution
Network operations refer to content independently of any one library row.
Content identifiers allow a node to ask:
1. Is this track already present in my local library?
2. Does one of my trusted devices have it?
3. Which federation peers currently advertise it?
Resolution follows that order conceptually: prefer a ready local source, reuse
known content where possible, and fetch from a peer only when necessary.
A federated track can initially exist as a lightweight pending item in a queue
or playlist. When playback reaches it, Furumi resolves an available peer,
starts the transfer, and either uses the cache or imports the result into the
local library. The rest of the player continues to work with the same
`TrackItem` model, so local and remote availability do not require separate
playback systems.
## Offline-first synchronization
Trusted devices do not share a live database connection. Each device records
operations locally and exchanges them when connectivity returns.
The synchronization model combines:
- immutable operation identifiers for deduplication;
- hybrid logical timestamps for deterministic last-writer decisions;
- materialized tables for fast UI queries;
- tombstones so deletion survives offline replicas;
- per-peer acknowledgements to determine when old tombstones can be compacted;
- snapshots to repair a peer that missed older operations.
This is eventual consistency scoped to a trusted device group. A temporarily
offline laptop can modify playlists, another device can continue playback, and
both can later converge without a permanently available coordinator.
Membership changes use the same replicated model. Revocation is state that
must propagate and converge, not an ephemeral server-side session flag.
## Playback across devices
Playback has one logical state but remains physically local to the device
producing audio.
The synchronized state describes the queue, current item, position, pause
state, volume, shuffle/repeat mode, and the active playback owner. Commands are
targeted and deduplicated. Handoff transfers intent and position; the receiving
device resolves the track against its own library or federation sources before
starting its audio engine.
Active-device leases and idle timing prevent stale snapshots from immediately
taking control after a device reconnects. This provides practical coordination
without introducing a central playback arbiter.
## Local application architecture
Inside one node, Furumi uses an event-driven state machine:
```text
terminal / player / database / network / OS media events
v
AppEvent
input → Action
v
update(AppState)
optional Effect
v
asynchronous runtime work
└──────────> AppEvent
```
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.
`AppState` is the single source of truth for the interface. The update layer
performs deterministic state transitions and requests effects; it does not
perform blocking I/O. SQLite access, imports, artwork decoding, DHT queries,
peer transfers, and audio preparation run through runtime services and report
their results as events.
## 3. UI model
This structure gives the TUI three important properties:
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).
- rendering is a pure projection of current state;
- input behavior can be tested without starting audio or networking;
- slow peers and large imports cannot block terminal interaction.
Tabs (each owns a navigation stack, like a browser per tab):
The audio engine is similarly isolated. The application owns the logical
queue, while `player::Controller` owns rodio playback and receives explicit
commands. Prefetching prepares the next source before the current item ends.
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.
## Scripted visualizations
Navigation state is `Vec<Route>` 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.
Visualizations are an extension boundary rather than hard-coded rendering
paths. Rust owns audio sampling, script execution, validation, and terminal
drawing; Rhai scripts own the visual composition.
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).
## 4. Backend integration notes
(Verified against the furumusic source; base path `/api/player`.)
- **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.
## 5. Reliability checklist
- 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.
## 6. Suggested initial dependencies
```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"
```text
rodio source
v
audio analyzer ──> normalized features + scope samples
v
Rhai render(input)
v
validated draw commands
v
ratatui frame buffer
```
## 7. Build order (milestones)
The player analyzer derives a bounded, renderer-independent input model:
energy, bass, mid, treble, beat strength, waveform samples, playback progress,
volume, pause state, track metadata, time, and terminal dimensions. Each
script implements `render(input)` and returns declarative commands such as
clear, cell, line, rectangle, trace, and text. Scripts never receive the
ratatui frame or audio engine directly.
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.
This command boundary is intentional:
- scripts remain independent of Rust UI internals;
- the host validates command shapes, colors, coordinates, and arrays;
- drawing is clipped to the current terminal area;
- script failures become an in-UI visualizer error instead of corrupting the
terminal or stopping playback.
Rhai files live in the user's visualization directory. The runtime discovers
them dynamically, compiles the selected script, caches its AST, and recompiles
it when the file modification time changes. A visualization can therefore be
created or edited while Furumi is running without rebuilding or restarting the
application. Bundled scripts use the same path and contract as user scripts,
so built-in and custom visualizations exercise the same runtime.
The Rhai engine is configured as a sandboxed computation environment. Module
loading through `import` and `export` is disabled, no filesystem or network API
is exposed to scripts, and execution is bounded by limits on operations, call
depth, variables, functions, expression depth, and collection/string sizes.
Only the input map, Rhai language primitives, and a small set of mathematical
helpers are available.
The sandbox protects responsiveness and keeps visualization code in its
intended role: transforming current audio features into drawing commands. It
is not a plugin mechanism for accessing the library, network, or player
controls.
## Persistence boundaries
Furumi stores different kinds of state according to their lifetime:
| State | Storage | Role |
| --- | --- | --- |
| Library, playlists, likes, history | SQLite | Durable local source of truth |
| Device operation log and replicas | SQLite | Offline synchronization |
| Federation catalog cache | SQLite/cache | Faster network browsing |
| Audio and artwork cache | Filesystem cache | Reusable fetched data |
| Settings, keymap, identity | Platform config/data dirs | Node configuration |
| Queue and playback snapshots | Application/device sync state | Continuity and handoff |
Caches are replaceable. The local library and device operation log are durable.
This distinction lets maintenance and recovery code discard derived network
data without risking the user's collection.
## Failure model
Expected failures are treated as ordinary state:
- a DHT query may return partial results;
- an advertised peer may be offline by the time a track is requested;
- a transfer may stop and be retried through another source;
- trusted devices may reconnect with overlapping changes;
- cached metadata may be stale;
- an audio device may disappear during playback.
The architecture avoids converting these cases into global failure. Search can
show partial data, synchronization can resume, and playback resolution can try
another source. Errors return to the application as events so the UI can expose
them without terminating the node.
## Main implementation boundaries
The source tree follows the architectural responsibilities:
- `library/` owns the local catalog and import pipeline;
- `player/` owns audio playback and analysis;
- `federation/` owns DHT-facing search, peer catalogs, and audio exchange;
- `devices.rs` owns trusted-device replication and playback coordination;
- `app/` owns state transitions and runtime orchestration;
- `ui/` renders the TUI;
- `media.rs` integrates platform media controls;
- `visualizer.rs` hosts programmable Rhai visualizations.
Dependencies should continue to point inward toward typed models and explicit
events. UI code should not own network tasks, network protocols should not
mutate UI state directly, and the local library should not depend on the
presence of federation.
## Architectural invariants
Future changes should preserve these rules:
1. A node must start and play its local library without network access.
2. No central Furumi service may become required for discovery, playback, or
trusted-device synchronization.
3. Federation membership must not imply personal-device trust.
4. Remote state must be merged or cached locally, never treated as an always
available database.
5. Network and storage work must remain outside the interactive UI path.
6. More peers should improve availability; losing peers should only reduce
remote capabilities.
Generated
+2255 -901
View File
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -1,7 +1,10 @@
[package]
name = "furumi_tui"
version = "0.1.1"
version = "0.2.0"
edition = "2024"
rust-version = "1.97"
description = "A federated P2P player for personal music libraries"
license = "WTFPL"
[[bin]]
name = "furumi"
@@ -9,22 +12,25 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.102"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
blake3 = "1"
crokey = "1.4.0"
crossterm = { version = "0.29.0", features = ["event-stream"] }
directories = "6.0.0"
futures-util = "0.3.32"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
open = "5.3.5"
lofty = "0.22"
# P2P federation: library index in a shared DHT + audio streaming between
# peers (same protocol as furumi-fd).
music-dht = { git = "https://gt.hexor.cy/ab/frid.git", rev = "8de7d1292708fa0b225e5a4a9d5ab4f0676202d3" }
ratatui = "0.30.1"
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
rhai = { version = "1", features = ["sync"] }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
stream-download = { version = "0.24.1", default-features = false, features = ["reqwest-rustls", "temp-storage"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time"] }
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
toml = "1.1.2"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
@@ -34,7 +40,7 @@ unicode-width = "0.2.2"
core-foundation = "0.10.1"
[target."cfg(windows)".dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader", "Win32_Graphics_Gdi"] }
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader", "Win32_Graphics_Gdi", "Win32_System_Console", "Win32_System_Pipes"] }
[target."cfg(unix)".dependencies]
libc = "0.2.186"
+13
View File
@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
+127 -26
View File
@@ -1,44 +1,145 @@
# furumi
Terminal client (TUI) for the furumusic server. Cross-platform: Linux,
macOS, Windows.
![furumi](furumi.png)
## Building
**Your music. Your devices. Your network.**
Rust 1.88+ (edition 2024).
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.
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.
Furumi runs on **Linux, macOS, and Windows**.
## 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. Visualizations are runtime-loadable Rhai
scripts executed in a resource-limited sandbox, so they can be added or edited
without rebuilding the player.
## Install
### macOS
On Apple Silicon Macs, install Furumi from the Homebrew tap:
```bash
cargo build --release # binary: target/release/furumi
brew install house-of-vanity/tap/furumi
```
### Linux
Run it with:
Sound output needs the system ALSA library — the one and only system
build dependency (PipeWire/PulseAudio are reached through the ALSA
compatibility layer at runtime):
```bash
furumi
```
### Linux, Windows, and other platforms
Download a prebuilt archive from the
[GitHub releases](https://github.com/house-of-vanity/furumi_tui/releases), or
build Furumi from source with Rust 1.97 or newer:
```bash
cargo build --release --locked
./target/release/furumi
```
On Debian or Ubuntu, install the Linux audio build dependencies first:
```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 pure Rust: TLS is rustls, MPRIS media keys go through
zbus (no libdbus), images and audio decoding are Rust crates.
Equivalent ALSA development packages are required on other Linux
distributions. macOS and Windows require no additional system packages.
### macOS / Windows
Import a music directory from Furumi's command line:
No system packages required.
```text
:import /path/to/music
```
## Configuration
Federation, trusted-device pairing, and key bindings are configured directly
inside the player.
- `keymap.toml` in the config dir — keybinding overrides, see
`src/config/default_keymap.toml` for the format and defaults.
Config dir: `~/.config/furumi` on Linux,
`~/Library/Application Support/furumi` on macOS.
- `credentials.json` in the same dir — created on login (0600).
- Logs: in-app on the Logs tab (`5`), and in the cache dir
(`furumi-cli.log`), filtered by `RUST_LOG`.
## Architecture
Furumi is a Rust application built with:
- `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.
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).
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

-226
View File
@@ -1,226 +0,0 @@
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};
use super::models::{TokensResponse, User};
/// Margin before access-token expiry at which we refresh proactively,
/// mirroring the Android/macOS clients.
pub const EXPIRY_SKEW_SECONDS: i64 = 60;
/// Persisted session, same shape as the macOS client's AuthSession.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthSession {
pub server_base_url: String,
pub user: User,
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
pub expires_at_epoch_seconds: i64,
}
impl AuthSession {
pub fn new(server_base_url: String, user: User, tokens: TokensResponse) -> Self {
Self {
server_base_url,
user,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: tokens.token_type,
expires_at_epoch_seconds: now_epoch_seconds() + tokens.expires_in_seconds,
}
}
pub fn apply_tokens(&mut self, tokens: TokensResponse) {
self.access_token = tokens.access_token;
self.refresh_token = tokens.refresh_token;
self.token_type = tokens.token_type;
self.expires_at_epoch_seconds = now_epoch_seconds() + tokens.expires_in_seconds;
}
pub fn access_token_expired(&self) -> bool {
now_epoch_seconds() + EXPIRY_SKEW_SECONDS >= self.expires_at_epoch_seconds
}
}
pub fn now_epoch_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn session_path() -> Option<PathBuf> {
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("credentials.json"))
}
pub fn load_session() -> Option<AuthSession> {
let path = session_path()?;
let text = fs::read_to_string(&path).ok()?;
match serde_json::from_str(&text) {
Ok(session) => Some(session),
Err(err) => {
tracing::warn!(path = %path.display(), %err, "ignoring unreadable credentials file");
None
}
}
}
pub fn save_session(session: &AuthSession) -> Result<()> {
let path = session_path().context("cannot determine config directory")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let text = serde_json::to_string_pretty(session)?;
write_private(&path, &text).with_context(|| format!("writing {}", path.display()))
}
pub fn delete_session() {
if let Some(path) = session_path() {
let _ = fs::remove_file(path);
}
}
#[cfg(unix)]
fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(text.as_bytes())
}
#[cfg(not(unix))]
fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> {
fs::write(path, text)
}
/// Same normalization rules as the Android client's ServerConfig:
/// add https:// when no scheme, require http(s) with a host, reject
/// credentials/query/fragment, lowercase the host, trim trailing slashes.
pub fn normalize_base_url(raw: &str) -> Result<String> {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
bail!("server URL is empty");
}
let with_scheme = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("https://{trimmed}")
};
let url = reqwest::Url::parse(&with_scheme).context("invalid server URL")?;
if !matches!(url.scheme(), "http" | "https") {
bail!("server URL must use http or https");
}
let host = url.host_str().filter(|h| !h.is_empty());
let Some(host) = host else {
bail!("server URL has no host");
};
if !url.username().is_empty() || url.password().is_some() {
bail!("server URL must not contain credentials");
}
if url.query().is_some() || url.fragment().is_some() {
bail!("server URL must not contain a query or fragment");
}
let mut normalized = format!("{}://{}", url.scheme(), host.to_ascii_lowercase());
if let Some(port) = url.port() {
normalized.push_str(&format!(":{port}"));
}
let path = url.path().trim_end_matches('/');
normalized.push_str(path);
Ok(normalized)
}
/// Accepts what the user pastes after browser SSO: either the full
/// `furumi://auth/callback?code=furu_mx_...` link (copied from the
/// "Open Furumi" button) or the bare `furu_mx_...` code.
pub fn extract_sso_code(input: &str) -> Result<String> {
let input = input.trim();
if input.is_empty() {
bail!("paste the link or code first");
}
if input.starts_with("furu_mx_") {
return Ok(input.to_string());
}
if let Ok(url) = reqwest::Url::parse(input) {
if let Some((_, error)) = url.query_pairs().find(|(k, _)| k == "error") {
bail!("SSO failed: {error}");
}
if let Some((_, code)) = url.query_pairs().find(|(k, _)| k == "code") {
return Ok(code.into_owned());
}
}
bail!("no furu_mx_ code found in the pasted text");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_adds_https_and_strips_slash() {
assert_eq!(
normalize_base_url(" Music.Hexor.cy/ ").unwrap(),
"https://music.hexor.cy"
);
}
#[test]
fn normalize_keeps_port_and_path() {
assert_eq!(
normalize_base_url("http://localhost:8000/furumi/").unwrap(),
"http://localhost:8000/furumi"
);
}
#[test]
fn normalize_rejects_bad_urls() {
assert!(normalize_base_url("").is_err());
assert!(normalize_base_url("ftp://x").is_err());
assert!(normalize_base_url("https://user:pw@host").is_err());
assert!(normalize_base_url("https://host?x=1").is_err());
}
#[test]
fn sso_code_from_deep_link() {
let code = extract_sso_code("furumi://auth/callback?code=furu_mx_abc123").unwrap();
assert_eq!(code, "furu_mx_abc123");
}
#[test]
fn sso_code_bare() {
assert_eq!(extract_sso_code(" furu_mx_x ").unwrap(), "furu_mx_x");
}
#[test]
fn sso_error_is_reported() {
let err = extract_sso_code("furumi://auth/callback?error=provider_denied")
.unwrap_err()
.to_string();
assert!(err.contains("provider_denied"));
}
#[test]
fn expiry_uses_skew() {
let session = AuthSession {
server_base_url: "https://x".into(),
user: User {
id: 1,
name: "n".into(),
role: "user".into(),
},
access_token: "a".into(),
refresh_token: "r".into(),
token_type: "Bearer".into(),
expires_at_epoch_seconds: now_epoch_seconds() + 30,
};
assert!(session.access_token_expired());
}
}
-610
View File
@@ -1,610 +0,0 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::sync::Mutex;
use super::auth::{self, AuthSession};
use super::models::{
ApiErrorBody, ArtistDetail, ArtistsPage, DevicePlaybackState, DevicePollResponse,
LikesResponse, LoginResponse, MeResponse, PlaylistCard, PlaylistDetail, ReleaseDetail,
SearchResults, TokensResponse, TrackItem,
};
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("{0}")]
Server(String),
/// Refresh token rejected or expired — the user must sign in again.
#[error("session expired, please sign in again")]
SessionExpired,
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
#[error("{0}")]
Other(#[from] anyhow::Error),
}
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(format!(
"furumi-tui/{} ({})",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS
))
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("reqwest client config is static")
}
pub fn device_name() -> String {
format!("furumi-tui ({})", std::env::consts::OS)
}
pub fn device_user_agent() -> String {
format!(
"FurumiTUI/{} {}",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS
)
}
#[derive(Serialize)]
struct PasswordLoginRequest<'a> {
username: &'a str,
password: &'a str,
device_name: String,
}
#[derive(Serialize)]
struct SsoExchangeRequest<'a> {
code: &'a str,
device_name: String,
}
#[derive(Serialize)]
struct RefreshRequest<'a> {
refresh_token: &'a str,
}
#[derive(Serialize)]
struct LogoutRequest<'a> {
refresh_token: &'a str,
}
pub async fn login_password(
http: &reqwest::Client,
base_url: &str,
username: &str,
password: &str,
) -> Result<AuthSession, ApiError> {
let response = http
.post(format!("{base_url}/api/auth/password"))
.json(&PasswordLoginRequest {
username,
password,
device_name: device_name(),
})
.send()
.await?;
let login: LoginResponse = parse_response(response).await?;
Ok(AuthSession::new(
base_url.to_string(),
login.user,
login.tokens,
))
}
pub async fn login_sso_exchange(
http: &reqwest::Client,
base_url: &str,
code: &str,
) -> Result<AuthSession, ApiError> {
let response = http
.post(format!("{base_url}/api/auth/sso/exchange"))
.json(&SsoExchangeRequest {
code,
device_name: device_name(),
})
.send()
.await?;
let login: LoginResponse = parse_response(response).await?;
Ok(AuthSession::new(
base_url.to_string(),
login.user,
login.tokens,
))
}
/// Browser entry point for SSO. redirect_uri is either our loopback
/// listener (`http://127.0.0.1:{port}/callback`) or the `furumi://` deep
/// link as a manual-paste fallback.
pub fn sso_start_url(base_url: &str, redirect_uri: &str) -> String {
let mut url = reqwest::Url::parse(&format!("{base_url}/auth/mobile/oidc/start"))
.expect("base_url is pre-validated");
url.query_pairs_mut()
.append_pair("redirect_uri", redirect_uri);
url.to_string()
}
async fn refresh_tokens(
http: &reqwest::Client,
base_url: &str,
refresh_token: &str,
) -> Result<TokensResponse, ApiError> {
let response = http
.post(format!("{base_url}/api/auth/refresh"))
.json(&RefreshRequest { refresh_token })
.send()
.await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err(ApiError::SessionExpired);
}
parse_response(response).await
}
/// Mirrors the backend's PlaybackStateDto.
#[derive(Debug, Serialize)]
pub struct PlaybackStateBody {
pub current_track_id: Option<i64>,
pub position_ms: i32,
pub queue: Vec<i64>,
pub queue_position: i32,
pub shuffle: bool,
pub repeat_mode: String,
pub volume: f64,
}
#[derive(Serialize)]
struct DevicePollRequest<'a> {
device_id: &'a str,
user_agent: String,
current_jam_id: Option<&'a str>,
playback_state: Option<DevicePlaybackState>,
}
#[derive(Serialize)]
struct DeviceActiveRequest<'a> {
device_id: &'a str,
current_device_id: &'a str,
}
#[derive(Serialize)]
struct DeviceCommandRequest<'a> {
target_device_id: Option<&'a str>,
jam_id: Option<&'a str>,
command: &'a str,
payload: &'a serde_json::Value,
}
/// Percent-encode a query-string value.
fn url_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, ApiError> {
let status = response.status();
if status.is_success() {
return Ok(response.json().await?);
}
let message = match response.json::<ApiErrorBody>().await {
Ok(body) => body.error,
Err(_) => format!("server returned {status}"),
};
Err(ApiError::Server(message))
}
/// Authenticated API client. Owns the session; refreshes the access token
/// proactively (60s skew) and once more on 401, persisting rotated tokens.
/// The session mutex makes concurrent refreshes single-flight.
pub struct ApiClient {
http: reqwest::Client,
base_url: String,
session: Mutex<AuthSession>,
}
impl ApiClient {
pub fn new(http: reqwest::Client, session: AuthSession) -> Self {
Self {
http,
base_url: session.server_base_url.clone(),
session: Mutex::new(session),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn me(&self) -> Result<MeResponse, ApiError> {
self.get_json("/api/player/me").await
}
pub async fn artists(&self, page: i64, limit: i64) -> Result<ArtistsPage, ApiError> {
self.get_json(&format!("/api/player/artists?page={page}&limit={limit}"))
.await
}
pub async fn artist(&self, id: i64) -> Result<ArtistDetail, ApiError> {
self.get_json(&format!("/api/player/artists/{id}")).await
}
pub async fn release(&self, id: i64) -> Result<ReleaseDetail, ApiError> {
self.get_json(&format!("/api/player/releases/{id}")).await
}
pub async fn search(&self, query: &str, limit: i64) -> Result<SearchResults, ApiError> {
self.get_json(&format!(
"/api/player/search?q={}&limit={limit}",
url_encode(query)
))
.await
}
/// Open an audio stream for playback: background download backed by a
/// temp file, exposing blocking Read+Seek for the decoder; seeking into
/// not-yet-downloaded ranges uses HTTP Range requests.
///
/// The download client carries the bearer token valid at start; on very
/// long tracks a Range request after token expiry (15 min) can fail —
/// acceptable for now, a refreshing middleware can replace this later.
pub async fn open_stream(
&self,
path: &str,
) -> Result<(crate::player::TrackReader, Option<u64>), ApiError> {
use stream_download::Settings;
use stream_download::http::HttpStream;
use stream_download::source::SourceStream as _;
use stream_download::storage::temp::TempStorageProvider;
let token = self.fresh_access_token().await?;
let mut headers = reqwest::header::HeaderMap::new();
let value = format!("Bearer {token}")
.parse()
.map_err(|_| ApiError::Server("invalid token header".to_string()))?;
headers.insert(reqwest::header::AUTHORIZATION, value);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(ApiError::Network)?;
let url = format!("{}{path}", self.base_url)
.parse()
.map_err(|err| ApiError::Server(format!("bad stream url: {err}")))?;
let stream = HttpStream::new(client, url)
.await
.map_err(|err| ApiError::Server(format!("stream open failed: {err}")))?;
let byte_len = stream.content_length();
let reader = stream_download::StreamDownload::from_stream(
stream,
TempStorageProvider::new(),
Settings::default(),
)
.await
.map_err(|err| ApiError::Server(format!("stream start failed: {err}")))?;
Ok((reader, byte_len))
}
/// Raw bytes (cover art, artist images) from a server-relative path.
pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>, ApiError> {
let url = format!("{}{path}", self.base_url);
let response = self
.send_authed(&url, |client, url, token| {
client.get(url).bearer_auth(token)
})
.await?;
let status = response.status();
if !status.is_success() {
return Err(ApiError::Server(format!("server returned {status}")));
}
Ok(response.bytes().await?.to_vec())
}
pub async fn playlists(&self) -> Result<Vec<PlaylistCard>, ApiError> {
self.get_json("/api/player/playlists").await
}
pub async fn playlist(&self, id: i64) -> Result<PlaylistDetail, ApiError> {
self.get_json(&format!("/api/player/playlists/{id}")).await
}
pub async fn create_playlist(&self, title: &str) -> Result<PlaylistCard, ApiError> {
#[derive(Serialize)]
struct Body<'a> {
title: &'a str,
}
self.post_json("/api/player/playlists", &Body { title })
.await
}
pub async fn add_tracks_to_playlist(
&self,
playlist_id: i64,
track_ids: &[i64],
) -> Result<(), ApiError> {
#[derive(Serialize)]
struct Body<'a> {
track_ids: &'a [i64],
}
let _: serde_json::Value = self
.post_json(
&format!("/api/player/playlists/{playlist_id}/tracks"),
&Body { track_ids },
)
.await?;
Ok(())
}
pub async fn likes(&self) -> Result<Vec<i64>, ApiError> {
let response: LikesResponse = self.get_json("/api/player/likes").await?;
Ok(response.track_ids)
}
pub async fn toggle_like(&self, track_id: i64) -> Result<bool, ApiError> {
#[derive(serde::Deserialize)]
struct Body {
liked: bool,
}
let body: Body = self
.post_json(&format!("/api/player/likes/toggle/{track_id}"), &())
.await?;
Ok(body.liked)
}
#[allow(
dead_code,
reason = "device-sync state restore needs id→track resolution"
)]
pub async fn tracks_by_ids(&self, track_ids: &[i64]) -> Result<Vec<TrackItem>, ApiError> {
#[derive(Serialize)]
struct Body<'a> {
track_ids: &'a [i64],
}
self.post_json("/api/player/tracks-by-ids", &Body { track_ids })
.await
}
/// Tell last.fm (via the server) what is playing right now. Called at
/// track start; the completed-play scrobble goes through /history.
pub async fn lastfm_now_playing(&self, track_id: i64) -> Result<(), ApiError> {
#[derive(Serialize)]
struct Body {
track_id: i64,
}
let _: serde_json::Value = self
.post_json("/api/player/lastfm/now-playing", &Body { track_id })
.await?;
Ok(())
}
/// Report a finished/aborted listen to the play history.
/// Body shape is the backend's HistoryEntry; `completed` marks a full
/// play (vs a manual skip).
pub async fn report_history(
&self,
track_id: i64,
started_at: Option<i64>,
listened_seconds: i32,
completed: bool,
) -> Result<(), ApiError> {
#[derive(Serialize)]
struct Body {
track_id: i64,
started_at: Option<i64>,
duration_listened: Option<i32>,
completed: bool,
}
let _: serde_json::Value = self
.post_json(
"/api/player/history",
&Body {
track_id,
started_at,
duration_listened: Some(listened_seconds),
completed,
},
)
.await?;
Ok(())
}
/// Persist playback state server-side (used for cross-device restore).
pub async fn push_state(&self, state: &PlaybackStateBody) -> Result<(), ApiError> {
let _: serde_json::Value = self.put_json("/api/player/state", state).await?;
Ok(())
}
pub async fn poll_device(
&self,
device_id: &str,
playback_state: Option<DevicePlaybackState>,
) -> Result<DevicePollResponse, ApiError> {
self.post_json(
"/api/player/devices/poll",
&DevicePollRequest {
device_id,
user_agent: device_user_agent(),
current_jam_id: None,
playback_state,
},
)
.await
}
pub async fn select_device(
&self,
target_device_id: &str,
current_device_id: &str,
) -> Result<DevicePollResponse, ApiError> {
self.post_json(
"/api/player/devices/active",
&DeviceActiveRequest {
device_id: target_device_id,
current_device_id,
},
)
.await
}
pub async fn send_device_command(
&self,
target_device_id: Option<&str>,
command: &str,
payload: &serde_json::Value,
) -> Result<(), ApiError> {
let _: serde_json::Value = self
.post_json(
"/api/player/devices/command",
&DeviceCommandRequest {
target_device_id,
jam_id: None,
command,
payload,
},
)
.await?;
Ok(())
}
/// Revoke this device's session server-side. Best effort: local
/// credentials are deleted regardless of the outcome.
pub async fn logout(&self) -> Result<bool, ApiError> {
let (access_token, refresh_token) = {
let session = self.session.lock().await;
(session.access_token.clone(), session.refresh_token.clone())
};
let response = self
.http
.post(format!("{}/api/auth/logout", self.base_url))
.bearer_auth(access_token)
.json(&LogoutRequest {
refresh_token: &refresh_token,
})
.send()
.await?;
#[derive(serde::Deserialize)]
struct LogoutResponse {
revoked: bool,
}
let body: LogoutResponse = parse_response(response).await?;
Ok(body.revoked)
}
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
self.json_request::<(), T>(reqwest::Method::GET, path, None)
.await
}
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, ApiError> {
self.json_request(reqwest::Method::POST, path, Some(body))
.await
}
pub async fn put_json<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, ApiError> {
self.json_request(reqwest::Method::PUT, path, Some(body))
.await
}
async fn json_request<B: Serialize, T: DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&B>,
) -> Result<T, ApiError> {
let url = format!("{}{path}", self.base_url);
let response = self
.send_authed(&url, |client, url, token| {
let mut request = client.request(method.clone(), url).bearer_auth(token);
if let Some(body) = body {
request = request.json(body);
}
request
})
.await;
let response = match response {
Ok(response) => response,
Err(err) => {
tracing::warn!(%err, %method, path, "api request failed");
return Err(err);
}
};
let status = response.status();
let result = parse_response(response).await;
if let Err(err) = &result {
tracing::warn!(%err, %status, %method, path, "api response error");
} else {
tracing::debug!(%status, %method, path, "api ok");
}
result
}
/// Send a request with a fresh bearer token; on 401, refresh once and
/// retry. `build` is called per attempt because RequestBuilder is not
/// reusable after send.
async fn send_authed<F>(&self, url: &str, build: F) -> Result<reqwest::Response, ApiError>
where
F: Fn(&reqwest::Client, &str, &str) -> reqwest::RequestBuilder,
{
let token = self.fresh_access_token().await?;
let response = build(&self.http, url, &token).send().await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
let token = self.refresh_after_rejection(&token).await?;
return Ok(build(&self.http, url, &token).send().await?);
}
Ok(response)
}
async fn fresh_access_token(&self) -> Result<String, ApiError> {
let mut session = self.session.lock().await;
if session.access_token_expired() {
self.refresh_locked(&mut session).await?;
}
Ok(session.access_token.clone())
}
/// A 401 with a token another task already rotated just retries with the
/// current token; otherwise this task performs the refresh itself.
async fn refresh_after_rejection(&self, rejected_token: &str) -> Result<String, ApiError> {
let mut session = self.session.lock().await;
if session.access_token != rejected_token {
return Ok(session.access_token.clone());
}
self.refresh_locked(&mut session).await?;
Ok(session.access_token.clone())
}
async fn refresh_locked(&self, session: &mut AuthSession) -> Result<(), ApiError> {
let result = refresh_tokens(&self.http, &self.base_url, &session.refresh_token).await;
match result {
Ok(tokens) => {
session.apply_tokens(tokens);
if let Err(err) = auth::save_session(session) {
tracing::warn!(%err, "failed to persist rotated tokens");
}
tracing::debug!("access token refreshed");
Ok(())
}
Err(ApiError::SessionExpired) => {
auth::delete_session();
Err(ApiError::SessionExpired)
}
Err(err) => Err(err),
}
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod auth;
pub mod client;
pub mod models;
-306
View File
@@ -1,306 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub name: String,
pub role: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TokensResponse {
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
pub expires_in_seconds: i64,
}
#[derive(Debug, Deserialize)]
pub struct LoginResponse {
pub user: User,
pub tokens: TokensResponse,
}
#[derive(Debug, Deserialize)]
#[allow(
dead_code,
reason = "rendered by the profile view in a later milestone"
)]
pub struct MeStats {
pub liked_tracks: i64,
pub playlists: i64,
pub plays: i64,
pub listened_minutes: i64,
}
#[derive(Debug, Deserialize)]
#[allow(
dead_code,
reason = "rendered by the profile view in a later milestone"
)]
pub struct MeResponse {
pub id: i64,
pub name: String,
pub role: String,
pub stats: MeStats,
}
#[derive(Debug, Deserialize)]
pub struct ApiErrorBody {
pub error: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ArtistCard {
#[allow(dead_code, reason = "opens the artist view in the next milestone")]
pub id: i64,
pub name: String,
/// Relative path like `/api/player/cover/{file_id}/medium`.
pub image_url: Option<String>,
pub release_count: i64,
pub track_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistRef {
#[allow(dead_code, reason = "navigation to artists from track rows later")]
pub id: i64,
pub name: String,
}
/// Serialize keeps every field the backend sent us, so device-sync payloads
/// (play_from_index, queue_add) carry full track objects like the web does.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackItem {
#[allow(dead_code, reason = "playback engine consumes this in milestone 3")]
pub id: i64,
pub title: String,
/// Absent in the artist-appearance variant of track payloads.
#[serde(default)]
pub track_number: Option<i32>,
pub duration_seconds: f64,
#[serde(default)]
pub artists: Vec<ArtistRef>,
#[serde(default)]
pub featured_artists: Vec<ArtistRef>,
#[allow(dead_code, reason = "jump-to-release navigation later")]
#[serde(default)]
pub release_id: i64,
#[serde(default)]
pub release_title: String,
#[allow(dead_code, reason = "shown in queue/now-playing later")]
pub release_year: Option<i32>,
/// Server-relative path to `/api/player/stream/{id}`.
#[serde(default)]
pub stream_url: String,
#[allow(dead_code, reason = "now-playing artwork in milestone 3")]
pub cover_url: Option<String>,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub file_size_bytes: Option<i64>,
#[allow(dead_code, reason = "popularity column later")]
pub lastfm_playcount: Option<i64>,
}
impl TrackItem {
pub fn artist_line(&self) -> String {
let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect();
if !self.featured_artists.is_empty() {
names.push("feat.");
names.extend(self.featured_artists.iter().map(|a| a.name.as_str()));
}
names.join(", ")
}
pub fn duration_label(&self) -> String {
let total = self.duration_seconds.round() as i64;
format!("{}:{:02}", total / 60, total % 60)
}
/// Full tech line for the status bar, including the sample rate.
pub fn tech_label_full(&self) -> String {
let mut parts = Vec::new();
if let Some(format) = &self.audio_format {
parts.push(format.to_uppercase());
}
if let Some(bitrate) = self.audio_bitrate {
parts.push(format!("{bitrate}kbps"));
}
if let Some(rate) = self.audio_sample_rate {
parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0));
}
if let Some(bytes) = self.file_size_bytes {
parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0));
}
parts.join(" · ")
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ReleaseCard {
pub id: i64,
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub cover_url: Option<String>,
pub track_count: i64,
}
#[derive(Debug, Deserialize)]
pub struct ArtistDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub name: String,
pub image_url: Option<String>,
pub total_track_count: i64,
pub total_play_count: i64,
pub top_tracks: Vec<TrackItem>,
pub releases: Vec<ReleaseCard>,
/// Tracks where this artist is featured (the only content for artists
/// without own releases).
#[serde(default)]
pub featured_tracks: Vec<TrackItem>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UploaderSummary {
pub name: String,
#[allow(dead_code, reason = "per-uploader stats for a later detail popup")]
pub track_count: i64,
}
#[derive(Debug, Deserialize)]
pub struct ReleaseDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub cover_url: Option<String>,
pub artists: Vec<ArtistRef>,
pub tracks: Vec<TrackItem>,
pub uploaders: Vec<UploaderSummary>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PlaylistCard {
pub id: i64,
pub title: String,
pub track_count: i64,
pub is_own: bool,
pub owner_name: Option<String>,
pub is_public: bool,
#[allow(dead_code, reason = "save/unsave playlists later")]
pub is_saved: bool,
#[allow(dead_code, reason = "playlist kinds get distinct icons later")]
pub kind: String,
}
#[derive(Debug, Deserialize)]
pub struct PlaylistDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub title: String,
#[allow(dead_code, reason = "shown in a detail header later")]
pub description: Option<String>,
pub tracks: Vec<TrackItem>,
}
#[derive(Debug, Deserialize)]
pub struct LikesResponse {
pub track_ids: Vec<i64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceDto {
pub id: String,
pub name: String,
pub kind: String,
#[allow(dead_code, reason = "server-side flag; we compare ids directly")]
pub is_current: bool,
pub is_active: bool,
#[allow(dead_code, reason = "freshness display later")]
pub last_seen_ms: i64,
}
#[derive(Debug, Deserialize)]
pub struct DeviceCommandDto {
#[allow(dead_code, reason = "commands are applied in poll order")]
pub id: Option<String>,
pub command: String,
#[serde(default)]
pub payload: serde_json::Value,
}
/// Mirrors the backend's PlayerDevicePlaybackStateDto; tracks stay raw JSON
/// so unknown fields survive the round trip between clients.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DevicePlaybackState {
#[serde(default)]
pub track: Option<serde_json::Value>,
#[serde(default)]
pub tracks: Vec<serde_json::Value>,
#[serde(default)]
pub index: i32,
#[serde(default)]
pub position_seconds: f64,
#[serde(default)]
pub duration_seconds: f64,
#[serde(default)]
pub paused: bool,
#[serde(default)]
pub shuffle: bool,
#[serde(default = "default_repeat_mode")]
pub repeat_mode: String,
#[serde(default = "default_volume")]
pub volume: f64,
#[serde(default)]
pub updated_at_ms: i64,
}
fn default_repeat_mode() -> String {
"off".to_string()
}
fn default_volume() -> f64 {
1.0
}
#[derive(Debug, Deserialize)]
pub struct DevicePollResponse {
#[allow(dead_code, reason = "echo of our own id")]
pub device_id: String,
pub active_device_id: Option<String>,
#[serde(default)]
pub devices: Vec<DeviceDto>,
#[serde(default)]
pub commands: Vec<DeviceCommandDto>,
#[serde(default)]
#[allow(dead_code, reason = "Jam control is out of scope for the TUI v1")]
pub current_jam_id: Option<String>,
pub playback_state: Option<DevicePlaybackState>,
}
#[derive(Debug, Default, Deserialize)]
pub struct SearchResults {
pub artists: Vec<ArtistCard>,
pub releases: Vec<ReleaseCard>,
pub tracks: Vec<TrackItem>,
}
impl SearchResults {
pub fn len(&self) -> usize {
self.artists.len() + self.releases.len() + self.tracks.len()
}
}
#[derive(Debug, Deserialize)]
pub struct ArtistsPage {
pub items: Vec<ArtistCard>,
pub total: i64,
pub page: i64,
#[allow(dead_code, reason = "part of the server pagination envelope")]
pub per_page: i64,
pub has_more: bool,
}
+53 -13
View File
@@ -21,25 +21,40 @@ pub enum Action {
PlayPause,
NextTrack,
PrevTrack,
SeekForward { seconds: u32 },
SeekBackward { seconds: u32 },
SeekForward {
seconds: u32,
},
SeekBackward {
seconds: u32,
},
VolumeUp,
VolumeDown,
ToggleShuffle,
CycleRepeat,
ToggleVisualizer,
ToggleLike,
ToggleTrackSelection,
OpenTrackInfo,
OpenCurrentTrackInfo,
QueueAddNext,
QueueAddLast,
/// Download the selected federated track(s) into the local library.
DownloadSelected,
RemoveFromQueue,
ClearQueue,
OpenConnectedDevices,
OpenListenHistory,
GoToRelease,
AddToPlaylist,
NewPlaylist,
ToggleHelp,
ToggleViewMode,
OpenDevices,
CycleSourceMode,
OpenLibraryFilters,
OpenCommandLine,
OpenSearch,
Logout,
EditSelected,
DeleteSelected,
}
/// Help-window sections, in display order.
@@ -47,15 +62,17 @@ pub enum Action {
pub enum Category {
Playback,
Queue,
Library,
Navigation,
Search,
System,
}
impl Category {
pub const ALL: [Category; 5] = [
pub const ALL: [Category; 6] = [
Category::Playback,
Category::Queue,
Category::Library,
Category::Navigation,
Category::Search,
Category::System,
@@ -65,6 +82,7 @@ impl Category {
match self {
Category::Playback => "Playback",
Category::Queue => "Queue & playlists",
Category::Library => "Library",
Category::Navigation => "Navigation",
Category::Search => "Search & commands",
Category::System => "System",
@@ -83,13 +101,21 @@ impl Action {
| Action::VolumeUp
| Action::VolumeDown
| Action::ToggleShuffle
| Action::CycleRepeat => Category::Playback,
| Action::CycleRepeat
| Action::ToggleVisualizer
| Action::OpenConnectedDevices
| Action::OpenListenHistory => Category::Playback,
Action::QueueAddNext
| Action::QueueAddLast
| Action::DownloadSelected
| Action::RemoveFromQueue
| Action::ClearQueue
| Action::AddToPlaylist
| Action::NewPlaylist
| Action::ToggleLike => Category::Queue,
| Action::ToggleLike
| Action::ToggleTrackSelection
| Action::OpenTrackInfo
| Action::OpenCurrentTrackInfo => Category::Queue,
Action::MoveUp
| Action::MoveDown
| Action::MoveLeft
@@ -105,9 +131,12 @@ impl Action {
| Action::GoToTab(_)
| Action::GoToRelease
| Action::ToggleViewMode => Category::Navigation,
Action::EditSelected
| Action::DeleteSelected
| Action::CycleSourceMode
| Action::OpenLibraryFilters => Category::Library,
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
Action::OpenDevices => Category::System,
Action::ToggleHelp | Action::Logout | Action::Quit => Category::System,
Action::ToggleHelp | Action::Quit => Category::System,
}
}
@@ -115,7 +144,7 @@ impl Action {
pub fn command_hint(&self) -> Option<&'static str> {
match self {
Action::Quit => Some(":q"),
Action::Logout => Some(":logout"),
Action::EditSelected => Some(":import <path> adds files"),
Action::PlayPause => Some(":play"),
Action::NextTrack => Some(":next"),
Action::PrevTrack => Some(":prev"),
@@ -124,7 +153,8 @@ impl Action {
Action::ToggleShuffle => Some(":shuffle"),
Action::CycleRepeat => Some(":repeat [off|one|all]"),
Action::ClearQueue => Some(":clear"),
Action::OpenDevices => Some(":devices"),
Action::OpenConnectedDevices => None,
Action::OpenListenHistory => None,
Action::ToggleHelp => Some(":help"),
Action::OpenSearch => Some("/text"),
_ => None,
@@ -156,19 +186,29 @@ impl Action {
Action::VolumeDown => "Volume down".into(),
Action::ToggleShuffle => "Toggle shuffle".into(),
Action::CycleRepeat => "Cycle repeat mode".into(),
Action::ToggleVisualizer => "Toggle fullscreen visualizer".into(),
Action::ToggleLike => "Like / unlike".into(),
Action::ToggleTrackSelection => "Track line selection".into(),
Action::OpenTrackInfo => "Track info".into(),
Action::OpenCurrentTrackInfo => "Current track info".into(),
Action::QueueAddNext => "Queue: add next".into(),
Action::QueueAddLast => "Queue: add to end".into(),
Action::DownloadSelected => "Federation: download to library".into(),
Action::RemoveFromQueue => "Queue: remove selected".into(),
Action::ClearQueue => "Queue: clear".into(),
Action::OpenConnectedDevices => "Connected devices…".into(),
Action::OpenListenHistory => "Listening history…".into(),
Action::GoToRelease => "Open the track's release".into(),
Action::AddToPlaylist => "Add track to a playlist…".into(),
Action::NewPlaylist => "Create a playlist".into(),
Action::ToggleHelp => "Show / hide keybindings".into(),
Action::ToggleViewMode => "Toggle tiles / table view".into(),
Action::OpenDevices => "Connected devices".into(),
Action::CycleSourceMode => "Cycle source mode: Local / My / Global".into(),
Action::OpenLibraryFilters => "Library filters…".into(),
Action::OpenCommandLine => "Command line (:help for commands)".into(),
Action::OpenSearch => "Search artists, releases, tracks".into(),
Action::Logout => "Sign out".into(),
Action::EditSelected => "Edit the selected item".into(),
Action::DeleteSelected => "Delete the selected item".into(),
}
}
}
+94 -38
View File
@@ -2,9 +2,8 @@ use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{KeyCode, KeyEvent};
use crate::api::client::ApiError;
use crate::app::Runtime;
use crate::app::command::{self, Command, Parsed};
use crate::app::event::AppEvent;
@@ -18,32 +17,22 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
match key.code {
KeyCode::Esc => cancel(state),
KeyCode::Enter => commit(state, runtime),
KeyCode::Backspace => {
if state.cmdline.input.pop().is_none() {
// Backspace on an empty line closes it, like vim.
cancel(state);
return;
// Backspace on an empty line closes it, like vim.
KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state),
_ => {
if state.cmdline.input.handle_key(key) {
after_change(state, runtime);
}
after_change(state, runtime);
}
KeyCode::Char(c) if is_typing(key) => {
state.cmdline.input.push(c);
after_change(state, runtime);
}
_ => {}
}
}
pub fn handle_paste(state: &mut AppState, runtime: &Runtime, pasted: &str) {
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
state.cmdline.input.push_str(&cleaned);
state.cmdline.input.insert_str(&cleaned);
after_change(state, runtime);
}
fn is_typing(key: KeyEvent) -> bool {
key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
}
/// Re-evaluate the input after every edit; live commands (search) take
/// effect immediately, while typing.
fn after_change(state: &mut AppState, runtime: &Runtime) {
@@ -59,7 +48,9 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
match command {
// One-shot commands have no live effect.
Command::Quit
| Command::Logout
| Command::Import(_)
| Command::Open(_)
| Command::ConnectInvite(_)
| Command::Volume(_)
| Command::Seek(_)
| Command::SeekTo(_)
@@ -70,7 +61,6 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
| Command::Prev
| Command::PlayPause
| Command::Help
| Command::Devices
| Command::Logs(_) => {}
Command::Search(query) => {
state.active_tab = Tab::Global;
@@ -96,17 +86,18 @@ fn set_view_cursor_zero(state: &mut AppState) {
/// Debounced, race-free search: every edit bumps the global sequence; the
/// spawned task only queries if it is still the latest after the debounce,
/// and the receiver drops responses that arrive out of date.
fn schedule_search(state: &mut AppState, runtime: &Runtime) {
pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
let query = state.search.query.clone();
if query.is_empty() {
state.search.loading = false;
state.search.results = None;
state.search.fed_tracks.clear();
state.search.fed_artists.clear();
state.search.fed_loading = false;
return;
}
let Some(api) = runtime.api.clone() else {
return;
};
let library = Arc::clone(&runtime.library);
state.search.loading = true;
let tx = runtime.event_tx.clone();
let latest = Arc::clone(&runtime.search_seq);
@@ -115,18 +106,56 @@ fn schedule_search(state: &mut AppState, runtime: &Runtime) {
if latest.load(Ordering::SeqCst) != seq {
return;
}
let event = match api.search(&query, SEARCH_LIMIT).await {
Ok(results) => AppEvent::SearchLoaded {
seq,
result: Ok(results),
},
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::SearchLoaded {
seq,
result: Err(err.to_string()),
},
};
let _ = tx.send(event);
let result = tokio::task::spawn_blocking(move || library.search(&query, SEARCH_LIMIT))
.await
.map_err(|err| err.to_string())
.and_then(|result| result.map_err(|err| format!("{err:#}")));
let _ = tx.send(AppEvent::SearchLoaded { seq, result });
});
// The same query also runs against the federated network (when the
// node is up); its results render as a separate, marked section.
state.search.fed_tracks.clear();
state.search.fed_artists.clear();
state.search.fed_loading = false;
if runtime.federation.settings().enabled {
state.search.fed_loading = true;
let fed = Arc::clone(&runtime.federation);
let query = state.search.query.clone();
let tx = runtime.event_tx.clone();
let latest = Arc::clone(&runtime.search_seq);
tokio::spawn(async move {
tokio::time::sleep(SEARCH_DEBOUNCE).await;
if latest.load(Ordering::SeqCst) != seq {
return;
}
let result = fed.search(&query).await.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::FedSearchLoaded { seq, result });
});
}
}
/// Refresh only the local-library half of an already open search.
///
/// Library/device sync notifications can arrive while federated search
/// results are visible. Reusing `schedule_search` here would clear the
/// federation rows and bump the shared sequence, causing valid network
/// responses to be dropped or flicker away.
pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
let query = state.search.query.clone();
if query.is_empty() {
return;
}
let seq = runtime.search_seq.load(Ordering::SeqCst);
let library = Arc::clone(&runtime.library);
state.search.loading = true;
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = tokio::task::spawn_blocking(move || library.search(&query, SEARCH_LIMIT))
.await
.map_err(|err| err.to_string())
.and_then(|result| result.map_err(|err| format!("{err:#}")));
let _ = tx.send(AppEvent::SearchLoaded { seq, result });
});
}
@@ -161,7 +190,9 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
match command {
Command::Search(_) => {}
Command::Quit => state.should_quit = true,
Command::Logout => super::perform_logout(state, runtime),
Command::Import(path) => super::spawn_import(state, runtime, &path),
Command::Open(link) => open_frid_link(state, runtime, link),
Command::ConnectInvite(invite) => super::device_connect(runtime, invite),
Command::Volume(value) => {
state.player.volume = value;
super::perform_effect(state, runtime, Effect::SetVolume(value));
@@ -194,7 +225,6 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
Command::Prev => run_action(state, runtime, Action::PrevTrack),
Command::PlayPause => run_action(state, runtime, Action::PlayPause),
Command::Help => state.help_visible = true,
Command::Devices => run_action(state, runtime, Action::OpenDevices),
Command::Logs(level) => {
if let Some(index) = level {
state.logs.level_index = index.min(LOG_LEVELS.len() - 1);
@@ -206,6 +236,32 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
}
}
fn open_frid_link(state: &mut AppState, runtime: &Runtime, link: String) {
let Some(link) = crate::share::parse_frid_link(&link) else {
state.status_message = Some("usage: :open frid://<content_id>".into());
return;
};
state.status_message = Some(match link.label.as_deref() {
Some(label) => format!("federation: opening \"{label}\""),
None => "federation: opening shared track…".into(),
});
let federation = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match federation
.track_by_content_id(&link.content_id, link.label.as_deref())
.await
{
Ok(track) => AppEvent::EnqueueTracks {
tracks: vec![crate::federation::pending_track(&track)],
next: false,
},
Err(err) => AppEvent::StatusMessage(format!("open failed: {err:#}")),
};
let _ = tx.send(event);
});
}
/// Esc: close the line and undo any live effect it had.
fn cancel(state: &mut AppState) {
retract_live(state);
+51 -8
View File
@@ -22,8 +22,13 @@ pub enum Command {
/// `:q` / `:quit` — exit immediately (explicit enough to skip the
/// double-press confirmation).
Quit,
/// `:logout` — sign out and return to the login screen.
Logout,
/// `:import <path>` — import an audio file or a directory into the
/// library.
Import(String),
/// `:open frid://...` — open a shared federation content link.
Open(String),
/// `:connect frid://i/...` — pair this client with a trusted device.
ConnectInvite(String),
/// `:volume 40` (also `:vol`) — set the volume precisely.
Volume(u8),
/// `:seek +30` / `:seek -10` — relative seek in seconds.
@@ -43,8 +48,6 @@ pub enum Command {
PlayPause,
/// `:help` — open the keybinding help.
Help,
/// `:devices` — open the connected-devices picker.
Devices,
/// `:logs [error|warn|info|debug|trace]` — jump to the Logs tab,
/// optionally setting the severity filter.
Logs(Option<usize>),
@@ -74,7 +77,31 @@ pub fn parse(input: &str) -> Parsed {
let arg = parts.next();
match name {
"q" | "quit" => Parsed::Command(Command::Quit),
"logout" => Parsed::Command(Command::Logout),
"import" | "add" => {
let path = input.trim_start().split_once(char::is_whitespace);
match path.map(|(_, rest)| rest.trim()) {
Some(path) if !path.is_empty() => {
Parsed::Command(Command::Import(path.to_string()))
}
_ => Parsed::Invalid("usage: :import <file or directory>".to_string()),
}
}
"open" => {
let value = input.trim_start().split_once(char::is_whitespace);
match value.map(|(_, rest)| rest.trim()) {
Some(value) if !value.is_empty() => Parsed::Command(Command::Open(value.into())),
_ => Parsed::Invalid("usage: :open frid://<content_id>".to_string()),
}
}
"connect" => {
let value = input.trim_start().split_once(char::is_whitespace);
match value.map(|(_, rest)| rest.trim()) {
Some(value) if !value.is_empty() => {
Parsed::Command(Command::ConnectInvite(value.into()))
}
_ => Parsed::Invalid("usage: :connect frid://i/<invite>".to_string()),
}
}
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
@@ -96,7 +123,6 @@ pub fn parse(input: &str) -> Parsed {
"prev" => Parsed::Command(Command::Prev),
"pause" | "play" => Parsed::Command(Command::PlayPause),
"help" => Parsed::Command(Command::Help),
"devices" | "device" => Parsed::Command(Command::Devices),
"logs" => match arg {
None => Parsed::Command(Command::Logs(None)),
Some(level) => match ["error", "warn", "info", "debug", "trace"]
@@ -152,7 +178,25 @@ mod tests {
fn parses_word_commands() {
assert_eq!(parse("q"), Parsed::Command(Command::Quit));
assert_eq!(parse("quit"), Parsed::Command(Command::Quit));
assert_eq!(parse("logout"), Parsed::Command(Command::Logout));
assert_eq!(
parse("import ~/Music/My Album"),
Parsed::Command(Command::Import("~/Music/My Album".to_string()))
);
assert_eq!(
parse(
"open frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=A-B"
),
Parsed::Command(Command::Open(
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=A-B"
.to_string()
))
);
assert!(matches!(parse("import"), Parsed::Invalid(_)));
assert!(matches!(parse("open"), Parsed::Invalid(_)));
assert_eq!(
parse("connect frid://i/abcd"),
Parsed::Command(Command::ConnectInvite("frid://i/abcd".to_string()))
);
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
@@ -162,7 +206,6 @@ mod tests {
Parsed::Command(Command::Repeat(Some(RepeatArg::All)))
);
assert_eq!(parse("clear"), Parsed::Command(Command::ClearQueue));
assert_eq!(parse("devices"), Parsed::Command(Command::Devices));
assert_eq!(parse("logs debug"), Parsed::Command(Command::Logs(Some(3))));
}
+115 -26
View File
@@ -1,25 +1,24 @@
use std::sync::Arc;
use crate::api::auth::AuthSession;
use crate::api::models::{
ArtistDetail, ArtistsPage, DevicePollResponse, PlaylistCard, PlaylistDetail, ReleaseDetail,
SearchResults, TrackItem,
};
use crate::art::ArtImage;
use crate::library::models::{
ArtistDetail, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults,
TrackItem,
};
/// Events delivered to the main loop by background tasks (API fetches, the
/// playback engine, device sync). Tasks never touch AppState directly.
/// Events delivered to the main loop by background tasks (library queries,
/// the playback engine, imports). Tasks never touch AppState directly.
#[derive(Debug)]
pub enum AppEvent {
StatusMessage(String),
LoginSucceeded(Box<AuthSession>),
LoginFailed(String),
/// Loopback listener received the browser SSO callback.
SsoCallback(Result<String, String>),
/// Refresh token rejected — stored credentials were deleted.
SessionExpired,
/// A page of the Global artists list arrived (or failed).
/// A page of the artists list arrived (or failed).
ArtistsLoaded(Result<ArtistsPage, String>),
/// A full reload after a library change: replaces the loaded artist
/// list wholesale, so the grid never flashes empty.
ArtistsReloaded {
page: ArtistsPage,
limit: i64,
},
ArtistViewLoaded {
id: i64,
result: Result<ArtistDetail, String>,
@@ -33,7 +32,7 @@ pub enum AppEvent {
seq: u64,
result: Result<SearchResults, String>,
},
/// Artwork fetched and decoded for the shared art cache.
/// Artwork loaded and decoded for the shared art cache.
ArtLoaded {
key: String,
art: Option<Arc<ArtImage>>,
@@ -41,7 +40,7 @@ pub enum AppEvent {
Player(crate::player::PlayerEvent),
/// A command from the OS media keys.
Media(crate::media::MediaCommand),
/// Gapless prefetch could not open the stream; the normal track-switch
/// Gapless prefetch could not open the file; the normal track-switch
/// path takes over when the current track ends.
PrefetchFailed {
pos: usize,
@@ -51,17 +50,28 @@ pub enum AppEvent {
id: i64,
result: Result<PlaylistDetail, String>,
},
/// Liked track ids for the ♥ markers.
LikesLoaded(Result<Vec<i64>, String>),
/// Liked local content ids for the ♥ markers.
LikesLoaded(Result<Vec<String>, String>),
/// Local-library content ids for availability markers.
LocalContentIdsLoaded(Result<Vec<String>, String>),
/// Counts and storage footprint of the local library/database.
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
ListenHistoryLoaded(Result<Vec<crate::library::ListenHistoryEntry>, String>),
/// One content id became available locally while the UI is open.
LocalContentAvailable {
content_id: String,
},
LikeToggled {
track_id: i64,
content_id: String,
liked: bool,
},
/// Liked federated item ids and content ids for the ♥ markers.
FedLikesLoaded(Result<Vec<String>, String>),
FedLikeToggled {
item_id: String,
content_id: Option<String>,
liked: bool,
},
/// Connected-devices poll result; carries device list, active id,
/// remote playback state and commands for this TUI.
DevicesPolled(Result<DevicePollResponse, String>),
/// Response from switching the active device.
DeviceActivated(Result<DevicePollResponse, String>),
/// A release fetched for queueing (a / shift-a on a release).
EnqueueTracks {
tracks: Vec<TrackItem>,
@@ -69,12 +79,91 @@ pub enum AppEvent {
},
PlaylistCreated {
result: Result<PlaylistCard, String>,
/// Add this track to the new playlist right away (Shift-P flow).
add_track: Option<TrackItem>,
/// Add this target to the new playlist right away (Shift-P flow).
add_target: Option<crate::app::state::PlaylistAddTarget>,
},
PlaylistTracksAdded {
playlist_id: i64,
playlist_title: String,
result: Result<(), String>,
},
/// The library was mutated (import, edit, delete): cached views must be
/// dropped and reloaded lazily.
LibraryChanged {
message: Option<String>,
},
/// Progress of a running import, shown in the status bar.
ImportProgress {
done: usize,
total: usize,
current: String,
},
/// Fresh copies of the queued tracks after a library change. Tracks
/// missing from the result were deleted and leave the queue.
QueueTracksRefreshed {
tracks: Vec<TrackItem>,
},
/// A status snapshot for the Federation tab.
FederationStatus(crate::federation::FedStatus),
/// Federated live-search results (artists a card can be opened for,
/// plus matching tracks).
FedSearchLoaded {
seq: u64,
result: Result<crate::federation::FedSearchResults, String>,
},
/// A federated artist card finished assembling.
FedArtistLoaded {
name: String,
result: Result<crate::federation::FedArtistCard, String>,
},
/// Federated enrichment for an already-open local artist view.
ArtistFederationLoaded {
id: i64,
name: String,
result: Result<crate::federation::FedArtistCard, String>,
},
/// A network-library source refreshed its cached top-artist slice.
NetworkArtistCacheUpdated {
source_id: String,
count: usize,
},
/// A streamed image for the open card arrived (artist image when
/// `release` is None, a release cover otherwise).
FedCardArt {
name: String,
release: Option<String>,
path: String,
},
/// A pending federated track finished downloading (or failed); the
/// queue swaps the placeholder for the resolved track.
FedTrackResolved {
placeholder_id: i64,
resolve_key: String,
result: Result<Box<crate::federation::FedPlayable>, String>,
},
/// Rich metadata for a federated track-info preview arrived without
/// downloading the audio file.
FedTrackInfoLoaded {
placeholder_id: i64,
item_id: String,
result: Result<TrackItem, String>,
},
/// This peer's connection ticket, requested from the Federation tab.
FedTicket(Result<String, String>),
/// Immediate library publish finished.
FedSyncFinished(String),
/// Fresh personal-device sync status snapshot for Settings.
DeviceSyncStatus(crate::devices::DeviceSyncStatus),
/// Invite link for pairing another device.
DeviceInvite(Result<String, String>),
/// Result of `:connect frid://i/...`.
DeviceConnectResult(Result<String, String>),
/// Manual trusted-device sync finished.
DeviceSyncFinished(String),
/// Incoming pairing request that passed the invite-secret check.
DevicePairingRequest(crate::devices::PendingPairing),
/// Trusted device playback state, delivered by personal-device sync.
DevicePlayback(crate::devices::PlaybackSnapshot),
/// Playback command addressed to this device.
PlaybackCommand(crate::devices::PlaybackCommand),
}
+147
View File
@@ -0,0 +1,147 @@
//! One-line text editing with a movable cursor, shared by every text input
//! (command line, edit forms, popup fields).
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// A single-line value plus a cursor position (in characters). Dereferences
/// to `&str`, so read paths treat it like the plain string it wraps.
#[derive(Debug, Clone, Default)]
pub struct LineEdit {
value: String,
cursor: usize,
}
impl LineEdit {
/// Starts with `value` and the cursor at its end.
pub fn new(value: impl Into<String>) -> Self {
let value = value.into();
let cursor = value.chars().count();
Self { value, cursor }
}
pub fn as_str(&self) -> &str {
&self.value
}
/// Cursor position in characters (0..=len).
pub fn cursor(&self) -> usize {
self.cursor
}
fn byte_index(&self, chars: usize) -> usize {
self.value
.char_indices()
.nth(chars)
.map(|(index, _)| index)
.unwrap_or(self.value.len())
}
pub fn clear(&mut self) {
self.value.clear();
self.cursor = 0;
}
pub fn insert(&mut self, c: char) {
let at = self.byte_index(self.cursor);
self.value.insert(at, c);
self.cursor += 1;
}
pub fn insert_str(&mut self, s: &str) {
let at = self.byte_index(self.cursor);
self.value.insert_str(at, s);
self.cursor += s.chars().count();
}
/// Removes the character before the cursor; `false` when at the start.
pub fn backspace(&mut self) -> bool {
if self.cursor == 0 {
return false;
}
let at = self.byte_index(self.cursor - 1);
self.value.remove(at);
self.cursor -= 1;
true
}
/// Removes the character under the cursor.
pub fn delete(&mut self) {
if self.cursor < self.value.chars().count() {
let at = self.byte_index(self.cursor);
self.value.remove(at);
}
}
/// Applies one editing key (characters, backspace/delete, cursor
/// movement). Returns `false` for keys this editor does not handle
/// (Enter, Esc, Tab, ...), which the caller interprets itself.
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
self.insert(c);
}
KeyCode::Backspace => {
self.backspace();
}
KeyCode::Delete => self.delete(),
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Right => self.cursor = (self.cursor + 1).min(self.value.chars().count()),
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.value.chars().count(),
_ => return false,
}
true
}
}
impl std::ops::Deref for LineEdit {
type Target = str;
fn deref(&self) -> &str {
&self.value
}
}
impl std::fmt::Display for LineEdit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.value)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn edits_at_cursor() {
let mut edit = LineEdit::new("hllo");
assert_eq!(edit.cursor(), 4);
edit.handle_key(key(KeyCode::Home));
edit.handle_key(key(KeyCode::Right));
edit.handle_key(key(KeyCode::Char('e')));
assert_eq!(edit.as_str(), "hello");
assert_eq!(edit.cursor(), 2);
edit.handle_key(key(KeyCode::End));
edit.handle_key(key(KeyCode::Backspace));
assert_eq!(edit.as_str(), "hell");
edit.handle_key(key(KeyCode::Home));
edit.handle_key(key(KeyCode::Delete));
assert_eq!(edit.as_str(), "ell");
}
#[test]
fn multibyte_safe() {
let mut edit = LineEdit::new("метл");
edit.handle_key(key(KeyCode::Left));
edit.insert('а');
assert_eq!(edit.as_str(), "метал");
edit.handle_key(key(KeyCode::End));
edit.insert('л');
assert_eq!(edit.as_str(), "металл");
}
}
-221
View File
@@ -1,221 +0,0 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::{auth, client};
use crate::app::Runtime;
use crate::app::event::AppEvent;
use crate::app::sso;
use crate::app::state::{AppState, LoginField, LoginForm, LoginMode};
pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
state.should_quit = true;
return;
}
let form = &mut state.login;
if form.busy {
return;
}
match form.mode {
LoginMode::Form => handle_form_key(form, runtime, key),
LoginMode::SsoPending => handle_sso_key(form, runtime, key),
}
}
/// Bracketed paste goes into whichever text field is focused.
pub fn handle_paste(state: &mut AppState, pasted: &str) {
let form = &mut state.login;
if form.busy {
return;
}
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
if let Some(field) = focused_text(form) {
field.push_str(&cleaned);
}
}
fn handle_form_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
match key.code {
KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(),
KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(),
KeyCode::Backspace => {
if let Some(field) = focused_text(form) {
field.pop();
}
}
KeyCode::Enter => match form.focus {
LoginField::ServerUrl | LoginField::Username => form.focus = form.focus.next(),
LoginField::Password | LoginField::SignInButton => {
submit_password(form, runtime);
}
LoginField::SsoButton => start_sso(form, runtime),
},
KeyCode::Char(c) if is_typing(key) => {
if let Some(field) = focused_text(form) {
field.push(c);
}
}
_ => {}
}
}
fn handle_sso_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
// Ctrl-shortcuts first: plain letters belong to the paste field.
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
// Copy the full SSO URL — terminals can't copy a wrapped link
// in one piece, the clipboard can.
KeyCode::Char('l') => {
form.error = None;
match copy_to_clipboard(&form.sso_url) {
Ok(()) => form.error = Some("link copied to clipboard".to_string()),
Err(err) => {
tracing::warn!(%err, "clipboard copy failed");
form.error = Some(format!("copy failed: {err}"));
}
}
}
KeyCode::Char('o') => {
if let Err(err) = open::that_detached(&form.sso_url) {
tracing::warn!(%err, "failed to reopen browser");
form.error = Some("couldn't open a browser".to_string());
}
}
_ => {}
}
return;
}
match key.code {
KeyCode::Esc => {
if let Some(listener) = runtime.sso.take() {
listener.abort();
}
form.mode = LoginMode::Form;
form.sso_paste.clear();
form.error = None;
}
KeyCode::Backspace => {
form.sso_paste.pop();
}
KeyCode::Enter => submit_sso_code(form, runtime),
KeyCode::Char(c) if is_typing(key) => form.sso_paste.push(c),
_ => {}
}
}
fn copy_to_clipboard(text: &str) -> Result<(), arboard::Error> {
arboard::Clipboard::new()?.set_text(text.to_string())
}
fn is_typing(key: KeyEvent) -> bool {
key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
}
fn focused_text(form: &mut LoginForm) -> Option<&mut String> {
if form.mode == LoginMode::SsoPending {
return Some(&mut form.sso_paste);
}
match form.focus {
LoginField::ServerUrl => Some(&mut form.server_url),
LoginField::Username => Some(&mut form.username),
LoginField::Password => Some(&mut form.password),
LoginField::SignInButton | LoginField::SsoButton => None,
}
}
fn submit_password(form: &mut LoginForm, runtime: &Runtime) {
form.error = None;
let base_url = match auth::normalize_base_url(&form.server_url) {
Ok(url) => url,
Err(err) => return form.error = Some(err.to_string()),
};
let username = form.username.trim().to_string();
if username.is_empty() {
return form.error = Some("enter a username".to_string());
}
if form.password.is_empty() {
return form.error = Some("enter a password".to_string());
}
form.server_url = base_url.clone();
form.busy = true;
let password = form.password.clone();
let http = runtime.http.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = client::login_password(&http, &base_url, &username, &password).await;
let _ = tx.send(login_event(result));
});
}
fn start_sso(form: &mut LoginForm, runtime: &mut Runtime) {
form.error = None;
let base_url = match auth::normalize_base_url(&form.server_url) {
Ok(url) => url,
Err(err) => return form.error = Some(err.to_string()),
};
form.server_url = base_url.clone();
form.sso_paste.clear();
// Preferred flow: loopback listener, the browser redirect finishes the
// login hands-free. Fallback: furumi:// deep link + manual code paste.
if let Some(listener) = runtime.sso.take() {
listener.abort();
}
match sso::start(runtime.event_tx.clone()) {
Ok(listener) => {
let redirect = format!("http://127.0.0.1:{}/callback", listener.port);
form.sso_url = client::sso_start_url(&base_url, &redirect);
form.sso_port = Some(listener.port);
runtime.sso = Some(listener);
}
Err(err) => {
tracing::warn!(%err, "loopback listener unavailable, falling back to manual paste");
form.sso_url = client::sso_start_url(&base_url, "furumi://auth/callback");
form.sso_port = None;
}
}
form.mode = LoginMode::SsoPending;
if let Err(err) = open::that_detached(&form.sso_url) {
tracing::warn!(%err, "failed to open browser for SSO");
form.error = Some("couldn't open a browser — use the URL below".to_string());
}
}
fn submit_sso_code(form: &mut LoginForm, runtime: &Runtime) {
form.error = None;
let code = match auth::extract_sso_code(&form.sso_paste) {
Ok(code) => code,
Err(err) => return form.error = Some(err.to_string()),
};
spawn_sso_exchange(form, runtime, code);
}
/// Used by both the manual paste path and the loopback callback event.
pub fn spawn_sso_exchange(form: &mut LoginForm, runtime: &Runtime, code: String) {
let base_url = form.server_url.clone();
form.busy = true;
let http = runtime.http.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = client::login_sso_exchange(&http, &base_url, &code).await;
let _ = tx.send(login_event(result));
});
}
fn login_event(result: Result<auth::AuthSession, client::ApiError>) -> AppEvent {
match result {
Ok(session) => {
tracing::info!(user = %session.user.name, server = %session.server_base_url, "signed in");
if let Err(err) = auth::save_session(&session) {
tracing::warn!(%err, "failed to persist credentials");
}
AppEvent::LoginSucceeded(Box::new(session))
}
Err(err) => {
tracing::warn!(%err, "login failed");
AppEvent::LoginFailed(err.to_string())
}
}
}
+2970 -906
View File
File diff suppressed because it is too large Load Diff
+1088 -112
View File
File diff suppressed because it is too large Load Diff
-134
View File
@@ -1,134 +0,0 @@
use std::io;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc::UnboundedSender;
use crate::app::event::AppEvent;
/// Loopback callback listener for browser SSO (RFC 8252 native-app flow).
/// The backend 303-redirects the browser to `http://127.0.0.1:{port}/callback`
/// with the exchange code; the listener delivers it as an AppEvent and exits.
pub struct SsoListener {
pub port: u16,
handle: tokio::task::JoinHandle<()>,
}
impl SsoListener {
pub fn abort(&self) {
self.handle.abort();
}
}
pub fn start(tx: UnboundedSender<AppEvent>) -> io::Result<SsoListener> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
let handle = tokio::spawn(async move {
let result = match serve_one(listener).await {
Ok(result) => result,
Err(err) => Err(format!("callback listener failed: {err}")),
};
let _ = tx.send(AppEvent::SsoCallback(result));
});
Ok(SsoListener { port, handle })
}
async fn serve_one(listener: std::net::TcpListener) -> io::Result<Result<String, String>> {
let listener = tokio::net::TcpListener::from_std(listener)?;
loop {
let (mut stream, _) = listener.accept().await?;
let request_line = read_request_line(&mut stream).await?;
let Some(result) = parse_request_line(&request_line) else {
// Stray request (favicon, prefetch) — keep waiting for the code.
let _ = stream.write_all(&response(404, "Not Found")).await;
continue;
};
let page = match &result {
Ok(_) => "Sign-in complete. You can close this window and return to the terminal.",
Err(_) => "Sign-in failed. Return to the terminal to see the error.",
};
let _ = stream.write_all(&response(200, page)).await;
let _ = stream.shutdown().await;
return Ok(result);
}
}
async fn read_request_line(stream: &mut tokio::net::TcpStream) -> io::Result<String> {
let mut buf = vec![0u8; 8192];
let mut len = 0;
while len < buf.len() {
let n = stream.read(&mut buf[len..]).await?;
if n == 0 {
break;
}
len += n;
if buf[..len].windows(2).any(|w| w == b"\r\n") {
break;
}
}
let text = String::from_utf8_lossy(&buf[..len]);
Ok(text.lines().next().unwrap_or_default().to_string())
}
/// `GET /callback?code=furu_mx_... HTTP/1.1` → Ok(code) / Err(error).
/// Values are plain tokens (no percent-encoded characters expected).
fn parse_request_line(line: &str) -> Option<Result<String, String>> {
let path = line.split_whitespace().nth(1)?;
let query = path.split_once('?').map(|(_, q)| q).unwrap_or("");
let mut code = None;
let mut error = None;
for pair in query.split('&') {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
match key {
"code" if !value.is_empty() => code = Some(value.to_string()),
"error" if !value.is_empty() => error = Some(value.to_string()),
_ => {}
}
}
if let Some(error) = error {
return Some(Err(format!("SSO failed: {error}")));
}
code.map(Ok)
}
fn response(status: u16, body: &str) -> Vec<u8> {
let reason = if status == 200 { "OK" } else { "Not Found" };
let body = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>furumi</title></head>\
<body style=\"font-family:sans-serif;background:#101114;color:#f5f2ea;\
display:grid;place-items:center;min-height:100vh;margin:0\"><p>{body}</p></body></html>"
);
format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_code_from_request_line() {
assert_eq!(
parse_request_line("GET /callback?code=furu_mx_abc HTTP/1.1"),
Some(Ok("furu_mx_abc".to_string()))
);
}
#[test]
fn parses_error_from_request_line() {
assert_eq!(
parse_request_line("GET /callback?error=provider_denied HTTP/1.1"),
Some(Err("SSO failed: provider_denied".to_string()))
);
}
#[test]
fn ignores_unrelated_requests() {
assert_eq!(parse_request_line("GET /favicon.ico HTTP/1.1"), None);
assert_eq!(parse_request_line("GET /callback HTTP/1.1"), None);
}
}
+1315 -156
View File
File diff suppressed because it is too large Load Diff
+1907 -546
View File
File diff suppressed because it is too large Load Diff
+926
View File
@@ -0,0 +1,926 @@
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
}
#[test]
fn listening_history_popup_requests_a_background_load() {
let mut state = AppState::default();
assert_eq!(
update(&mut state, Action::OpenListenHistory),
Some(Effect::LoadListenHistory)
);
assert!(matches!(
state.popup,
Some(crate::app::state::Popup::ListenHistory { cursor: 0 })
));
assert!(matches!(
state.listen_history,
Some(crate::app::state::Loadable::Loading)
));
}
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,
}
}
fn pending_fed_track(id: i64) -> TrackItem {
crate::federation::pending_track(&crate::federation::FedTrack {
item_id: format!("fed-{id}"),
owner: "peer".into(),
own: false,
title: format!("remote-{id}"),
artist_names: vec!["remote artist".into()],
featured_artist_names: vec![],
year: None,
duration_seconds: Some(1),
content_id: Some(format!("b3:{id:064x}")),
release_title: Some("remote release".into()),
track_number: None,
disc_number: 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 source_mode_cycles_on_library_playlists_and_queue_tabs() {
use crate::config::settings::LibrarySourceMode;
let mut state = AppState::default();
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::My);
state.active_tab = Tab::Playlists;
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global);
state.active_tab = Tab::Queue;
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
state.active_tab = Tab::Federation;
assert_eq!(update(&mut state, Action::CycleSourceMode), None);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
}
#[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 local_mode_hides_pending_federation_tracks_from_playlists_and_playback() {
let mut state = AppState {
active_tab: Tab::Playlists,
..AppState::default()
};
state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 });
state.playlist_views.insert(
7,
Loadable::Ready(crate::library::models::PlaylistDetail {
id: 7,
title: "mixed".into(),
description: None,
tracks: vec![test_track(1), pending_fed_track(2), test_track(3)],
}),
);
assert_eq!(
playlist_tracks(&state, 7)
.unwrap()
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(
update(&mut state, Action::Select),
Some(Effect::PlayCurrent)
);
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(state.player.queue_pos, 1);
}
#[test]
fn network_modes_show_pending_federation_playlist_tracks() {
let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My;
state.playlist_views.insert(
7,
Loadable::Ready(crate::library::models::PlaylistDetail {
id: 7,
title: "mixed".into(),
description: None,
tracks: vec![test_track(1), pending_fed_track(2)],
}),
);
assert_eq!(playlist_tracks(&state, 7).unwrap().len(), 2);
}
#[test]
fn local_mode_rejects_async_federation_queue_additions() {
let mut state = AppState::default();
enqueue_tracks(
&mut state,
vec![test_track(1), pending_fed_track(2), test_track(3)],
false,
);
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
}
#[test]
fn switching_to_local_mode_removes_pending_federation_queue_tracks() {
let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My;
state.player.queue = vec![test_track(1), pending_fed_track(2), test_track(3)];
state.player.queue_pos = 1;
state.player.current = Some(state.player.queue[1].clone());
state.player.playing = true;
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
let effect = apply_library_filter_change(&mut state);
assert!(matches!(
effect,
Some(Effect::RemoveQueueIndices {
indices,
restart_paused: Some(false),
stop: false,
}) if indices == vec![1]
));
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
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 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);
}
+51 -10
View File
@@ -9,7 +9,7 @@
# command: an Action name, optionally with parameters:
# command = { SeekForward = { seconds = 30 } }
# context: optional view filter — global (default), library, search,
# playlists, queue, devices.
# playlists, queue, logs.
[[keymaps]]
key_sequence = "q"
@@ -47,6 +47,14 @@ command = { GoToTab = 2 }
key_sequence = "4"
command = { GoToTab = 3 }
[[keymaps]]
key_sequence = "5"
command = { GoToTab = 4 }
[[keymaps]]
key_sequence = "y"
command = "DownloadSelected"
[[keymaps]]
key_sequence = "a"
command = "QueueAddNext"
@@ -57,7 +65,16 @@ command = "QueueAddLast"
[[keymaps]]
key_sequence = "shift-c"
command = "ClearQueue"
command = "OpenConnectedDevices"
[[keymaps]]
key_sequence = "d"
command = "RemoveFromQueue"
context = "queue"
[[keymaps]]
key_sequence = "delete"
command = "RemoveFromQueue"
context = "queue"
[[keymaps]]
@@ -68,11 +85,6 @@ command = "GoToRelease"
key_sequence = "shift-p"
command = "AddToPlaylist"
[[keymaps]]
key_sequence = "n"
command = "NewPlaylist"
context = "playlists"
[[keymaps]]
key_sequence = "j"
command = "MoveDown"
@@ -181,22 +193,51 @@ command = "ToggleShuffle"
key_sequence = "r"
command = "CycleRepeat"
[[keymaps]]
key_sequence = "shift-l"
command = "ToggleVisualizer"
[[keymaps]]
key_sequence = "shift-h"
command = "OpenListenHistory"
[[keymaps]]
key_sequence = "x"
command = "ToggleLike"
[[keymaps]]
key_sequence = "shift-l"
command = "Logout"
key_sequence = "shift-v"
command = "ToggleTrackSelection"
[[keymaps]]
key_sequence = "i"
command = "OpenTrackInfo"
[[keymaps]]
key_sequence = "shift-i"
command = "OpenCurrentTrackInfo"
[[keymaps]]
key_sequence = "e"
command = "EditSelected"
[[keymaps]]
key_sequence = "shift-d"
command = "OpenDevices"
command = "DeleteSelected"
[[keymaps]]
key_sequence = "v"
command = "ToggleViewMode"
[[keymaps]]
key_sequence = "m"
command = "CycleSourceMode"
[[keymaps]]
key_sequence = "f"
command = "OpenLibraryFilters"
context = "library"
[[keymaps]]
key_sequence = ":"
command = "OpenCommandLine"
+127 -28
View File
@@ -20,7 +20,8 @@ pub enum KeyContext {
Search,
Playlists,
Queue,
Devices,
#[serde(rename = "settings", alias = "federation")]
Federation,
Logs,
}
@@ -32,7 +33,7 @@ impl KeyContext {
KeyContext::Search => "search",
KeyContext::Playlists => "playlists",
KeyContext::Queue => "queue",
KeyContext::Devices => "devices",
KeyContext::Federation => "settings",
KeyContext::Logs => "logs",
}
}
@@ -81,19 +82,19 @@ impl Keymap {
let mut bindings =
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
let mut warning = None;
if let Some(path) = user_keymap_path() {
if path.exists() {
match fs::read_to_string(&path)
.map_err(anyhow::Error::from)
.and_then(|text| parse_bindings(&text))
{
Ok(user) => merge(&mut bindings, user),
Err(err) => {
warning = Some(format!(
"{} ignored: {err:#}; using default keybindings",
path.display()
));
}
if let Some(path) = user_keymap_path()
&& path.exists()
{
match fs::read_to_string(&path)
.map_err(anyhow::Error::from)
.and_then(|text| parse_bindings(&text))
{
Ok(user) => merge(&mut bindings, user),
Err(err) => {
warning = Some(format!(
"{} ignored: {err:#}; using default keybindings",
path.display()
));
}
}
}
@@ -219,10 +220,11 @@ enum Lookup {
/// Letters (of any alphabet) keep SHIFT (that is how "shift-g" works);
/// symbols drop it so a "?" binding matches everywhere.
fn normalize(key: KeyCombination) -> KeyCombination {
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes {
if !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
}
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes
&& !c.is_alphabetic()
&& key.modifiers.contains(KeyModifiers::SHIFT)
{
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
}
key
}
@@ -303,15 +305,15 @@ fn parse_chord(chord: &str) -> Result<KeyCombination> {
// crokey only parses single-byte characters; non-ASCII keys (Cyrillic
// bindings) are built directly.
let mut chars = chord.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
if !c.is_ascii() {
let modifiers = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
}
if let (Some(c), None) = (chars.next(), chars.next())
&& !c.is_ascii()
{
let modifiers = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
}
KeyCombination::from_str(chord)
.map_err(|e| anyhow::anyhow!("{e}"))
@@ -415,6 +417,94 @@ mod tests {
);
}
#[test]
fn default_playlist_context_keeps_next_track_on_n() {
let mut km = keymap_from(DEFAULT_KEYMAP);
assert_eq!(
km.resolve(key!(n), KeyContext::Playlists),
KeyResolution::Action(Action::NextTrack)
);
}
#[test]
fn default_source_mode_key_resolves_on_content_tabs() {
let mut km = keymap_from(DEFAULT_KEYMAP);
for context in [
KeyContext::Library,
KeyContext::Playlists,
KeyContext::Queue,
] {
assert_eq!(
km.resolve(key!(m), context),
KeyResolution::Action(Action::CycleSourceMode)
);
}
}
#[test]
fn default_shift_n_is_unbound() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let shift_n = KeyCombination::new(KeyCode::Char('N'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(shift_n, KeyContext::Library),
KeyResolution::Unmatched
);
assert_eq!(
km.resolve(shift_n, KeyContext::Playlists),
KeyResolution::Unmatched
);
}
#[test]
fn default_add_to_playlist_key_is_global() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let shift_p = KeyCombination::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(shift_p, KeyContext::Library),
KeyResolution::Action(Action::AddToPlaylist)
);
}
#[test]
fn default_current_track_info_key_resolves() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let shift_i = KeyCombination::new(KeyCode::Char('I'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(shift_i, KeyContext::Library),
KeyResolution::Action(Action::OpenCurrentTrackInfo)
);
}
#[test]
fn default_visualizer_key_resolves() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let shift_l = KeyCombination::new(KeyCode::Char('L'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(shift_l, KeyContext::Library),
KeyResolution::Action(Action::ToggleVisualizer)
);
}
#[test]
fn settings_context_keeps_federation_alias() {
let settings = parse_bindings(
r#"
[[keymaps]]
key_sequence = "z"
command = "ToggleHelp"
context = "settings"
[[keymaps]]
key_sequence = "x"
command = "ToggleHelp"
context = "federation"
"#,
)
.unwrap();
assert_eq!(settings[0].context, KeyContext::Federation);
assert_eq!(settings[1].context, KeyContext::Federation);
}
#[test]
fn user_binding_overrides_default() {
let mut bindings = parse_bindings(DEFAULT_KEYMAP).unwrap();
@@ -506,4 +596,13 @@ mod tests {
KeyResolution::Action(Action::SeekForward { seconds: 10 })
);
}
#[test]
fn default_listening_history_key_resolves() {
let mut km = keymap_from(DEFAULT_KEYMAP);
assert_eq!(
km.resolve(key!(shift - h), KeyContext::Library),
KeyResolution::Action(Action::OpenListenHistory)
);
}
}
+1 -47
View File
@@ -1,55 +1,9 @@
pub mod keymap;
pub mod logging;
pub mod settings;
use directories::ProjectDirs;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
pub fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("", "", "furumi")
}
pub fn device_id_path() -> Option<PathBuf> {
project_dirs().map(|dirs| dirs.config_dir().join("device_id"))
}
pub fn load_or_create_device_id() -> String {
if let Some(path) = device_id_path() {
if let Ok(raw) = fs::read_to_string(&path) {
let id = raw.trim();
if valid_device_id(id) {
return id.to_string();
}
}
let id = generate_device_id();
if let Some(parent) = path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
tracing::warn!(path = %parent.display(), %err, "failed to create config directory");
return id;
}
}
if let Err(err) = fs::write(&path, &id) {
tracing::warn!(path = %path.display(), %err, "failed to persist device id");
}
return id;
}
generate_device_id()
}
fn valid_device_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
}
fn generate_device_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("tui-{nanos:x}-{:x}", std::process::id())
}
+133
View File
@@ -0,0 +1,133 @@
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LibrarySourceMode {
#[default]
Local,
My,
Global,
}
impl LibrarySourceMode {
pub fn label(self) -> &'static str {
match self {
LibrarySourceMode::Local => "Local",
LibrarySourceMode::My => "My",
LibrarySourceMode::Global => "Global",
}
}
pub fn includes_network(self) -> bool {
!matches!(self, LibrarySourceMode::Local)
}
pub fn includes_global_peers(self) -> bool {
matches!(self, LibrarySourceMode::Global)
}
pub fn next(self) -> Self {
match self {
LibrarySourceMode::Local => LibrarySourceMode::My,
LibrarySourceMode::My => LibrarySourceMode::Global,
LibrarySourceMode::Global => LibrarySourceMode::Local,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LibraryFilters {
#[serde(default)]
pub hide_featured_only: bool,
#[serde(default)]
pub source_mode: LibrarySourceMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default = "default_volume")]
pub volume: u8,
#[serde(default)]
pub library: LibraryFilters,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
volume: default_volume(),
library: LibraryFilters::default(),
}
}
}
impl AppSettings {
pub fn normalized(mut self) -> Self {
self.volume = self.volume.min(100);
self
}
}
fn default_volume() -> u8 {
80
}
pub fn load() -> (AppSettings, Option<String>) {
let Some(path) = settings_path() else {
return (AppSettings::default(), None);
};
match std::fs::read_to_string(&path) {
Ok(text) => match toml::from_str::<AppSettings>(&text) {
Ok(settings) => (settings.normalized(), None),
Err(err) => (
AppSettings::default(),
Some(format!("settings.toml is malformed: {err}")),
),
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => (AppSettings::default(), None),
Err(err) => (
AppSettings::default(),
Some(format!("settings.toml could not be read: {err}")),
),
}
}
pub fn save(settings: &AppSettings) -> Result<()> {
let path = settings_path().context("cannot determine the config directory")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(
&path,
toml::to_string_pretty(&settings.clone().normalized())?,
)
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
fn settings_path() -> Option<std::path::PathBuf> {
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("settings.toml"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_parse_and_normalize() {
let settings: AppSettings = toml::from_str(
r#"
volume = 150
[library]
hide_featured_only = true
"#,
)
.unwrap();
let settings = settings.normalized();
assert_eq!(settings.volume, 100);
assert!(settings.library.hide_featured_only);
assert_eq!(settings.library.source_mode, LibrarySourceMode::Local);
}
}
+3600
View File
File diff suppressed because it is too large Load Diff
+506
View File
@@ -0,0 +1,506 @@
use super::*;
static NEXT_TEST_DB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn test_sync() -> DeviceSync {
let conn = Connection::open_in_memory().unwrap();
init_schema(&conn).unwrap();
let unique = NEXT_TEST_DB.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let library_path = std::env::temp_dir().join(format!(
"furumi-devices-test-{}-{}-{}.sqlite3",
std::process::id(),
now_ms(),
unique
));
let sync = DeviceSync {
conn: Arc::new(std::sync::Mutex::new(conn)),
library: Arc::new(Library::open(&library_path).unwrap()),
event_tx: Arc::new(std::sync::Mutex::new(None)),
playback: Arc::new(std::sync::Mutex::new(PlaybackShared::default())),
};
sync.ensure_identity().unwrap();
sync
}
fn device_revoked(sync: &DeviceSync, device_id: &str) -> bool {
let conn = lock(&sync.conn);
conn.query_row(
"SELECT revoked_at_ms IS NOT NULL
FROM sync_devices
WHERE device_id = ?1",
[device_id],
|row| row.get::<_, i64>(0),
)
.optional()
.unwrap()
.unwrap_or(0)
!= 0
}
fn device_known(sync: &DeviceSync, device_id: &str) -> bool {
let conn = lock(&sync.conn);
conn.query_row(
"SELECT 1 FROM sync_devices WHERE device_id = ?1",
[device_id],
|row| row.get::<_, i64>(0),
)
.optional()
.unwrap()
.is_some()
}
fn test_fed_track(content_id: &str) -> crate::federation::FedTrack {
crate::federation::FedTrack {
item_id: "fed_item_1".to_string(),
owner: "fed_owner_1".to_string(),
own: false,
title: "Remote Song".to_string(),
artist_names: vec!["Remote Artist".to_string()],
featured_artist_names: Vec::new(),
year: Some(2026),
duration_seconds: Some(123),
content_id: Some(content_id.to_string()),
release_title: Some("Remote Release".to_string()),
track_number: Some(1),
disc_number: Some(1),
}
}
#[test]
fn base64url_round_trip_without_padding() {
for input in [b"".as_slice(), b"a", b"ab", b"abc", b"abcdef"] {
let encoded = base64url_encode(input);
assert!(!encoded.contains('='));
assert_eq!(base64url_decode(&encoded).unwrap(), input);
}
}
#[test]
fn tombstone_detection() {
assert!(
SyncOpPayload::TrackLikeSet {
content_id: "b3:0".into(),
liked: false,
fed: None,
}
.is_tombstone()
);
assert!(
!SyncOpPayload::TrackLikeSet {
content_id: "b3:0".into(),
liked: true,
fed: None,
}
.is_tombstone()
);
}
#[test]
fn playback_tracks_do_not_sync_device_local_paths() {
let source = TrackItem {
id: 7,
title: "Local Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
duration_seconds: 180.0,
artists: vec![ArtistRef {
id: 1,
name: "Local Artist".to_string(),
}],
featured_artists: Vec::new(),
release_id: 2,
release_title: "Local Release".to_string(),
release_year: Some(2026),
file_path: r"C:\Users\me\Music\song.mp3".to_string(),
content_id: Some(format!("b3:{}", "a".repeat(64))),
cover_path: None,
audio_format: Some("mp3".to_string()),
audio_bitrate: Some(320),
audio_sample_rate: Some(44_100),
audio_bit_depth: None,
file_size_bytes: Some(123_456),
play_count: 3,
fed: None,
};
let wire = PlaybackTrack::from_track(&source);
assert!(wire.file_path.is_empty());
let mut legacy_wire = wire.clone();
legacy_wire.file_path = "/Users/me/Music/song.mp3".to_string();
let restored = legacy_wire.to_track_item();
assert!(restored.id < 0);
assert_ne!(restored.id, source.id);
assert!(restored.file_path.is_empty());
assert_eq!(restored.content_id, source.content_id);
}
#[test]
fn compacted_device_revoke_removes_device_row() {
let sync = test_sync();
let device_id = "dev_old";
sync.apply_device_trusted(device_id, 10).unwrap();
assert!(device_known(&sync, device_id));
sync.revoke_device(device_id).unwrap();
assert!(!device_known(&sync, device_id));
}
#[test]
fn leave_group_self_revokes_then_resets_to_new_group() {
let sync = test_sync();
let identity = sync.ensure_identity().unwrap();
let old_group = identity.group_id.clone();
sync.apply_device_trusted("dev_peer", 10).unwrap();
let op_id = sync.record_leave_group_revoke().unwrap();
assert!(device_revoked(&sync, &identity.device_id));
{
let conn = lock(&sync.conn);
let payload_json: String = conn
.query_row(
"SELECT payload_json FROM sync_ops WHERE op_id = ?1",
[&op_id],
|row| row.get(0),
)
.unwrap();
let payload: SyncOpPayload = serde_json::from_str(&payload_json).unwrap();
match payload {
SyncOpPayload::DeviceRevoked {
target_device_id,
target_max_seq_seen,
} => {
assert_eq!(target_device_id, identity.device_id);
assert_eq!(target_max_seq_seen, 1);
}
other => panic!("unexpected payload: {other:?}"),
}
}
let new_group = sync.finish_leave_group_reset().unwrap();
assert_ne!(old_group, new_group);
let status = sync.status();
assert_eq!(status.group_id, new_group);
assert_eq!(status.active_devices, 1);
assert_eq!(status.devices.len(), 1);
assert!(status.devices[0].is_self);
assert!(!status.devices[0].revoked);
assert_eq!(status.ops_total, 0);
assert_eq!(status.outbox_ops, 0);
assert!(!device_known(&sync, "dev_peer"));
}
#[test]
fn playback_command_is_targeted_and_deduplicated() {
let sync = test_sync();
let identity = sync.ensure_identity().unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
sync.set_event_tx(tx);
let command = PlaybackCommand::SetState {
state: PlaybackStateWire {
queue: Vec::new(),
queue_pos: 0,
playing: false,
paused: false,
idle_since_ms: None,
position_secs: 0.0,
volume: 42,
shuffle: false,
repeat: PlaybackRepeat::Off,
},
seek: false,
};
sync.apply_playback_command("dev_other", &command, "op_other")
.unwrap();
assert!(rx.try_recv().is_err());
sync.apply_playback_command(&identity.device_id, &command, "op_1")
.unwrap();
assert!(matches!(
rx.try_recv().unwrap(),
crate::app::event::AppEvent::PlaybackCommand(_)
));
sync.apply_playback_command(&identity.device_id, &command, "op_1")
.unwrap();
assert!(rx.try_recv().is_err());
}
#[test]
fn newer_device_trust_reactivates_revoked_device() {
let sync = test_sync();
let device_id = "dev_readd";
sync.apply_device_trusted(device_id, 10).unwrap();
assert!(!device_revoked(&sync, device_id));
sync.apply_device_revoked(device_id, 20, "dev_owner", 0)
.unwrap();
assert!(device_revoked(&sync, device_id));
sync.apply_device_trusted(device_id, 30).unwrap();
assert!(!device_revoked(&sync, device_id));
sync.apply_device_revoked(device_id, 25, "dev_owner", 0)
.unwrap();
assert!(!device_revoked(&sync, device_id));
sync.apply_device_profile(
&DeviceProfileWire {
device_id: device_id.to_string(),
name: "readded".to_string(),
client_version: CLIENT_VERSION.to_string(),
protocol_version: PROTOCOL_VERSION,
endpoint_id: String::new(),
endpoint_ticket: String::new(),
revoked: true,
revoke_cutoff_seq: Some(0),
updated_at_ms: 20,
},
false,
)
.unwrap();
assert!(!device_revoked(&sync, device_id));
}
#[test]
fn tombstone_gc_waits_for_every_active_remote_ack() {
let sync = test_sync();
let origin = sync.ensure_identity().unwrap().device_id;
sync.apply_device_trusted("dev_a", 1).unwrap();
sync.apply_device_trusted("dev_b", 1).unwrap();
sync.record_local_op(SyncOpPayload::PlaylistDeleted {
playlist_id: "pl_deleted".to_string(),
})
.unwrap();
{
let conn = lock(&sync.conn);
let tombstones: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(tombstones, 1);
}
let ack = BTreeMap::from([(origin, 1)]);
sync.note_peer_vector("dev_a", &ack).unwrap();
sync.gc_tombstones().unwrap();
{
let conn = lock(&sync.conn);
let tombstones: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(tombstones, 1);
}
sync.note_peer_vector("dev_b", &ack).unwrap();
sync.gc_tombstones().unwrap();
let conn = lock(&sync.conn);
let tombstones: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(tombstones, 0);
}
#[test]
fn snapshot_carries_deleted_playlists_to_repair_stale_peers() {
let source = test_sync();
let source_playlist = source.library.create_playlist("Gone").unwrap();
let playlist_sync_id = source
.library
.ensure_playlist_sync_id(source_playlist.id)
.unwrap();
source
.apply_playlist_state(&playlist_sync_id, "Gone", false, 10, "dev_remote:1")
.unwrap();
source
.apply_playlist_state(&playlist_sync_id, "", true, 20, "dev_remote:2")
.unwrap();
let snapshot = source.snapshot().unwrap();
assert!(
snapshot
.deleted_playlists
.iter()
.any(|playlist| playlist.playlist_id == playlist_sync_id)
);
let peer = test_sync();
let peer_playlist = peer
.library
.upsert_synced_playlist(&playlist_sync_id, "Gone")
.unwrap();
assert!(peer.library.playlist(peer_playlist).is_ok());
peer.apply_snapshot(snapshot).unwrap();
assert!(
!peer
.library
.playlists()
.unwrap()
.iter()
.any(|playlist| playlist.title == "Gone")
);
}
#[test]
fn synced_fed_like_metadata_repairs_existing_like_state() {
let sync = test_sync();
let content_id = format!("b3:{}", "a".repeat(64));
let fed = test_fed_track(&content_id);
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
assert!(
sync.apply_like_state(&content_id, true, None, 10, "dev_remote:1")
.unwrap()
);
assert!(sync.library.fed_like_ids().unwrap().is_empty());
assert!(
sync.apply_like_state(&content_id, true, Some(&synced), 10, "dev_remote:1")
.unwrap()
);
let keys = sync.library.fed_like_ids().unwrap();
assert!(keys.contains(&fed.item_id));
assert!(keys.contains(&content_id));
assert!(
sync.apply_like_state(&content_id, false, None, 11, "dev_remote:2")
.unwrap()
);
assert!(sync.library.fed_like_ids().unwrap().is_empty());
}
#[test]
fn synced_fed_likes_are_ordered_by_hlc_not_receive_time() {
let sync = test_sync();
let old_content_id = format!("b3:{}", "c".repeat(64));
let new_content_id = format!("b3:{}", "d".repeat(64));
let mut old_fed = test_fed_track(&old_content_id);
old_fed.item_id = "fed_old".to_string();
old_fed.title = "Old Fed".to_string();
let mut new_fed = test_fed_track(&new_content_id);
new_fed.item_id = "fed_new".to_string();
new_fed.title = "New Fed".to_string();
let new_synced = SyncedFedTrack::from_fed(&new_fed).unwrap();
let old_synced = SyncedFedTrack::from_fed(&old_fed).unwrap();
sync.apply_like_state(&new_content_id, true, Some(&new_synced), 20, "dev_remote:2")
.unwrap();
sync.apply_like_state(&old_content_id, true, Some(&old_synced), 10, "dev_remote:1")
.unwrap();
let titles: Vec<String> = sync
.library
.playlist(crate::library::LIKES_PLAYLIST_ID)
.unwrap()
.tracks
.into_iter()
.map(|track| track.title)
.collect();
assert_eq!(titles, vec!["New Fed", "Old Fed"]);
}
#[test]
fn synced_playlist_item_metadata_creates_pending_fed_track() {
let sync = test_sync();
let playlist = sync.library.create_playlist("Remote Mix").unwrap();
let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap();
let content_id = format!("b3:{}", "b".repeat(64));
let fed = test_fed_track(&content_id);
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
assert!(
sync.apply_playlist_item_state(
&playlist_sync_id,
&content_id,
true,
3,
Some(&synced),
10,
"dev_remote:2",
)
.unwrap()
);
let detail = sync.library.playlist(playlist.id).unwrap();
assert_eq!(detail.tracks.len(), 1);
assert!(detail.tracks[0].is_fed_pending());
assert_eq!(detail.tracks[0].title, fed.title);
let conn = lock(&sync.conn);
assert_eq!(
sync.unresolved_playlist_item_count_with_conn(&conn)
.unwrap(),
0
);
drop(conn);
assert!(
sync.apply_playlist_item_state(
&playlist_sync_id,
&content_id,
false,
0,
None,
11,
"dev_remote:3",
)
.unwrap()
);
assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0);
}
#[test]
fn stale_synced_playlist_item_metadata_repairs_pending_fed_track() {
let sync = test_sync();
let playlist = sync.library.create_playlist("Remote Mix").unwrap();
let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap();
let content_id = format!("b3:{}", "e".repeat(64));
let fed = test_fed_track(&content_id);
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
assert!(
sync.apply_playlist_item_state(
&playlist_sync_id,
&content_id,
true,
7,
None,
10,
"dev_remote:2",
)
.unwrap()
);
assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0);
assert!(
sync.apply_playlist_item_state(
&playlist_sync_id,
&content_id,
true,
7,
Some(&synced),
10,
"dev_remote:2",
)
.unwrap()
);
let detail = sync.library.playlist(playlist.id).unwrap();
assert_eq!(detail.tracks.len(), 1);
assert!(detail.tracks[0].is_fed_pending());
assert_eq!(detail.tracks[0].title, fed.title);
}
+784
View File
@@ -0,0 +1,784 @@
//! The peer-to-peer audio protocol, wire compatible with furumi-fd.
//!
//! One byte stream per request: the requester sends one JSON line
//! ([`AudioRequest`]) and receives one JSON line ([`AudioResponseHeader`])
//! followed by the raw file bytes from the requested offset, unless the
//! requester asked for metadata only.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Context, Result};
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use crate::library::Library;
/// ALPN of the audio streaming protocol (shared with furumi-fd).
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
/// Maximum size of a JSON protocol line (request or response header).
const MAX_PROTOCOL_LINE: usize = 4096;
const STREAM_START_BUFFER_BYTES: u64 = 2 * 1024 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct DownloadProgress {
pub stream_key: u64,
pub transport_phase: &'static str,
pub received: u64,
pub total: u64,
pub transport: Option<music_dht::ByteStreamConnectionStats>,
}
#[derive(Debug)]
pub struct StreamingStart {
pub reader: crate::streaming::GrowingFileReader,
pub mime_type: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct AudioRequest {
/// Hex-encoded [`ItemId`] of the track.
item_id: String,
/// Byte offset to start streaming from (audio only; the cover, when
/// requested, is always sent whole).
offset: u64,
/// Ask the owner to send the cover art between the header and the
/// audio bytes. Default false keeps the wire layout compatible with
/// older peers in both directions.
#[serde(default)]
want_cover: bool,
/// Ask only for the response header with metadata and file facts.
/// Older peers ignore the field and may start streaming audio; the
/// requester simply drops the stream after reading the header.
#[serde(default)]
metadata_only: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct AudioResponseHeader {
ok: bool,
#[serde(default)]
error: Option<String>,
#[serde(default)]
mime_type: String,
#[serde(default)]
total_size: u64,
#[serde(default)]
offset: u64,
/// Full track metadata from the owner's database — richer and more
/// authoritative than whatever tags the file itself carries. Absent
/// when the peer predates the field (the header is extensible).
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata: Option<TrackMetadata>,
/// Size of the cover-art segment sent between this header and the
/// audio bytes; 0 = no cover (not requested, not available).
#[serde(default)]
cover_size: u64,
#[serde(default)]
cover_mime: String,
/// Size of the main artist's image segment, sent after the cover and
/// before the audio; 0 = none. Governed by the same `want_cover` flag.
#[serde(default)]
artist_image_size: u64,
#[serde(default)]
artist_image_mime: String,
}
/// Covers above this size are skipped rather than transferred.
const MAX_COVER_BYTES: u64 = 16 * 1024 * 1024;
fn image_mime(path: &Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"png" => "image/png",
"webp" => "image/webp",
"gif" => "image/gif",
"bmp" => "image/bmp",
_ => "image/jpeg",
}
}
/// File extension for a received cover, from its mime type.
pub fn image_extension(mime: &str) -> &'static str {
match mime {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/bmp" => "bmp",
_ => "jpg",
}
}
/// Track metadata exchanged alongside the audio bytes.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrackMetadata {
#[serde(default)]
pub title: String,
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)]
pub featured_artists: Vec<String>,
#[serde(default)]
pub album_artists: Vec<String>,
#[serde(default)]
pub release_title: String,
#[serde(default)]
pub release_type: Option<String>,
#[serde(default)]
pub year: Option<i32>,
#[serde(default)]
pub track_number: Option<i32>,
#[serde(default)]
pub disc_number: Option<i32>,
#[serde(default)]
pub duration_seconds: Option<f64>,
#[serde(default)]
pub audio_format: Option<String>,
#[serde(default)]
pub audio_bitrate: Option<i32>,
#[serde(default)]
pub audio_sample_rate: Option<i32>,
#[serde(default)]
pub audio_bit_depth: Option<i32>,
}
pub fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn hex_decode_item_id(value: &str) -> Option<ItemId> {
if value.len() != 64 {
return None;
}
let mut bytes = [0u8; 32];
for (i, byte) in bytes.iter_mut().enumerate() {
*byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).ok()?;
}
Some(ItemId::from_bytes(bytes))
}
fn guess_mime(path: &Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"mp3" => "audio/mpeg",
"flac" => "audio/flac",
"ogg" | "oga" => "audio/ogg",
"opus" => "audio/opus",
"wav" => "audio/wav",
"m4a" | "mp4" | "alac" => "audio/mp4",
"aac" => "audio/aac",
"aiff" | "aif" => "audio/aiff",
_ => "application/octet-stream",
}
}
/// Extension for a downloaded file, from the mime type the peer reported.
fn extension_for_mime(mime: &str) -> &'static str {
match mime {
"audio/mpeg" => "mp3",
"audio/flac" | "audio/x-flac" => "flac",
"audio/ogg" => "ogg",
"audio/opus" => "opus",
"audio/wav" | "audio/x-wav" => "wav",
"audio/mp4" | "audio/x-m4a" => "m4a",
"audio/aac" => "aac",
"audio/aiff" => "aiff",
_ => "bin",
}
}
/// Best-effort audio format label for metadata previews, from the owner's
/// response mime type.
pub fn format_for_mime(mime: &str) -> Option<String> {
let extension = extension_for_mime(mime);
(extension != "bin").then(|| extension.to_string())
}
/// Reads one `\n`-terminated line, bounded by [`MAX_PROTOCOL_LINE`].
pub(super) async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
let mut line = Vec::new();
let mut byte = [0u8; 1];
loop {
let n = reader.read(&mut byte).await?;
if n == 0 {
anyhow::bail!("stream ended before the protocol line was complete");
}
if byte[0] == b'\n' {
return Ok(line);
}
line.push(byte[0]);
if line.len() > MAX_PROTOCOL_LINE {
anyhow::bail!("protocol line exceeds {MAX_PROTOCOL_LINE} bytes");
}
}
}
async fn write_line<W: AsyncWriteExt + Unpin>(
writer: &mut W,
value: &impl Serialize,
) -> Result<()> {
let mut line = serde_json::to_vec(value)?;
line.push(b'\n');
writer.write_all(&line).await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Requesting side: download a track from its owner
// ---------------------------------------------------------------------------
/// Outcome of [`download_track`].
pub struct Downloaded {
pub path: PathBuf,
pub mime_type: String,
pub metadata: Option<TrackMetadata>,
/// Cover art (bytes, file extension) sent by the owner, if any.
pub cover: Option<(Vec<u8>, &'static str)>,
/// The main artist's image (bytes, file extension), if any.
pub artist_image: Option<(Vec<u8>, &'static str)>,
}
/// Header-only metadata fetched without downloading the audio bytes.
pub struct FetchedMetadata {
pub mime_type: String,
pub total_size: u64,
pub metadata: Option<TrackMetadata>,
}
/// Fetches the owner's response header for a track and closes the stream
/// before audio bytes are read.
pub async fn fetch_metadata(
service: &MusicDhtService,
owner: EndpointId,
item_id_hex: &str,
) -> Result<FetchedMetadata> {
let mut stream = service
.open_stream(owner, AUDIO_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach the owner peer: {err}"))?;
write_line(
&mut stream.send,
&AudioRequest {
item_id: item_id_hex.to_string(),
offset: 0,
want_cover: false,
metadata_only: true,
},
)
.await?;
stream.send.finish()?;
let header: AudioResponseHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)
.context("malformed response header")?;
if !header.ok {
anyhow::bail!(
"peer refused the metadata: {}",
header.error.unwrap_or_else(|| "unknown error".to_string())
);
}
Ok(FetchedMetadata {
mime_type: header.mime_type,
total_size: header.total_size,
metadata: header.metadata,
})
}
pub async fn download_track_with_streaming<F>(
service: &MusicDhtService,
owner: EndpointId,
item_id_hex: &str,
dir: &Path,
stem: &str,
want_images: bool,
mut progress: F,
mut stream_start: Option<&mut (dyn FnMut(StreamingStart) + Send)>,
) -> Result<Downloaded>
where
F: FnMut(DownloadProgress) + Send,
{
let started = Instant::now();
let mut stream = service
.open_stream(owner, AUDIO_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach the owner peer: {err}"))?;
let stream_key = super::stream_transport_key(&stream);
write_line(
&mut stream.send,
&AudioRequest {
item_id: item_id_hex.to_string(),
offset: 0,
want_cover: want_images,
metadata_only: false,
},
)
.await?;
stream.send.finish()?;
let header: AudioResponseHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)
.context("malformed response header")?;
if !header.ok {
anyhow::bail!(
"peer refused the stream: {}",
header.error.unwrap_or_else(|| "unknown error".to_string())
);
}
progress(DownloadProgress {
stream_key,
transport_phase: "download-open",
received: 0,
total: header.total_size,
transport: Some(stream.connection_stats()),
});
tracing::info!(
owner = %owner,
item_id = %item_id_hex,
audio_bytes = header.total_size,
cover_bytes = header.cover_size,
artist_image_bytes = header.artist_image_size,
want_images,
"federated audio stream opened"
);
// The image segments precede the audio bytes and are read regardless of
// the cache state — they sit first in the stream.
let mut read_image =
async |size: u64, mime: &str, what: &str| -> Result<Option<(Vec<u8>, &'static str)>> {
if size == 0 {
return Ok(None);
}
anyhow::ensure!(
size <= MAX_COVER_BYTES,
"{what} of {size} bytes exceeds the {MAX_COVER_BYTES} byte limit"
);
let mut bytes = vec![0u8; size as usize];
stream
.recv
.read_exact(&mut bytes)
.await
.with_context(|| format!("stream ended inside the {what} segment"))?;
Ok(Some((bytes, image_extension(mime))))
};
let cover = read_image(header.cover_size, &header.cover_mime, "cover").await?;
let artist_image = read_image(
header.artist_image_size,
&header.artist_image_mime,
"artist image",
)
.await?;
let extension = extension_for_mime(&header.mime_type);
let path = dir.join(format!("{stem}.{extension}"));
if let Ok(metadata) = tokio::fs::metadata(&path).await
&& metadata.len() == header.total_size
&& header.total_size > 0
{
// Audio already fully downloaded earlier; no need to fetch again.
progress(DownloadProgress {
stream_key,
transport_phase: "download-done",
received: header.total_size,
total: header.total_size,
transport: Some(stream.connection_stats()),
});
tracing::info!(
owner = %owner,
item_id = %item_id_hex,
bytes = header.total_size,
path = %path.display(),
"federated audio reused from cache"
);
return Ok(Downloaded {
path,
mime_type: header.mime_type,
metadata: header.metadata,
cover,
artist_image,
});
}
let temp_path = dir.join(format!(".{stem}.{extension}.part"));
let mut file = tokio::fs::File::create(&temp_path).await?;
let mut streaming = match stream_start.as_ref() {
Some(_) => match crate::streaming::growing_file(&temp_path) {
Ok((reader, writer)) => Some((Some(reader), writer, false)),
Err(err) => {
tracing::debug!(%err, path = %temp_path.display(), "streaming reader disabled");
None
}
},
None => None,
};
let stream_start_at = stream_start_buffer(header.total_size);
let mut received: u64 = 0;
let mut chunk = vec![0u8; 64 * 1024];
// quinn's inherent read returns None when the peer finished the stream.
while let Some(n) = stream.recv.read(&mut chunk).await? {
file.write_all(&chunk[..n]).await?;
received += n as u64;
if let Some((reader, writer, started)) = &mut streaming {
writer.add_available(n as u64);
if !*started
&& received >= stream_start_at
&& let (Some(reader), Some(callback)) = (reader.take(), stream_start.as_mut())
{
callback(StreamingStart {
reader,
mime_type: header.mime_type.clone(),
});
*started = true;
}
}
progress(DownloadProgress {
stream_key,
transport_phase: "download",
received,
total: header.total_size,
transport: None,
});
}
if let Some((reader, writer, started)) = &mut streaming {
if !*started
&& received > 0
&& let (Some(reader), Some(callback)) = (reader.take(), stream_start.as_mut())
{
callback(StreamingStart {
reader,
mime_type: header.mime_type.clone(),
});
*started = true;
}
writer.finish();
}
file.flush().await?;
drop(file);
if header.total_size > 0 && received != header.total_size {
let _ = tokio::fs::remove_file(&temp_path).await;
anyhow::bail!(
"download incomplete: got {received} of {} bytes",
header.total_size
);
}
tokio::fs::rename(&temp_path, &path).await?;
progress(DownloadProgress {
stream_key,
transport_phase: "download-done",
received,
total: header.total_size,
transport: Some(stream.connection_stats()),
});
let elapsed = started.elapsed();
let kib_per_sec = if elapsed.as_secs_f64() > 0.0 {
received as f64 / 1024.0 / elapsed.as_secs_f64()
} else {
0.0
};
tracing::info!(
owner = %owner,
item_id = %item_id_hex,
bytes = received,
elapsed_ms = elapsed.as_millis(),
kib_per_sec,
"federated audio downloaded"
);
Ok(Downloaded {
path,
mime_type: header.mime_type,
metadata: header.metadata,
cover,
artist_image,
})
}
fn stream_start_buffer(total_size: u64) -> u64 {
if total_size == 0 {
STREAM_START_BUFFER_BYTES
} else {
total_size.min(STREAM_START_BUFFER_BYTES)
}
}
// ---------------------------------------------------------------------------
// Serving side: answer audio requests from other peers
// ---------------------------------------------------------------------------
/// Finds the local track whose derived DHT item id matches `item_id`.
pub fn resolve_local_track_id(
library: &Library,
own: EndpointId,
item_id: ItemId,
) -> Result<Option<i64>> {
let export = library.federation_export()?;
for track in export.tracks {
let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{}", track.id));
if derived == item_id {
return Ok(Some(track.id));
}
}
Ok(None)
}
/// What the serving side needs to answer one audio request.
struct Served {
file_path: String,
metadata: TrackMetadata,
cover_path: Option<String>,
artist_image_path: Option<String>,
}
/// Resolves the item to the audio file, metadata and cover.
fn resolve_for_serving(
library: &Library,
own: EndpointId,
item_id: ItemId,
) -> Result<Option<Served>> {
let Some(track_id) = resolve_local_track_id(library, own, item_id)? else {
return Ok(None);
};
let Some(track) = library.tracks_by_ids(&[track_id])?.into_iter().next() else {
return Ok(None);
};
// Release type and album artists live on the release row.
let (release_type, album_artists) = match library.release(track.release_id) {
Ok(detail) => (
Some(detail.release_type),
detail.artists.iter().map(|a| a.name.clone()).collect(),
),
Err(_) => (None, Vec::new()),
};
let metadata = TrackMetadata {
title: track.title.clone(),
artists: track.artists.iter().map(|a| a.name.clone()).collect(),
featured_artists: track
.featured_artists
.iter()
.map(|a| a.name.clone())
.collect(),
album_artists,
release_title: track.release_title.clone(),
release_type,
year: track.release_year,
track_number: track.track_number,
disc_number: track.disc_number,
duration_seconds: Some(track.duration_seconds),
audio_format: track.audio_format.clone(),
audio_bitrate: track.audio_bitrate,
audio_sample_rate: track.audio_sample_rate,
audio_bit_depth: track.audio_bit_depth,
};
let artist_image_path = track
.artists
.first()
.and_then(|artist| library.artist_image(artist.id).ok().flatten());
Ok(Some(Served {
cover_path: track.cover_path.clone(),
artist_image_path,
file_path: track.file_path,
metadata,
}))
}
/// Runs the accept loop of the audio protocol until the acceptor closes.
/// Every track of the local library is downloadable by every peer of the
/// network — the libraries of all participants are equal.
pub async fn serve_peers(
mut acceptor: StreamAcceptor,
library: Arc<Library>,
own: EndpointId,
transport_stats: Arc<crate::federation::TransportStats>,
) {
while let Some(stream) = acceptor.accept().await {
let library = Arc::clone(&library);
let transport_stats = Arc::clone(&transport_stats);
tokio::spawn(async move {
let peer = stream.peer_id;
if let Err(err) = serve_one(stream, library, own, transport_stats).await {
tracing::warn!(peer = %peer, "audio stream failed: {err:#}");
}
});
}
}
async fn serve_one(
mut stream: ByteStream,
library: Arc<Library>,
own: EndpointId,
transport_stats: Arc<crate::federation::TransportStats>,
) -> Result<()> {
crate::federation::record_stream_transport(
&transport_stats,
"audio",
"inbound",
"open",
&stream,
);
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
tracing::info!(
peer = %stream.peer_id,
item = %request.item_id,
offset = request.offset,
"peer requested audio"
);
let resolved = match hex_decode_item_id(&request.item_id) {
Some(item_id) => {
let library = Arc::clone(&library);
match tokio::task::spawn_blocking(move || resolve_for_serving(&library, own, item_id))
.await
{
Ok(Ok(Some(found))) => Ok(found),
Ok(Ok(None)) => Err("track not found in the library".to_string()),
Ok(Err(err)) => Err(format!("library lookup failed: {err:#}")),
Err(err) => Err(format!("lookup task failed: {err}")),
}
}
None => Err("malformed item_id".to_string()),
};
let served = match resolved {
Ok(found) => found,
Err(message) => return refuse(stream, message).await,
};
let path = PathBuf::from(&served.file_path);
let mut file = match tokio::fs::File::open(&path).await {
Ok(file) => file,
Err(err) => return refuse(stream, format!("audio file is not readable: {err}")).await,
};
let total_size = file.metadata().await?.len();
let offset = request.offset.min(total_size);
if offset > 0 {
file.seek(std::io::SeekFrom::Start(offset)).await?;
}
// Images ride between the header and the audio, when asked for.
let (cover, artist_image) = if request.want_cover && !request.metadata_only {
(
load_cover(served.cover_path.as_deref()).await,
load_cover(served.artist_image_path.as_deref()).await,
)
} else {
(None, None)
};
write_line(
&mut stream.send,
&AudioResponseHeader {
ok: true,
error: None,
mime_type: guess_mime(&path).to_string(),
total_size,
offset,
metadata: Some(served.metadata),
cover_size: cover.as_ref().map_or(0, |(bytes, _)| bytes.len() as u64),
cover_mime: cover
.as_ref()
.map(|(_, mime)| mime.to_string())
.unwrap_or_default(),
artist_image_size: artist_image
.as_ref()
.map_or(0, |(bytes, _)| bytes.len() as u64),
artist_image_mime: artist_image
.as_ref()
.map(|(_, mime)| mime.to_string())
.unwrap_or_default(),
},
)
.await?;
if request.metadata_only {
stream.send.finish()?;
let _ = stream.send.stopped().await;
return Ok(());
}
let cover_bytes = cover.as_ref().map_or(0, |(bytes, _)| bytes.len() as u64);
let artist_image_bytes = artist_image
.as_ref()
.map_or(0, |(bytes, _)| bytes.len() as u64);
tracing::info!(
peer = %stream.peer_id,
item = %request.item_id,
audio_bytes = total_size.saturating_sub(offset),
cover_bytes,
artist_image_bytes,
want_images = request.want_cover,
"serving federated audio stream"
);
let started = Instant::now();
if let Some((bytes, _)) = &cover {
stream.send.write_all(bytes).await?;
}
if let Some((bytes, _)) = &artist_image {
stream.send.write_all(bytes).await?;
}
let audio_sent = tokio::io::copy(&mut file, &mut stream.send).await?;
stream.send.finish()?;
// Wait until the peer read everything (or gave up) before dropping the
// stream, otherwise the tail of the file is lost.
let _ = stream.send.stopped().await;
crate::federation::record_stream_transport(
&transport_stats,
"audio",
"inbound",
"done",
&stream,
);
let elapsed = started.elapsed();
let total_sent = cover_bytes + artist_image_bytes + audio_sent;
let kib_per_sec = if elapsed.as_secs_f64() > 0.0 {
total_sent as f64 / 1024.0 / elapsed.as_secs_f64()
} else {
0.0
};
tracing::info!(
peer = %stream.peer_id,
item = %request.item_id,
audio_bytes = audio_sent,
total_bytes = total_sent,
elapsed_ms = elapsed.as_millis(),
kib_per_sec,
"served federated audio stream"
);
Ok(())
}
/// Reads a cover image from disk, skipping unreadable or oversized files.
async fn load_cover(cover_path: Option<&str>) -> Option<(Vec<u8>, &'static str)> {
let path = PathBuf::from(cover_path?);
let size = tokio::fs::metadata(&path).await.ok()?.len();
if size == 0 || size > MAX_COVER_BYTES {
return None;
}
let bytes = tokio::fs::read(&path).await.ok()?;
Some((bytes, image_mime(&path)))
}
/// Sends a refusal header and waits until the peer read it.
async fn refuse(mut stream: ByteStream, message: String) -> Result<()> {
write_line(
&mut stream.send,
&AudioResponseHeader {
ok: false,
error: Some(message.clone()),
mime_type: String::new(),
total_size: 0,
offset: 0,
metadata: None,
cover_size: 0,
cover_mime: String::new(),
artist_image_size: 0,
artist_image_mime: String::new(),
},
)
.await?;
stream.send.finish()?;
let _ = stream.send.stopped().await;
anyhow::bail!("refused audio request: {message}");
}
+856
View File
@@ -0,0 +1,856 @@
//! The peer catalog protocol: one peer asks another for its library slice
//! of a single artist (releases with full tracklists plus featured
//! appearances), used to assemble a federated artist card.
//!
//! Wire shape on the `furumi-fd/catalog/1` ALPN: the requester sends one
//! JSON line ([`CatalogRequest`]) and finishes; the owner answers with one
//! JSON document ([`CatalogResponse`]) and finishes. All fields default, so
//! the shape is extensible like the audio protocol.
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::{Context, Result};
pub use music_dht::catalog::{
CATALOG_ALPN, CatalogAppearance, CatalogArtist, CatalogArtistPreview, CatalogRelease,
CatalogTrack,
};
use music_dht::catalog::{CatalogImageHeader as ImageHeader, CatalogRequest, CatalogResponse};
use music_dht::{ByteStream, EndpointId, ItemKind, MusicDhtService, StreamAcceptor};
use tokio::io::AsyncReadExt;
use crate::library::Library;
/// Upper bound for one catalog response (thousands of tracks fit easily).
const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024;
/// Images above this size are skipped rather than transferred.
const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
// ---------------------------------------------------------------------------
// Serving side
// ---------------------------------------------------------------------------
/// Runs the catalog accept loop until the acceptor closes.
pub async fn serve_peers(
mut acceptor: StreamAcceptor,
library: Arc<Library>,
own: EndpointId,
transport_stats: Arc<crate::federation::TransportStats>,
) {
while let Some(stream) = acceptor.accept().await {
let library = Arc::clone(&library);
let transport_stats = Arc::clone(&transport_stats);
tokio::spawn(async move {
let peer = stream.peer_id;
if let Err(err) = serve_one(stream, library, own, transport_stats).await {
tracing::warn!(peer = %peer, "catalog request failed: {err:#}");
}
});
}
}
async fn serve_one(
mut stream: ByteStream,
library: Arc<Library>,
own: EndpointId,
transport_stats: Arc<crate::federation::TransportStats>,
) -> Result<()> {
crate::federation::record_stream_transport(
&transport_stats,
"catalog",
"inbound",
"open",
&stream,
);
let request: CatalogRequest =
serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?)?;
tracing::info!(
peer = %stream.peer_id,
artist = %request.artist,
want = request.want.as_deref().unwrap_or("catalog"),
"peer requested a catalog"
);
match request.want.as_deref() {
Some("artists") => {
let cursor = request.cursor.clone();
let limit = request.limit.unwrap_or(64).clamp(1, 200);
let response =
tokio::task::spawn_blocking(move || build_artist_slice(&library, cursor, limit))
.await?
.unwrap_or_else(|err| CatalogResponse {
ok: false,
error: Some(format!("artist slice failed: {err:#}")),
..CatalogResponse::default()
});
let payload = serde_json::to_vec(&response)?;
stream.send.write_all(&payload).await?;
}
None | Some("catalog") | Some("artist") => {
let response =
tokio::task::spawn_blocking(move || build_catalog(&library, own, &request.artist))
.await?
.unwrap_or_else(|err| CatalogResponse {
ok: false,
error: Some(format!("catalog lookup failed: {err:#}")),
..CatalogResponse::default()
});
let payload = serde_json::to_vec(&response)?;
stream.send.write_all(&payload).await?;
}
Some(want @ ("artist_image" | "release_cover")) => {
let want_cover = want == "release_cover";
let release = request.release.clone().unwrap_or_default();
let artist = request.artist.clone();
let path = tokio::task::spawn_blocking(move || -> Result<Option<String>> {
if want_cover {
library.release_cover_by_names(&artist, &release)
} else {
let Some(artist_id) = library.artist_id_by_name(&artist)? else {
return Ok(None);
};
library.artist_image(artist_id)
}
})
.await??;
serve_image(&mut stream, path.as_deref()).await?;
}
Some(other) => {
let response = CatalogResponse {
ok: false,
error: Some(format!("unknown request kind '{other}'")),
..CatalogResponse::default()
};
stream
.send
.write_all(&serde_json::to_vec(&response)?)
.await?;
}
}
stream.send.finish()?;
let _ = stream.send.stopped().await;
crate::federation::record_stream_transport(
&transport_stats,
"catalog",
"inbound",
"done",
&stream,
);
Ok(())
}
/// Streams one image file: header line, then the raw bytes.
async fn serve_image(stream: &mut ByteStream, path: Option<&str>) -> Result<()> {
let loaded = match path {
Some(path) => match tokio::fs::read(path).await {
Ok(bytes) if !bytes.is_empty() && bytes.len() as u64 <= MAX_IMAGE_BYTES => {
Some((bytes, image_mime_by_path(path)))
}
_ => None,
},
None => None,
};
let header = match &loaded {
Some((bytes, mime)) => ImageHeader {
ok: true,
error: None,
mime_type: (*mime).to_string(),
size: bytes.len() as u64,
},
None => ImageHeader {
ok: false,
error: Some("no image".to_string()),
..ImageHeader::default()
},
};
let mut line = serde_json::to_vec(&header)?;
line.push(b'\n');
stream.send.write_all(&line).await?;
if let Some((bytes, _)) = &loaded {
stream.send.write_all(bytes).await?;
}
Ok(())
}
fn image_mime_by_path(path: &str) -> &'static str {
match std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"png" => "image/png",
"webp" => "image/webp",
"gif" => "image/gif",
"bmp" => "image/bmp",
_ => "image/jpeg",
}
}
/// Builds this instance's library slice for `artist`.
fn build_catalog(library: &Library, own: EndpointId, artist: &str) -> Result<CatalogResponse> {
let Some(artist) = build_catalog_artist(library, own, artist)? else {
return Ok(CatalogResponse {
ok: false,
error: Some("artist not found in the library".to_string()),
..CatalogResponse::default()
});
};
Ok(CatalogResponse {
ok: true,
error: None,
artist: Some(artist),
..CatalogResponse::default()
})
}
fn build_artist_slice(
library: &Library,
cursor: Option<String>,
limit: usize,
) -> Result<CatalogResponse> {
let offset = cursor
.as_deref()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
let (artists, next_cursor) = library.artist_preview_slice(offset, limit)?;
Ok(CatalogResponse {
ok: true,
artists: artists
.into_iter()
.map(|artist| CatalogArtistPreview {
artist_key: artist.artist_key,
name: artist.name,
image_path: artist.image_path,
release_count: artist.release_count,
track_count: artist.track_count,
})
.collect(),
next_cursor,
..CatalogResponse::default()
})
}
/// Builds the successful payload for this instance's library slice.
pub(crate) fn build_catalog_artist(
library: &Library,
own: EndpointId,
artist: &str,
) -> Result<Option<CatalogArtist>> {
let Some(artist_id) = library.artist_id_by_name(artist)? else {
return Ok(None);
};
let detail = library.artist(artist_id)?;
let item_id_of = |track_id: i64| -> String {
super::audio::hex_encode(
music_dht::ItemId::derive(&own, ItemKind::Track, &format!("track:{track_id}"))
.as_bytes(),
)
};
let mut releases = Vec::new();
for card in &detail.releases {
let release = library.release(card.id)?;
releases.push(CatalogRelease {
title: release.title,
release_type: release.release_type,
year: release.year,
tracks: release
.tracks
.iter()
.map(|track| catalog_track(track, item_id_of(track.id)))
.collect(),
});
}
let mut appears_on = Vec::new();
for track in &detail.featured_tracks {
let release = library.release(track.release_id)?;
appears_on.push(CatalogAppearance {
release_title: track.release_title.clone(),
release_type: release.release_type,
year: track.release_year,
track: catalog_track(track, item_id_of(track.id)),
});
}
Ok(Some(CatalogArtist {
name: detail.name,
releases,
appears_on,
}))
}
fn catalog_track(track: &crate::library::models::TrackItem, item_id: String) -> CatalogTrack {
CatalogTrack {
title: track.title.clone(),
artists: track
.artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
featured_artists: track
.featured_artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
track_number: track.track_number,
disc_number: track.disc_number,
duration_seconds: (track.duration_seconds > 0.0).then_some(track.duration_seconds),
content_id: track.content_id.clone(),
item_id,
}
}
// ---------------------------------------------------------------------------
// Requesting side
// ---------------------------------------------------------------------------
/// Fetches one peer's catalog slice for `artist`.
pub async fn fetch_catalog(
service: &MusicDhtService,
owner: EndpointId,
artist: &str,
transport_stats: &Arc<crate::federation::TransportStats>,
) -> Result<CatalogArtist> {
let mut stream = service
.open_stream(owner, CATALOG_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?;
crate::federation::record_stream_transport(
transport_stats,
"catalog",
"outbound",
"open",
&stream,
);
let mut line = serde_json::to_vec(&CatalogRequest {
artist: artist.to_string(),
want: None,
release: None,
cursor: None,
limit: None,
})?;
line.push(b'\n');
stream.send.write_all(&line).await?;
stream.send.finish()?;
let mut payload = Vec::new();
// The whole response is one JSON document, bounded by the byte cap.
tokio::io::AsyncReadExt::take(StreamReader(&mut stream), MAX_CATALOG_BYTES + 1)
.read_to_end(&mut payload)
.await?;
anyhow::ensure!(
payload.len() as u64 <= MAX_CATALOG_BYTES,
"catalog response exceeds {MAX_CATALOG_BYTES} bytes"
);
let response: CatalogResponse =
serde_json::from_slice(&payload).context("malformed catalog response")?;
crate::federation::record_stream_transport(
transport_stats,
"catalog",
"outbound",
"done",
&stream,
);
if !response.ok {
anyhow::bail!(
"peer refused the catalog: {}",
response
.error
.unwrap_or_else(|| "unknown error".to_string())
);
}
response.artist.context("empty catalog response")
}
/// Fetches a thin top-artist slice from one peer.
pub async fn fetch_artist_slice(
service: &MusicDhtService,
owner: EndpointId,
cursor: Option<&str>,
limit: usize,
transport_stats: &Arc<crate::federation::TransportStats>,
) -> Result<(Vec<CatalogArtistPreview>, Option<String>)> {
let mut stream = service
.open_stream(owner, CATALOG_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?;
crate::federation::record_stream_transport(
transport_stats,
"catalog",
"outbound",
"open",
&stream,
);
let mut line = serde_json::to_vec(&CatalogRequest {
artist: String::new(),
want: Some("artists".to_string()),
release: None,
cursor: cursor.map(str::to_string),
limit: Some(limit),
})?;
line.push(b'\n');
stream.send.write_all(&line).await?;
stream.send.finish()?;
let mut payload = Vec::new();
tokio::io::AsyncReadExt::take(StreamReader(&mut stream), MAX_CATALOG_BYTES + 1)
.read_to_end(&mut payload)
.await?;
anyhow::ensure!(
payload.len() as u64 <= MAX_CATALOG_BYTES,
"catalog response exceeds {MAX_CATALOG_BYTES} bytes"
);
let response: CatalogResponse =
serde_json::from_slice(&payload).context("malformed catalog response")?;
crate::federation::record_stream_transport(
transport_stats,
"catalog",
"outbound",
"done",
&stream,
);
if !response.ok {
anyhow::bail!(
"peer refused the artist slice: {}",
response
.error
.unwrap_or_else(|| "unknown error".to_string())
);
}
Ok((response.artists, response.next_cursor))
}
/// Fetches an image (artist image or a release cover) from a peer over the
/// catalog protocol. `release: None` asks for the artist image. Returns the
/// raw bytes and a file extension, or None when the peer has no image.
pub async fn fetch_image(
service: &MusicDhtService,
owner: EndpointId,
artist: &str,
release: Option<&str>,
transport_stats: &Arc<crate::federation::TransportStats>,
) -> Result<Option<(Vec<u8>, &'static str)>> {
let mut stream = service
.open_stream(owner, CATALOG_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?;
crate::federation::record_stream_transport(
transport_stats,
"catalog",
"outbound",
"open",
&stream,
);
let mut line = serde_json::to_vec(&CatalogRequest {
artist: artist.to_string(),
want: Some(if release.is_some() {
"release_cover".to_string()
} else {
"artist_image".to_string()
}),
release: release.map(str::to_string),
cursor: None,
limit: None,
})?;
line.push(b'\n');
stream.send.write_all(&line).await?;
stream.send.finish()?;
let header: ImageHeader =
serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?)
.context("malformed image header")?;
if !header.ok || header.size == 0 {
return Ok(None);
}
anyhow::ensure!(
header.size <= MAX_IMAGE_BYTES,
"image of {} bytes exceeds the {MAX_IMAGE_BYTES} byte limit",
header.size
);
let mut bytes = vec![0u8; header.size as usize];
stream
.recv
.read_exact(&mut bytes)
.await
.context("stream ended inside the image")?;
crate::federation::record_stream_transport(
transport_stats,
"catalog",
"outbound",
"done",
&stream,
);
let extension = match header.mime_type.as_str() {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/bmp" => "bmp",
_ => "jpg",
};
Ok(Some((bytes, extension)))
}
/// AsyncRead adapter over the receive half of a byte stream.
struct StreamReader<'a>(&'a mut ByteStream);
impl tokio::io::AsyncRead for StreamReader<'_> {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.0.recv).poll_read(cx, buf)
}
}
// ---------------------------------------------------------------------------
// Aggregation
// ---------------------------------------------------------------------------
/// The assembled, deduplicated federated artist card.
#[derive(Debug, Clone, Default)]
pub struct FedArtistCard {
#[allow(dead_code, reason = "the open card is keyed by name in AppState")]
pub name: String,
/// This node's endpoint id, used to keep own tracks local when an open
/// card includes the local catalog alongside remote peers.
pub own_owner: Option<String>,
/// Peers whose catalogs contributed to the card.
pub peers: usize,
/// Every contributing peer (hex ids) — where images are fetched from.
pub owners: Vec<String>,
/// Local cache path of the artist image, streamed from a peer.
pub image_path: Option<String>,
pub releases: Vec<FedRelease>,
pub appears_on: Vec<FedAppearsOn>,
}
#[derive(Debug, Clone, Default)]
pub struct FedRelease {
pub title: String,
pub release_type: String,
pub year: Option<i32>,
/// Peers holding this release (hex ids).
pub owners: Vec<String>,
/// Local cache path of the cover, streamed from a peer.
pub cover_path: Option<String>,
pub tracks: Vec<FedCardTrack>,
}
#[derive(Debug, Clone, Default)]
pub struct FedAppearsOn {
pub release_title: String,
pub release_type: String,
pub year: Option<i32>,
pub track: FedCardTrack,
}
#[derive(Debug, Clone, Default)]
pub struct FedCardTrack {
pub title: String,
pub artists: Vec<String>,
pub featured_artists: Vec<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: Option<f64>,
pub content_id: Option<String>,
/// Every peer that can serve this track: (owner hex, item id hex).
/// Duplicates collapse into one row; all sources stay playable.
pub sources: Vec<(String, String)>,
}
/// Merges per-peer catalogs into one card: releases are keyed by normalized
/// title, tracks within a release by normalized title + track number; a
/// track present on several peers keeps every source.
pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> FedArtistCard {
let peers = catalogs.len();
let mut releases: Vec<FedRelease> = Vec::new();
let mut release_index: HashMap<String, usize> = HashMap::new();
let mut appears_on: Vec<FedAppearsOn> = Vec::new();
let mut appearance_index: HashMap<String, usize> = HashMap::new();
let mut card_owners: Vec<String> = Vec::new();
for (owner_hex, catalog) in catalogs {
if !card_owners.contains(&owner_hex) {
card_owners.push(owner_hex.clone());
}
for release in catalog.releases {
let release_key = music_dht::normalize_name(&release.title);
let slot = *release_index.entry(release_key).or_insert_with(|| {
releases.push(FedRelease {
title: release.title.clone(),
release_type: release.release_type.clone(),
year: None,
owners: Vec::new(),
cover_path: None,
tracks: Vec::new(),
});
releases.len() - 1
});
let merged = &mut releases[slot];
if !merged.owners.contains(&owner_hex) {
merged.owners.push(owner_hex.clone());
}
if merged.year.is_none() {
merged.year = release.year;
}
if merged.release_type.is_empty() {
merged.release_type = release.release_type.clone();
}
for track in release.tracks {
if track.item_id.is_empty() {
continue;
}
let existing = merged.tracks.iter_mut().find(|t| {
music_dht::normalize_name(&t.title) == music_dht::normalize_name(&track.title)
&& (t.track_number == track.track_number
|| t.track_number.is_none()
|| track.track_number.is_none())
});
match existing {
Some(t) => {
merge_card_track(t, &owner_hex, track);
}
None => merged.tracks.push(card_track(owner_hex.clone(), track)),
}
}
}
for appearance in catalog.appears_on {
if appearance.track.item_id.is_empty() {
continue;
}
let key = format!(
"{}:{}:{:?}",
music_dht::normalize_name(&appearance.release_title),
music_dht::normalize_name(&appearance.track.title),
appearance.track.track_number
);
let slot = *appearance_index.entry(key).or_insert_with(|| {
appears_on.push(FedAppearsOn {
release_title: appearance.release_title.clone(),
release_type: appearance.release_type.clone(),
year: appearance.year,
track: FedCardTrack::default(),
});
appears_on.len() - 1
});
let merged = &mut appears_on[slot];
if merged.release_type.is_empty() {
merged.release_type = appearance.release_type.clone();
}
if merged.year.is_none() {
merged.year = appearance.year;
}
if merged.track.title.is_empty() {
merged.track = card_track(owner_hex.clone(), appearance.track);
} else {
merge_card_track(&mut merged.track, &owner_hex, appearance.track);
}
}
}
for release in &mut releases {
release.tracks.sort_by_key(|t| {
(
t.disc_number.unwrap_or(1),
t.track_number.unwrap_or(i32::MAX),
)
});
}
appears_on.sort_by(|a, b| {
b.year
.unwrap_or(i32::MIN)
.cmp(&a.year.unwrap_or(i32::MIN))
.then_with(|| a.release_title.cmp(&b.release_title))
.then_with(|| {
a.track
.track_number
.unwrap_or(i32::MAX)
.cmp(&b.track.track_number.unwrap_or(i32::MAX))
})
.then_with(|| a.track.title.cmp(&b.track.title))
});
releases.sort_by(|a, b| {
b.year
.unwrap_or(i32::MIN)
.cmp(&a.year.unwrap_or(i32::MIN))
.then_with(|| a.title.cmp(&b.title))
});
FedArtistCard {
name: name.to_string(),
own_owner: None,
peers,
owners: card_owners,
image_path: None,
releases,
appears_on,
}
}
fn card_track(owner_hex: String, track: CatalogTrack) -> FedCardTrack {
FedCardTrack {
title: track.title,
artists: track.artists,
featured_artists: track.featured_artists,
track_number: track.track_number,
disc_number: track.disc_number,
duration_seconds: track.duration_seconds,
content_id: track.content_id,
sources: vec![(owner_hex, track.item_id)],
}
}
fn merge_card_track(target: &mut FedCardTrack, owner_hex: &str, track: CatalogTrack) {
if target.artists.is_empty() {
target.artists = track.artists;
}
if target.featured_artists.is_empty() {
target.featured_artists = track.featured_artists;
}
if target.track_number.is_none() {
target.track_number = track.track_number;
}
if target.disc_number.is_none() {
target.disc_number = track.disc_number;
}
if target.duration_seconds.is_none() {
target.duration_seconds = track.duration_seconds;
}
if target.content_id.is_none() {
target.content_id = track.content_id;
}
let source = (owner_hex.to_string(), track.item_id);
if !target.sources.contains(&source) {
target.sources.push(source);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn track(title: &str, number: i32, item: &str) -> CatalogTrack {
CatalogTrack {
title: title.into(),
artists: vec!["Metallica".into()],
featured_artists: Vec::new(),
track_number: Some(number),
disc_number: None,
duration_seconds: Some(100.0),
content_id: None,
item_id: item.into(),
}
}
#[test]
fn merges_and_dedupes_across_peers() {
let catalog = |item_prefix: &str| CatalogArtist {
name: "Metallica".into(),
releases: vec![CatalogRelease {
title: "Black Album".into(),
release_type: "album".into(),
year: Some(1991),
tracks: vec![
track("Enter Sandman", 1, &format!("{item_prefix}1")),
track("Sad But True", 2, &format!("{item_prefix}2")),
],
}],
appears_on: Vec::new(),
};
let card = merge_catalogs(
"Metallica",
vec![
("peer-a".to_string(), catalog("a")),
("peer-b".to_string(), catalog("b")),
],
);
assert_eq!(card.peers, 2);
assert_eq!(card.releases.len(), 1);
let release = &card.releases[0];
assert_eq!(release.year, Some(1991));
assert_eq!(release.tracks.len(), 2);
// Both peers stay as sources of the deduplicated track.
assert_eq!(release.tracks[0].sources.len(), 2);
}
#[test]
fn merges_featured_appearances_across_peers() {
let mut featured = track("Guest Verse", 3, "a1");
featured.artists = vec!["Host".into()];
featured.featured_artists = vec!["Guest".into()];
let mut same_featured = featured.clone();
same_featured.item_id = "b1".into();
let card = merge_catalogs(
"Guest",
vec![
(
"peer-a".to_string(),
CatalogArtist {
name: "Guest".into(),
releases: Vec::new(),
appears_on: vec![CatalogAppearance {
release_title: "Host Album".into(),
release_type: "album".into(),
year: Some(2024),
track: featured,
}],
},
),
(
"peer-b".to_string(),
CatalogArtist {
name: "Guest".into(),
releases: Vec::new(),
appears_on: vec![CatalogAppearance {
release_title: "Host Album".into(),
release_type: "album".into(),
year: Some(2024),
track: same_featured,
}],
},
),
],
);
assert!(card.releases.is_empty());
assert_eq!(card.appears_on.len(), 1);
assert_eq!(card.appears_on[0].track.sources.len(), 2);
assert_eq!(card.appears_on[0].track.artists, vec!["Host"]);
assert_eq!(card.appears_on[0].track.featured_artists, vec!["Guest"]);
}
#[test]
fn merge_catalogs_sorts_releases_newest_first() {
let release = |title: &str, year: Option<i32>, item: &str| CatalogRelease {
title: title.into(),
release_type: "album".into(),
year,
tracks: vec![track("Song", 1, item)],
};
let card = merge_catalogs(
"Metallica",
vec![(
"peer-a".to_string(),
CatalogArtist {
name: "Metallica".into(),
releases: vec![
release("Old Album", Some(1991), "a1"),
release("New Album", Some(2024), "a2"),
release("Undated Album", None, "a3"),
],
appears_on: Vec::new(),
},
)],
);
let titles: Vec<&str> = card
.releases
.iter()
.map(|release| release.title.as_str())
.collect();
assert_eq!(titles, vec!["New Album", "Old Album", "Undated Album"]);
}
}
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
use super::*;
fn test_owner() -> EndpointId {
music_dht::SecretKey::from_bytes(&[7; 32]).public()
}
fn dht_track(main: &[&str], featured: &[&str]) -> LibraryItem {
let owner = test_owner();
LibraryItem {
id: music_dht::ItemId::derive(&owner, ItemKind::Track, "track:1"),
owner,
kind: ItemKind::Track,
name: "Guest Verse".into(),
normalized_name: music_dht::normalize_name("Guest Verse"),
artist_names: main.iter().map(|name| name.to_string()).collect(),
featured_artist_names: featured.iter().map(|name| name.to_string()).collect(),
year: Some(2024),
release_type: Some("album".into()),
release_title: Some("Host Album".into()),
track_number: Some(2),
disc_number: Some(1),
duration_seconds: Some(180.0),
content_id: Some(
"b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
),
revision: 1,
deleted: false,
updated_at_ms: 0,
}
}
#[test]
fn dht_appearance_requires_explicit_featured_artist() {
let normalized = music_dht::normalize_name("Guest");
assert!(dht_appearance_hit(&dht_track(&["Guest"], &[]), &normalized, "Guest").is_none());
let hit = dht_appearance_hit(&dht_track(&["Host"], &["Guest"]), &normalized, "Guest").unwrap();
assert_eq!(hit.release_title, "Host Album");
assert_eq!(hit.release_type, "album");
assert_eq!(hit.year, Some(2024));
assert_eq!(hit.track.artists, vec!["Host"]);
assert_eq!(hit.track.featured_artists, vec!["Guest"]);
assert_eq!(hit.track.track_number, Some(2));
assert_eq!(hit.track.disc_number, Some(1));
}
#[test]
fn federation_search_ranks_exact_names_first() {
let normalized = music_dht::normalize_name("ежемесячные");
let mut artists = vec![
FedArtistHit {
name: "Booker".into(),
peers: 3,
},
FedArtistHit {
name: "Ежемесячные".into(),
peers: 1,
},
];
let mut tracks = vec![
FedTrack {
item_id: "a".into(),
owner: "peer-a".into(),
own: false,
title: "Гость".into(),
artist_names: vec!["Other".into()],
featured_artist_names: vec!["Ежемесячные".into()],
year: None,
duration_seconds: None,
content_id: None,
release_title: None,
track_number: None,
disc_number: None,
},
FedTrack {
item_id: "b".into(),
owner: "peer-b".into(),
own: false,
title: "Ежемесячные".into(),
artist_names: vec!["Other".into()],
featured_artist_names: Vec::new(),
year: None,
duration_seconds: None,
content_id: None,
release_title: None,
track_number: None,
disc_number: None,
},
];
rank_fed_search_results(&mut artists, &mut tracks, &normalized);
assert_eq!(artists[0].name, "Ежемесячные");
assert_eq!(tracks[0].title, "Ежемесячные");
}
+561
View File
@@ -0,0 +1,561 @@
//! Importing audio files into the library: directory scanning, tag reading
//! (via lofty) and cover extraction. Importing the same file again updates
//! its metadata instead of duplicating it.
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result};
use lofty::file::{AudioFile as _, TaggedFileExt as _};
use lofty::picture::MimeType;
use lofty::tag::{Accessor as _, ItemKey};
use rusqlite::{OptionalExtension as _, params};
use super::{Library, audio_content_id, find_or_create_artist};
/// Extensions the playback engine can decode (rodio/symphonia feature set).
const AUDIO_EXTENSIONS: [&str; 8] = ["mp3", "flac", "ogg", "oga", "wav", "m4a", "mp4", "aac"];
/// Everything known about one audio file, ready to be written to the DB.
#[derive(Debug)]
pub struct TrackImport {
pub file_path: String,
pub title: String,
pub artists: Vec<String>,
pub featured_artists: Vec<String>,
pub album_artists: Vec<String>,
pub release_title: String,
/// Release type ("album", "single", ...) when known from a richer
/// source than file tags (e.g. federation metadata); None = "album".
pub release_type: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: f64,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
pub file_size_bytes: Option<i64>,
/// Embedded cover art (bytes, file extension), if any.
pub cover: Option<(Vec<u8>, &'static str)>,
}
#[derive(Debug, Default)]
pub struct ImportOutcome {
pub added: usize,
pub updated: usize,
pub failed: Vec<(PathBuf, String)>,
}
impl ImportOutcome {
pub fn summary(&self) -> String {
let mut message = format!("imported {} track(s)", self.added);
if self.updated > 0 {
message.push_str(&format!(", updated {}", self.updated));
}
if !self.failed.is_empty() {
message.push_str(&format!(", {} failed", self.failed.len()));
}
message
}
}
/// Import a file or a directory (recursively). `progress(done, total, name)`
/// is called after every file.
pub fn import_path(
library: &Library,
path: &Path,
mut progress: impl FnMut(usize, usize, &str),
) -> Result<ImportOutcome> {
let path = path
.canonicalize()
.with_context(|| format!("{} does not exist", path.display()))?;
let mut files = Vec::new();
collect_audio_files(&path, &mut files);
anyhow::ensure!(
!files.is_empty(),
"no audio files found at {} (supported: {})",
path.display(),
AUDIO_EXTENSIONS.join(", ")
);
files.sort();
let total = files.len();
let mut outcome = ImportOutcome::default();
for (index, file) in files.iter().enumerate() {
match read_file(file).and_then(|import| upsert_track(library, &import)) {
Ok((_, created)) => {
if created {
outcome.added += 1;
} else {
outcome.updated += 1;
}
}
Err(err) => {
tracing::warn!(file = %file.display(), %err, "import failed");
outcome.failed.push((file.clone(), format!("{err:#}")));
}
}
let name = file
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
progress(index + 1, total, &name);
}
Ok(outcome)
}
fn collect_audio_files(path: &Path, files: &mut Vec<PathBuf>) {
if path.is_dir() {
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
for entry in entries.flatten() {
collect_audio_files(&entry.path(), files);
}
return;
}
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase());
if extension.is_some_and(|ext| AUDIO_EXTENSIONS.contains(&ext.as_str())) {
files.push(path.to_path_buf());
}
}
/// Read tags and audio properties from one file.
pub fn read_file(path: &Path) -> Result<TrackImport> {
let tagged = lofty::read_from_path(path).context("cannot read tags")?;
let properties = tagged.properties();
let tag = tagged.primary_tag().or_else(|| tagged.first_tag());
let fallback_title = path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| "Unknown".to_string());
let (mut title, artist_raw, album, year, track_number, disc_number, album_artist_raw, cover) =
match tag {
Some(tag) => (
tag.title()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or(fallback_title),
tag.artist().map(|value| value.into_owned()),
tag.album()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
tag.year().and_then(|value| i32::try_from(value).ok()),
tag.track().and_then(|value| i32::try_from(value).ok()),
tag.disk().and_then(|value| i32::try_from(value).ok()),
tag.get_string(&ItemKey::AlbumArtist)
.map(|value| value.to_string()),
tag.pictures().first().map(|picture| {
let extension = match picture.mime_type() {
Some(MimeType::Png) => "png",
Some(MimeType::Gif) => "gif",
Some(MimeType::Bmp) => "bmp",
_ => "jpg",
};
(picture.data().to_vec(), extension)
}),
),
None => (fallback_title, None, None, None, None, None, None, None),
};
let (mut artists, mut featured) = split_artist_tag(artist_raw.as_deref().unwrap_or(""));
// "Song (feat. X)" in the title moves X into the featured list.
if let Some((clean_title, feat)) = extract_title_feat(&title) {
title = clean_title;
for name in feat {
if !featured.iter().any(|f| f.eq_ignore_ascii_case(&name)) {
featured.push(name);
}
}
}
if artists.is_empty() {
artists.push("Unknown Artist".to_string());
}
let album_artists = match album_artist_raw.as_deref().map(split_artist_tag) {
Some((main, _)) if !main.is_empty() => main,
_ => artists.clone(),
};
let metadata = std::fs::metadata(path).ok();
Ok(TrackImport {
file_path: path.to_string_lossy().into_owned(),
title,
artists,
featured_artists: featured,
album_artists,
release_title: album.unwrap_or_else(|| "Unknown Album".to_string()),
release_type: None,
year,
track_number,
disc_number,
duration_seconds: properties.duration().as_secs_f64(),
audio_format: path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase()),
audio_bitrate: properties
.audio_bitrate()
.and_then(|value| i32::try_from(value).ok()),
audio_sample_rate: properties
.sample_rate()
.and_then(|value| i32::try_from(value).ok()),
audio_bit_depth: properties.bit_depth().map(i32::from),
file_size_bytes: metadata.map(|meta| meta.len() as i64),
cover,
})
}
/// Insert or update one track (matching by file path). Returns the track id
/// and whether a new row was created.
pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, bool)> {
let content_id = audio_content_id(&import.file_path);
let mut conn = library.lock();
let tx = conn.transaction()?;
// Release, keyed by (title, first album artist).
let album_artist_id = find_or_create_artist(
&tx,
import
.album_artists
.first()
.map(String::as_str)
.unwrap_or("Unknown Artist"),
)?;
let release_id: Option<i64> = tx
.query_row(
"SELECT r.id FROM releases r
JOIN release_artists ra ON ra.release_id = r.id
WHERE r.title = ?1 COLLATE NOCASE AND ra.artist_id = ?2",
params![import.release_title, album_artist_id],
|row| row.get(0),
)
.optional()?;
let release_id = match release_id {
Some(id) => {
// Fill in the year if this file is the first one to know it.
if import.year.is_some() {
tx.execute(
"UPDATE releases SET year = COALESCE(year, ?2) WHERE id = ?1",
params![id, import.year],
)?;
}
id
}
None => {
tx.execute(
"INSERT INTO releases (title, release_type, year) VALUES (?1, ?2, ?3)",
params![
import.release_title,
import.release_type.as_deref().unwrap_or("album"),
import.year,
],
)?;
let id = tx.last_insert_rowid();
for (position, name) in import.album_artists.iter().enumerate() {
let artist_id = find_or_create_artist(&tx, name)?;
tx.execute(
"INSERT OR IGNORE INTO release_artists (release_id, artist_id, position)
VALUES (?1, ?2, ?3)",
params![id, artist_id, position as i64],
)?;
}
id
}
};
let existing: Option<i64> = tx
.query_row(
"SELECT id FROM tracks WHERE file_path = ?1",
[&import.file_path],
|row| row.get(0),
)
.optional()?;
let (track_id, created) = match existing {
Some(id) => {
tx.execute(
"UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4,
duration_seconds = ?5, release_id = ?6, audio_format = ?7,
audio_bitrate = ?8, audio_sample_rate = ?9, audio_bit_depth = ?10,
file_size_bytes = ?11, content_id = ?12
WHERE id = ?1",
params![
id,
import.title,
import.track_number,
import.disc_number,
import.duration_seconds,
release_id,
import.audio_format,
import.audio_bitrate,
import.audio_sample_rate,
import.audio_bit_depth,
import.file_size_bytes,
content_id.as_deref(),
],
)?;
tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?;
(id, false)
}
None => {
tx.execute(
"INSERT INTO tracks (title, track_number, disc_number, duration_seconds,
release_id, file_path, audio_format, audio_bitrate, audio_sample_rate,
audio_bit_depth, file_size_bytes, content_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
import.title,
import.track_number,
import.disc_number,
import.duration_seconds,
release_id,
import.file_path,
import.audio_format,
import.audio_bitrate,
import.audio_sample_rate,
import.audio_bit_depth,
import.file_size_bytes,
content_id.as_deref(),
],
)?;
(tx.last_insert_rowid(), true)
}
};
for (position, name) in import.artists.iter().enumerate() {
let artist_id = find_or_create_artist(&tx, name)?;
tx.execute(
"INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position)
VALUES (?1, ?2, 'main', ?3)",
params![track_id, artist_id, position as i64],
)?;
}
for (position, name) in import.featured_artists.iter().enumerate() {
let artist_id = find_or_create_artist(&tx, name)?;
tx.execute(
"INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position)
VALUES (?1, ?2, 'featured', ?3)",
params![track_id, artist_id, position as i64],
)?;
}
// Cover: a release keeps the first cover found — an image file next to
// the audio, or the embedded picture saved into the covers directory.
let has_cover: bool = tx
.query_row(
"SELECT cover_path IS NOT NULL FROM releases WHERE id = ?1",
[release_id],
|row| row.get(0),
)
.unwrap_or(false);
if !has_cover && let Some(cover_path) = resolve_cover(library, release_id, import) {
tx.execute(
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
params![release_id, cover_path],
)?;
}
tx.commit()?;
Ok((track_id, created))
}
/// Find a cover image for the release: a cover/folder/front image in the
/// audio file's directory, or the embedded picture written to disk.
fn resolve_cover(library: &Library, release_id: i64, import: &TrackImport) -> Option<String> {
let directory = Path::new(&import.file_path).parent()?;
if let Ok(entries) = std::fs::read_dir(directory) {
for entry in entries.flatten() {
let path = entry.path();
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.to_ascii_lowercase());
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase());
let is_image = matches!(
extension.as_deref(),
Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "gif")
);
if is_image
&& matches!(
stem.as_deref(),
Some("cover" | "folder" | "front" | "album")
)
{
return Some(path.to_string_lossy().into_owned());
}
}
}
let (data, extension) = import.cover.as_ref()?;
let covers_dir = library.covers_dir();
if let Err(err) = std::fs::create_dir_all(covers_dir) {
tracing::warn!(%err, "cannot create covers directory");
return None;
}
let path = covers_dir.join(format!("release_{release_id}.{extension}"));
match std::fs::write(&path, data) {
Ok(()) => Some(path.to_string_lossy().into_owned()),
Err(err) => {
tracing::warn!(%err, path = %path.display(), "cannot save embedded cover");
None
}
}
}
/// Split an artist tag into (main artists, featured artists).
/// Separators: ";" and "/" between main artists; "feat."/"ft."/"featuring"
/// starts the featured list.
pub fn split_artist_tag(raw: &str) -> (Vec<String>, Vec<String>) {
let raw = raw.trim();
if raw.is_empty() {
return (Vec::new(), Vec::new());
}
let (main_part, feat_part) = match find_feat_marker(raw) {
Some((at, marker_len)) => {
let main = raw[..at].trim_end_matches(['(', '[', ' ', ',', '-']);
let feat = raw[at + marker_len..].trim_end_matches([')', ']']);
(main, feat)
}
None => (raw, ""),
};
(split_names(main_part), split_names(feat_part))
}
/// The earliest "feat."/"ft."/"featuring" marker that stands as its own
/// word — preceded by a separator and followed by a space — so artist names
/// like "Daft Punk" are not split on the "ft" inside them.
fn find_feat_marker(raw: &str) -> Option<(usize, usize)> {
let lowered = raw.to_lowercase();
let mut best: Option<(usize, usize)> = None;
for marker in ["featuring", "feat.", "feat", "ft.", "ft"] {
for (at, _) in lowered.match_indices(marker) {
let before_ok = raw[..at]
.chars()
.next_back()
.is_some_and(|c| matches!(c, ' ' | '(' | '[' | ',' | '-'));
let after_ok = raw[at + marker.len()..].starts_with(' ');
if before_ok && after_ok && best.is_none_or(|(current, _)| at < current) {
best = Some((at, marker.len()));
}
}
}
best
}
fn split_names(raw: &str) -> Vec<String> {
raw.split([';', '/'])
.flat_map(|part| part.split(" & "))
.map(|name| name.trim().trim_matches(',').trim().to_string())
.filter(|name| !name.is_empty())
.collect()
}
/// Extract "(feat. X)" / "[ft. Y]" from a track title.
fn extract_title_feat(title: &str) -> Option<(String, Vec<String>)> {
let lowered = title.to_lowercase();
for marker in ["(feat.", "(feat ", "(ft.", "[feat.", "[ft."] {
if let Some(start) = lowered.find(marker) {
let closer = if marker.starts_with('(') { ')' } else { ']' };
let rest = &title[start + marker.len()..];
let end = rest.find(closer)?;
let names = split_names(&rest[..end]);
if names.is_empty() {
return None;
}
let mut clean = title[..start].trim_end().to_string();
clean.push_str(rest[end + 1..].trim_end());
return Some((clean.trim().to_string(), names));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal valid WAV file: 0.1s of silence at 8kHz mono 16-bit.
fn write_test_wav(path: &Path) {
let samples: u32 = 800;
let data_len = samples * 2;
let mut bytes = Vec::new();
bytes.extend_from_slice(b"RIFF");
bytes.extend_from_slice(&(36 + data_len).to_le_bytes());
bytes.extend_from_slice(b"WAVEfmt ");
bytes.extend_from_slice(&16u32.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes()); // PCM
bytes.extend_from_slice(&1u16.to_le_bytes()); // mono
bytes.extend_from_slice(&8000u32.to_le_bytes()); // sample rate
bytes.extend_from_slice(&16000u32.to_le_bytes()); // byte rate
bytes.extend_from_slice(&2u16.to_le_bytes()); // block align
bytes.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
bytes.extend_from_slice(b"data");
bytes.extend_from_slice(&data_len.to_le_bytes());
bytes.resize(bytes.len() + data_len as usize, 0);
std::fs::write(path, bytes).unwrap();
}
#[test]
fn imports_a_real_audio_file_end_to_end() {
let dir = std::env::temp_dir().join(format!("furumi-import-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let wav = dir.join("My Song.wav");
write_test_wav(&wav);
let db = dir.join("library.db");
let library = Library::open(&db).unwrap();
let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap();
assert_eq!(outcome.added, 1);
assert!(outcome.failed.is_empty());
// Untagged files fall back to the file name and placeholder names.
let results = library.search("My Song", 10).unwrap();
assert_eq!(results.tracks.len(), 1);
let track = &results.tracks[0];
assert_eq!(track.title, "My Song");
assert_eq!(track.artists[0].name, "Unknown Artist");
assert_eq!(track.release_title, "Unknown Album");
assert!(track.duration_seconds > 0.05);
assert_eq!(track.audio_sample_rate, Some(8000));
assert!(std::fs::File::open(&track.file_path).is_ok());
// Re-importing the same directory only updates.
let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap();
assert_eq!((outcome.added, outcome.updated), (0, 1));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn splits_plain_artist() {
let (main, feat) = split_artist_tag("Daft Punk");
assert_eq!(main, vec!["Daft Punk"]);
assert!(feat.is_empty());
}
#[test]
fn splits_multiple_and_featured() {
let (main, feat) = split_artist_tag("A; B feat. C & D");
assert_eq!(main, vec!["A", "B"]);
assert_eq!(feat, vec!["C", "D"]);
}
#[test]
fn keeps_commas_inside_names() {
let (main, _) = split_artist_tag("Tyler, The Creator");
assert_eq!(main, vec!["Tyler, The Creator"]);
}
#[test]
fn extracts_feat_from_title() {
let (title, names) = extract_title_feat("Song (feat. X & Y)").unwrap();
assert_eq!(title, "Song");
assert_eq!(names, vec!["X", "Y"]);
assert!(extract_title_feat("Plain Song").is_none());
}
}
+2871
View File
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
//! Data shapes the views render. They mirror what the furumusic API used to
//! return, but every field is now filled from the local SQLite library.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Availability {
#[default]
Local,
Mixed,
Remote,
}
impl Availability {
pub fn is_remoteish(self) -> bool {
matches!(self, Availability::Mixed | Availability::Remote)
}
}
#[derive(Debug, Clone)]
pub struct ArtistCard {
pub id: i64,
pub name: String,
/// Path to a local image file, if one is set for the artist.
pub image_path: Option<String>,
pub release_count: i64,
pub track_count: i64,
pub availability: Availability,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtistRef {
pub id: i64,
pub name: String,
}
#[derive(Debug, Clone)]
pub struct TrackItem {
pub id: i64,
pub title: String,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: f64,
pub artists: Vec<ArtistRef>,
pub featured_artists: Vec<ArtistRef>,
pub release_id: i64,
pub release_title: String,
pub release_year: Option<i32>,
/// Absolute path to the local audio file.
pub file_path: String,
/// Stable audio content id (`b3:<64 hex>`) when known.
pub content_id: Option<String>,
/// Path to a local cover image (the release cover).
pub cover_path: Option<String>,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
pub file_size_bytes: Option<i64>,
/// Completed local plays, from the history table.
pub play_count: i64,
/// Set for federated tracks that are not in the local library (yet):
/// carries everything needed to download them from the owning peer.
/// With an empty `file_path` the player resolves the track on demand.
pub fed: Option<crate::federation::FedTrack>,
}
impl TrackItem {
/// A federated track that still needs downloading before playback.
pub fn is_fed_pending(&self) -> bool {
self.fed.is_some() && self.file_path.is_empty()
}
pub fn artist_line(&self) -> String {
let artists = self
.artists
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let featured = self
.featured_artists
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ");
match (artists.is_empty(), featured.is_empty()) {
(false, false) => format!("{artists} feat. {featured}"),
(false, true) => artists,
(true, false) => format!("feat. {featured}"),
(true, true) => String::new(),
}
}
pub fn duration_label(&self) -> String {
let total = self.duration_seconds.round() as i64;
format!("{}:{:02}", total / 60, total % 60)
}
/// Full tech line for the status bar, including the sample rate.
pub fn tech_label_full(&self) -> String {
let mut parts = Vec::new();
if let Some(format) = &self.audio_format {
parts.push(format.to_uppercase());
}
if let Some(bitrate) = self.audio_bitrate {
parts.push(format!("{bitrate}kbps"));
}
if let Some(rate) = self.audio_sample_rate {
parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0));
}
if let Some(bytes) = self.file_size_bytes {
parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0));
}
parts.join(" · ")
}
}
#[derive(Debug, Clone)]
pub struct ReleaseCard {
pub id: i64,
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub cover_path: Option<String>,
pub track_count: i64,
pub availability: Availability,
}
#[derive(Debug)]
pub struct ArtistDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub name: String,
pub image_path: Option<String>,
pub total_track_count: i64,
pub total_play_count: i64,
pub top_tracks: Vec<TrackItem>,
pub releases: Vec<ReleaseCard>,
/// Tracks where this artist is featured (the only content for artists
/// without own releases).
pub featured_tracks: Vec<TrackItem>,
}
#[derive(Debug)]
pub struct ReleaseDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub cover_path: Option<String>,
pub artists: Vec<ArtistRef>,
pub tracks: Vec<TrackItem>,
}
#[derive(Debug, Clone)]
pub struct PlaylistCard {
pub id: i64,
pub title: String,
pub track_count: i64,
/// "normal" for user playlists, "likes" for the virtual Likes playlist.
pub kind: String,
}
#[derive(Debug)]
pub struct PlaylistDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub title: String,
#[allow(dead_code, reason = "shown in a detail header later")]
pub description: Option<String>,
pub tracks: Vec<TrackItem>,
}
#[derive(Debug, Default)]
pub struct SearchResults {
pub artists: Vec<ArtistCard>,
pub releases: Vec<ReleaseCard>,
pub tracks: Vec<TrackItem>,
}
impl SearchResults {
pub fn len(&self) -> usize {
self.artists.len() + self.releases.len() + self.tracks.len()
}
}
#[derive(Debug)]
pub struct ArtistsPage {
pub items: Vec<ArtistCard>,
pub total: i64,
pub page: i64,
pub has_more: bool,
}
/// Edited values submitted from the track edit form. `None` numbers clear
/// the column.
#[derive(Debug, Clone)]
pub struct TrackEdit {
pub title: String,
pub artists: Vec<String>,
pub featured_artists: Vec<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
/// Cover image path; the cover lives on the track's release (the same
/// image every view shows for the track). None clears it.
pub cover_path: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ReleaseEdit {
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub artists: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn artist(name: &str) -> ArtistRef {
ArtistRef {
id: 1,
name: name.to_string(),
}
}
#[test]
fn artist_line_formats_featured_artists() {
let track = TrackItem {
id: 1,
title: "Track".into(),
track_number: None,
disc_number: None,
duration_seconds: 1.0,
artists: vec![artist("Main")],
featured_artists: vec![artist("Guest"), artist("Other")],
release_id: 1,
release_title: "Release".into(),
release_year: None,
file_path: "/tmp/track.mp3".into(),
content_id: None,
cover_path: None,
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
play_count: 0,
fed: None,
};
assert_eq!(track.artist_line(), "Main feat. Guest, Other");
}
}
+556
View File
@@ -0,0 +1,556 @@
use super::*;
fn test_library() -> Library {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
register_norm_function(&conn).unwrap();
conn.execute_batch(SCHEMA).unwrap();
Library {
conn: Mutex::new(conn),
db_path: std::env::temp_dir().join("furumi-test-library.db"),
covers_dir: std::env::temp_dir().join("furumi-test-covers-unused"),
}
}
fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 {
add_track_with_featured(lib, title, artist, &[], album)
}
fn add_track_with_featured(
lib: &Library,
title: &str,
artist: &str,
featured: &[&str],
album: &str,
) -> i64 {
let import = import::TrackImport {
release_type: None,
file_path: format!("/music/{artist}/{album}/{title}.mp3"),
title: title.to_string(),
artists: vec![artist.to_string()],
featured_artists: featured.iter().map(|name| (*name).to_string()).collect(),
album_artists: vec![artist.to_string()],
release_title: album.to_string(),
year: Some(2020),
track_number: None,
disc_number: None,
duration_seconds: 60.0,
audio_format: Some("mp3".into()),
audio_bitrate: Some(320),
audio_sample_rate: Some(44100),
audio_bit_depth: None,
file_size_bytes: Some(1),
cover: None,
};
let id = import::upsert_track(lib, &import).unwrap().0;
let content_id = format!("b3:{}", blake3::hash(import.file_path.as_bytes()).to_hex());
lib.lock()
.execute(
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
params![id, content_id],
)
.unwrap();
id
}
fn artist_filters(hide_featured_only: bool) -> crate::config::settings::LibraryFilters {
crate::config::settings::LibraryFilters {
hide_featured_only,
..Default::default()
}
}
#[test]
fn local_stats_counts_library_rows_and_audio_bytes() {
let lib = test_library();
add_track(&lib, "One", "Artist", "First");
add_track(&lib, "Two", "Artist", "Second");
let stats = lib.local_stats().unwrap();
assert_eq!(stats.artist_count, 1);
assert_eq!(stats.release_count, 2);
assert_eq!(stats.track_count, 2);
assert_eq!(stats.audio_bytes, 2);
assert_eq!(stats.tracks_without_size, 0);
}
#[test]
fn artists_page_prioritizes_releases_then_tracks() {
let lib = test_library();
add_track(&lib, "Solo", "Zed", "Zed Album");
add_track_with_featured(&lib, "Guest One", "A Host", &["Guest"], "A Host Album");
add_track_with_featured(&lib, "Guest Two", "B Host", &["Guest"], "B Host Album");
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
let zed_pos = page
.items
.iter()
.position(|artist| artist.name == "Zed")
.unwrap();
let guest_pos = page
.items
.iter()
.position(|artist| artist.name == "Guest")
.unwrap();
let guest = &page.items[guest_pos];
assert_eq!(guest.release_count, 0);
assert_eq!(guest.track_count, 2);
assert!(zed_pos < guest_pos);
let filtered = lib.artists(1, 10, artist_filters(true)).unwrap();
assert!(filtered.items.iter().all(|artist| artist.release_count > 0));
assert!(!filtered.items.iter().any(|artist| artist.name == "Guest"));
}
#[test]
fn network_artist_image_hint_becomes_local_image_after_fetch() {
let lib = test_library();
let artist_key = music_dht::normalize_name("Remote Artist");
lib.replace_network_artist_cache(
"peer-a",
"personal",
&[NetworkArtistPreview {
artist_key: artist_key.clone(),
name: "Remote Artist".into(),
image_path: Some("peer-local/image.jpg".into()),
release_count: 1,
track_count: 3,
}],
true,
)
.unwrap();
let filters = crate::config::settings::LibraryFilters {
source_mode: crate::config::settings::LibrarySourceMode::My,
..Default::default()
};
let page = lib.artists(1, 10, filters).unwrap();
assert_eq!(page.items[0].image_path, None);
let requests = lib
.network_artist_image_requests(filters, &["Remote Artist".into()], 8)
.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].source_id, "peer-a");
assert_eq!(requests[0].artist_key, artist_key);
lib.set_network_artist_image("peer-a", &artist_key, "/tmp/remote-artist.jpg")
.unwrap();
let page = lib.artists(1, 10, filters).unwrap();
assert_eq!(
page.items[0].image_path.as_deref(),
Some("/tmp/remote-artist.jpg")
);
}
#[test]
fn import_creates_artist_release_track() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].name, "Artist");
assert_eq!(page.items[0].track_count, 1);
let detail = lib.artist(page.items[0].id).unwrap();
assert_eq!(detail.releases.len(), 1);
assert_eq!(detail.top_tracks.len(), 1);
let release = lib.release(detail.releases[0].id).unwrap();
assert_eq!(release.tracks.len(), 1);
assert_eq!(release.tracks[0].id, track_id);
assert_eq!(release.tracks[0].artists[0].name, "Artist");
}
#[test]
fn reimport_updates_instead_of_duplicating() {
let lib = test_library();
let first = add_track(&lib, "Song", "Artist", "Album");
let second = add_track(&lib, "Song", "Artist", "Album");
assert_eq!(first, second);
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
assert_eq!(page.items[0].track_count, 1);
}
#[test]
fn content_id_backfill_hashes_missing_track_ids() {
let lib = test_library();
let path = std::env::temp_dir().join(format!(
"furumi-content-id-test-{}-{}.bin",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, b"portable content id").unwrap();
let file_path = path.to_string_lossy().into_owned();
let import = import::TrackImport {
release_type: None,
file_path: file_path.clone(),
title: "Portable".to_string(),
artists: vec!["Artist".to_string()],
featured_artists: Vec::new(),
album_artists: vec!["Artist".to_string()],
release_title: "Album".to_string(),
year: Some(2026),
track_number: None,
disc_number: None,
duration_seconds: 60.0,
audio_format: Some("bin".into()),
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: Some(19),
cover: None,
};
let track_id = import::upsert_track(&lib, &import).unwrap().0;
let expected = audio_content_id(&file_path).unwrap();
{
let conn = lib.lock();
conn.execute(
"UPDATE tracks SET content_id = NULL WHERE id = ?1",
[track_id],
)
.unwrap();
}
let stats = lib.backfill_missing_content_ids().unwrap();
assert_eq!(stats.hashed, 1);
assert_eq!(stats.updated(), 1);
assert_eq!(
lib.track_content_id_by_id(track_id).unwrap().as_deref(),
Some(expected.as_str())
);
let _ = std::fs::remove_file(path);
}
#[test]
fn search_finds_all_kinds() {
let lib = test_library();
add_track(&lib, "Neon Lights", "Neon Artist", "Neon Album");
let results = lib.search("neon", 10).unwrap();
assert_eq!(results.artists.len(), 1);
assert_eq!(results.releases.len(), 1);
assert_eq!(results.tracks.len(), 1);
// LIKE wildcards in the query must not match everything.
assert_eq!(lib.search("%", 10).unwrap().len(), 0);
}
#[test]
fn search_ranks_exact_names_first() {
let lib = test_library();
add_track(&lib, "A Needle", "A Needle Artist", "A Needle Album");
add_track(&lib, "Needle", "Needle", "Needle");
let results = lib.search("needle", 10).unwrap();
assert_eq!(results.artists[0].name, "Needle");
assert_eq!(results.releases[0].title, "Needle");
assert_eq!(results.tracks[0].title, "Needle");
}
#[test]
fn search_folds_case_beyond_ascii() {
let lib = test_library();
add_track(&lib, "Nothing Else Matters", "Металлика", "Чёрный альбом");
// SQLite's LIKE/NOCASE only fold ASCII; norm() folds every script.
assert_eq!(lib.search("металлика", 10).unwrap().artists.len(), 1);
assert_eq!(lib.search("МЕТАЛЛИКА", 10).unwrap().artists.len(), 1);
assert_eq!(lib.search("чёрный", 10).unwrap().releases.len(), 1);
assert_eq!(lib.search("matters", 10).unwrap().tracks.len(), 1);
}
#[test]
fn playlists_and_likes_round_trip() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
let playlist = lib.create_playlist("Mix").unwrap();
lib.add_tracks_to_playlist(playlist.id, &[track_id])
.unwrap();
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1);
let content_id = lib.track_content_id_by_id(track_id).unwrap().unwrap();
assert!(lib.toggle_like_by_content_id(&content_id).unwrap());
assert_eq!(lib.liked_content_ids().unwrap(), vec![content_id.clone()]);
assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1);
assert!(!lib.toggle_like_by_content_id(&content_id).unwrap());
lib.remove_tracks_from_playlist(playlist.id, &[track_id])
.unwrap();
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0);
lib.delete_playlist(playlist.id).unwrap();
// Only the virtual Likes playlist remains.
assert_eq!(lib.playlists().unwrap().len(), 1);
}
#[test]
fn likes_playlist_orders_local_and_federated_by_liked_at() {
let lib = test_library();
let old_id = add_track(&lib, "Old Local", "Artist", "Album");
let new_id = add_track(&lib, "New Local", "Artist", "Album");
let old_content_id = lib.track_content_id_by_id(old_id).unwrap().unwrap();
let new_content_id = lib.track_content_id_by_id(new_id).unwrap().unwrap();
let content_id = format!("b3:{}", "c".repeat(64));
let fed = crate::federation::FedTrack {
item_id: "fed_item_order".to_string(),
owner: "fed_owner_order".to_string(),
own: false,
title: "Middle Fed".to_string(),
artist_names: vec!["Remote Artist".to_string()],
featured_artist_names: Vec::new(),
year: Some(2026),
duration_seconds: Some(123),
content_id: Some(content_id),
release_title: Some("Remote Release".to_string()),
track_number: Some(1),
disc_number: Some(1),
};
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
assert!(lib.toggle_like_by_content_id(&new_content_id).unwrap());
assert!(lib.toggle_fed_like(&fed).unwrap());
{
let conn = lib.lock();
conn.execute(
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
params![old_id, "2026-01-01 00:00:00"],
)
.unwrap();
conn.execute(
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
params![new_id, "2026-01-02 00:00:00"],
)
.unwrap();
conn.execute(
"UPDATE fed_likes SET liked_at = ?2 WHERE item_id = ?1",
params![fed.item_id, "2026-01-03 00:00:00"],
)
.unwrap();
}
let titles: Vec<String> = lib
.playlist(LIKES_PLAYLIST_ID)
.unwrap()
.tracks
.into_iter()
.map(|track| track.title)
.collect();
assert_eq!(titles, vec!["Middle Fed", "New Local", "Old Local"]);
assert!(!lib.toggle_like_by_content_id(&old_content_id).unwrap());
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
{
let conn = lib.lock();
conn.execute(
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
params![old_id, "2026-01-04 00:00:00"],
)
.unwrap();
}
let titles: Vec<String> = lib
.playlist(LIKES_PLAYLIST_ID)
.unwrap()
.tracks
.into_iter()
.map(|track| track.title)
.collect();
assert_eq!(titles, vec!["Old Local", "Middle Fed", "New Local"]);
}
#[test]
fn synced_playlist_can_show_federated_pending_tracks() {
let lib = test_library();
let playlist = lib.create_playlist("Remote Mix").unwrap();
let sync_id = lib.ensure_playlist_sync_id(playlist.id).unwrap();
let content_id = format!("b3:{}", "a".repeat(64));
let fed = crate::federation::FedTrack {
item_id: "fed_item_1".to_string(),
owner: "fed_owner_1".to_string(),
own: false,
title: "Remote Song".to_string(),
artist_names: vec!["Remote Artist".to_string()],
featured_artist_names: vec!["Remote Guest".to_string()],
year: Some(2026),
duration_seconds: Some(123),
content_id: Some(content_id.clone()),
release_title: Some("Remote Release".to_string()),
track_number: Some(2),
disc_number: Some(1),
};
assert!(lib.upsert_fed_playlist_track(&sync_id, &fed, 4).unwrap());
assert!(
lib.has_playlist_content_reference(&sync_id, &content_id)
.unwrap()
);
let detail = lib.playlist(playlist.id).unwrap();
assert_eq!(detail.tracks.len(), 1);
let track = &detail.tracks[0];
assert!(track.is_fed_pending());
assert_eq!(track.title, "Remote Song");
assert_eq!(track.artist_line(), "Remote Artist feat. Remote Guest");
assert_eq!(track.release_title, "Remote Release");
assert_eq!(track.content_id.as_deref(), Some(content_id.as_str()));
let card = lib
.playlists()
.unwrap()
.into_iter()
.find(|card| card.id == playlist.id)
.unwrap();
assert_eq!(card.track_count, 1);
lib.remove_content_ids_from_playlist(playlist.id, std::slice::from_ref(&content_id))
.unwrap();
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0);
assert!(
lib.fed_playlist_track_by_content_id(&sync_id, &content_id)
.unwrap()
.is_none()
);
}
#[test]
fn add_federated_pending_track_to_playlist_records_position() {
let lib = test_library();
let local_id = add_track(&lib, "Local Song", "Artist", "Album");
let playlist = lib.create_playlist("Remote Mix").unwrap();
let content_id = format!("b3:{}", "b".repeat(64));
let fed = crate::federation::FedTrack {
item_id: "fed_item_2".to_string(),
owner: "fed_owner_2".to_string(),
own: false,
title: "Remote Song".to_string(),
artist_names: vec!["Remote Artist".to_string()],
featured_artist_names: Vec::new(),
year: Some(2026),
duration_seconds: Some(123),
content_id: Some(content_id.clone()),
release_title: Some("Remote Release".to_string()),
track_number: Some(2),
disc_number: Some(1),
};
lib.add_tracks_to_playlist(playlist.id, &[local_id])
.unwrap();
lib.add_fed_tracks_to_playlist(playlist.id, std::slice::from_ref(&fed))
.unwrap();
let position = lib
.playlist_content_position(playlist.id, &content_id)
.unwrap();
assert_eq!(position, Some(1));
let detail = lib.playlist(playlist.id).unwrap();
assert_eq!(
detail
.tracks
.into_iter()
.map(|track| track.title)
.collect::<Vec<_>>(),
vec!["Local Song", "Remote Song"]
);
}
#[test]
fn track_edit_relinks_artists() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
lib.update_track(
track_id,
&TrackEdit {
title: "Renamed".into(),
artists: vec!["Other".into()],
featured_artists: vec!["Guest".into()],
track_number: Some(2),
disc_number: None,
cover_path: None,
},
)
.unwrap();
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
assert_eq!(track.title, "Renamed");
assert_eq!(track.artists[0].name, "Other");
assert_eq!(track.featured_artists[0].name, "Guest");
assert_eq!(track.track_number, Some(2));
}
#[test]
fn deleting_artist_cleans_up_own_content() {
let lib = test_library();
add_track(&lib, "Song", "Solo", "Solo Album");
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
lib.delete_artist(page.items[0].id).unwrap();
assert_eq!(lib.artists(1, 10, artist_filters(false)).unwrap().total, 0);
assert_eq!(lib.search("Song", 10).unwrap().len(), 0);
}
#[test]
fn delete_track_drops_empty_release() {
let lib = test_library();
let track_id = add_track(&lib, "Only", "Artist", "Album");
lib.delete_track(track_id).unwrap();
let detail = lib
.artist(lib.artists(1, 10, artist_filters(false)).unwrap().items[0].id)
.unwrap();
assert!(detail.releases.is_empty());
}
#[test]
fn history_counts_completed_plays() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
let content_id = lib
.tracks_by_ids(&[track_id])
.unwrap()
.remove(0)
.content_id
.unwrap();
let event = music_dht::device_sync::ListenEvent {
listen_id: "listen-1".to_string(),
content_id,
started_at_ms: 1_700_000_000_000,
listened_ms: 60_000,
track_duration_ms: Some(60_000),
ended_reason: music_dht::device_sync::ListenEndReason::Finished,
track: music_dht::device_sync::ListenTrackMetadata {
title: "Song".to_string(),
artist_names: vec!["Artist".to_string()],
featured_artist_names: Vec::new(),
release_title: Some("Album".to_string()),
},
};
assert!(lib.apply_listen_event(&event, "device-a").unwrap());
assert!(!lib.apply_listen_event(&event, "device-a").unwrap());
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
assert_eq!(track.play_count, 1);
let history = lib.listen_history(20).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].listen_id, "listen-1");
assert_eq!(history[0].title, "Song");
assert_eq!(history[0].artist, "Artist");
assert_eq!(history[0].origin_device_id, "device-a");
}
#[test]
fn listen_history_hides_unqualified_events_and_keeps_remote_metadata() {
let lib = test_library();
let event = music_dht::device_sync::ListenEvent {
listen_id: "remote-listen".to_string(),
content_id: format!("b3:{}", "a".repeat(64)),
started_at_ms: 1_700_000_000_000,
listened_ms: 10_000,
track_duration_ms: Some(120_000),
ended_reason: music_dht::device_sync::ListenEndReason::Skipped,
track: music_dht::device_sync::ListenTrackMetadata {
title: "Remote song".to_string(),
artist_names: vec!["Remote artist".to_string()],
featured_artist_names: vec!["Guest".to_string()],
release_title: None,
},
};
assert!(lib.apply_listen_event(&event, "remote-device").unwrap());
assert!(lib.listen_history(20).unwrap().is_empty());
}
+47 -2
View File
@@ -1,10 +1,15 @@
mod api;
mod app;
mod art;
mod config;
mod devices;
mod federation;
mod library;
mod media;
mod player;
mod share;
mod streaming;
mod ui;
mod visualizer;
use std::io;
@@ -15,6 +20,11 @@ use crossterm::event::{
};
fn main() -> Result<()> {
if std::env::args_os().any(|arg| arg == "--version" || arg == "-V") {
println!("furumi {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let mut startup_warning = None;
if let Err(err) = config::logging::init() {
startup_warning = Some(format!("logging disabled: {err:#}"));
@@ -122,7 +132,42 @@ fn capture_stderr() {
}
}
#[cfg(not(unix))]
#[cfg(windows)]
fn capture_stderr() {
use std::io::BufRead as _;
use std::os::windows::io::FromRawHandle as _;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Console::{STD_ERROR_HANDLE, SetStdHandle};
use windows_sys::Win32::System::Pipes::CreatePipe;
unsafe {
let mut read = core::ptr::null_mut();
let mut write = core::ptr::null_mut();
if CreatePipe(&mut read, &mut write, core::ptr::null(), 0) == 0 {
return;
}
if SetStdHandle(STD_ERROR_HANDLE, write) == 0 {
CloseHandle(read);
CloseHandle(write);
return;
}
let reader = std::fs::File::from_raw_handle(read);
std::thread::Builder::new()
.name("stderr".to_string())
.spawn(move || {
for line in std::io::BufReader::new(reader).lines() {
let Ok(line) = line else { break };
if !line.trim().is_empty() {
tracing::warn!(target: "stderr", "{line}");
}
}
})
.ok();
}
}
#[cfg(not(any(unix, windows)))]
fn capture_stderr() {}
/// Kitty keyboard protocol, where supported, disambiguates Esc from alt-keys
+455
View File
@@ -0,0 +1,455 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicI16, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use rodio::source::SeekError;
use rodio::{ChannelCount, Sample, SampleRate, Source};
const LEVEL_SCALE: f32 = 1_000_000.0;
const SCOPE_SAMPLES: usize = 256;
const SCOPE_SAMPLE_SCALE: f32 = i16::MAX as f32;
const TARGET_ANALYSIS_HZ: f32 = 30.0;
const TARGET_SCOPE_HZ: f32 = 240.0;
#[derive(Debug, Clone, Default)]
pub struct AudioAnalysisSnapshot {
pub sequence: u64,
pub energy: f64,
pub bass: f64,
pub mid: f64,
pub treble: f64,
pub beat: f64,
pub scope: Vec<f64>,
}
#[derive(Debug)]
pub struct AnalyzerShared {
sequence: AtomicU64,
energy: AtomicU32,
bass: AtomicU32,
mid: AtomicU32,
treble: AtomicU32,
beat: AtomicU32,
scope_write: AtomicUsize,
scope: Box<[AtomicI16]>,
}
impl Default for AnalyzerShared {
fn default() -> Self {
Self {
sequence: AtomicU64::new(0),
energy: AtomicU32::new(0),
bass: AtomicU32::new(0),
mid: AtomicU32::new(0),
treble: AtomicU32::new(0),
beat: AtomicU32::new(0),
scope_write: AtomicUsize::new(0),
scope: (0..SCOPE_SAMPLES)
.map(|_| AtomicI16::new(0))
.collect::<Vec<_>>()
.into_boxed_slice(),
}
}
}
impl AnalyzerShared {
pub fn clear(&self) {
self.energy.store(0, Ordering::Relaxed);
self.bass.store(0, Ordering::Relaxed);
self.mid.store(0, Ordering::Relaxed);
self.treble.store(0, Ordering::Relaxed);
self.beat.store(0, Ordering::Relaxed);
self.scope_write.store(0, Ordering::Relaxed);
for sample in self.scope.iter() {
sample.store(0, Ordering::Relaxed);
}
self.sequence.store(0, Ordering::Relaxed);
}
pub fn snapshot(&self) -> AudioAnalysisSnapshot {
let write = self.scope_write.load(Ordering::Relaxed);
let len = self.scope.len().max(1);
let scope = (0..self.scope.len())
.map(|offset| {
let index = (write + offset) % len;
f64::from(self.scope[index].load(Ordering::Relaxed)) / f64::from(i16::MAX)
})
.collect();
AudioAnalysisSnapshot {
sequence: self.sequence.load(Ordering::Relaxed),
energy: load_norm(&self.energy),
bass: load_norm(&self.bass),
mid: load_norm(&self.mid),
treble: load_norm(&self.treble),
beat: load_norm(&self.beat),
scope,
}
}
fn store_levels(&self, energy: f32, bass: f32, mid: f32, treble: f32, beat: f32) {
store_norm(&self.energy, energy);
store_norm(&self.bass, bass);
store_norm(&self.mid, mid);
store_norm(&self.treble, treble);
store_norm(&self.beat, beat);
self.sequence.fetch_add(1, Ordering::Relaxed);
}
fn push_scope(&self, sample: f32) {
let index = self.scope_write.fetch_add(1, Ordering::Relaxed) % self.scope.len();
let value = (sample.clamp(-1.0, 1.0) * SCOPE_SAMPLE_SCALE).round() as i16;
self.scope[index].store(value, Ordering::Relaxed);
}
}
pub struct AnalyzedSource<S> {
input: S,
shared: Arc<AnalyzerShared>,
state: AnalyzerState,
}
impl<S> AnalyzedSource<S>
where
S: Source,
{
pub fn new(input: S, shared: Arc<AnalyzerShared>) -> Self {
let channels = input.channels();
let sample_rate = input.sample_rate();
Self {
input,
shared,
state: AnalyzerState::new(channels, sample_rate),
}
}
}
impl<S> Iterator for AnalyzedSource<S>
where
S: Source,
{
type Item = Sample;
fn next(&mut self) -> Option<Self::Item> {
let sample = self.input.next()?;
self.state
.ensure_format(self.input.channels(), self.input.sample_rate());
self.state.accept_sample(sample as f32, &self.shared);
Some(sample)
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.input.size_hint()
}
}
impl<S> Source for AnalyzedSource<S>
where
S: Source,
{
fn current_span_len(&self) -> Option<usize> {
self.input.current_span_len()
}
fn channels(&self) -> ChannelCount {
self.input.channels()
}
fn sample_rate(&self) -> SampleRate {
self.input.sample_rate()
}
fn total_duration(&self) -> Option<Duration> {
self.input.total_duration()
}
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
let result = self.input.try_seek(pos);
if result.is_ok() {
self.shared.clear();
self.state.reset_filters();
}
result
}
}
#[derive(Debug)]
struct AnalyzerState {
channels: usize,
sample_rate: f32,
bass_alpha: f32,
mid_alpha: f32,
channel_index: usize,
frame_sum: f32,
frame_count: usize,
window_frames: usize,
scope_counter: usize,
scope_stride: usize,
low_bass: f32,
low_mid: f32,
full_sq: f32,
bass_sq: f32,
mid_sq: f32,
treble_sq: f32,
slow_energy: f32,
smooth_energy: f32,
smooth_bass: f32,
smooth_mid: f32,
smooth_treble: f32,
smooth_beat: f32,
}
impl AnalyzerState {
fn new(channels: ChannelCount, sample_rate: SampleRate) -> Self {
let mut state = Self {
channels: channels.get() as usize,
sample_rate: sample_rate.get() as f32,
bass_alpha: 0.0,
mid_alpha: 0.0,
channel_index: 0,
frame_sum: 0.0,
frame_count: 0,
window_frames: 0,
scope_counter: 0,
scope_stride: 0,
low_bass: 0.0,
low_mid: 0.0,
full_sq: 0.0,
bass_sq: 0.0,
mid_sq: 0.0,
treble_sq: 0.0,
slow_energy: 0.0,
smooth_energy: 0.0,
smooth_bass: 0.0,
smooth_mid: 0.0,
smooth_treble: 0.0,
smooth_beat: 0.0,
};
state.configure();
state
}
fn ensure_format(&mut self, channels: ChannelCount, sample_rate: SampleRate) {
let channels = channels.get() as usize;
let sample_rate = sample_rate.get() as f32;
if self.channels != channels || (self.sample_rate - sample_rate).abs() >= 1.0 {
self.channels = channels;
self.sample_rate = sample_rate;
self.configure();
self.reset_filters();
}
}
fn configure(&mut self) {
self.channels = self.channels.max(1);
self.sample_rate = self.sample_rate.max(1.0);
self.bass_alpha = lowpass_alpha(180.0, self.sample_rate);
self.mid_alpha = lowpass_alpha(2_400.0, self.sample_rate);
self.window_frames = (self.sample_rate / TARGET_ANALYSIS_HZ).round().max(256.0) as usize;
self.scope_stride = (self.sample_rate / TARGET_SCOPE_HZ).round().max(1.0) as usize;
}
fn reset_filters(&mut self) {
self.channel_index = 0;
self.frame_sum = 0.0;
self.frame_count = 0;
self.scope_counter = 0;
self.low_bass = 0.0;
self.low_mid = 0.0;
self.full_sq = 0.0;
self.bass_sq = 0.0;
self.mid_sq = 0.0;
self.treble_sq = 0.0;
self.slow_energy = 0.0;
self.smooth_energy = 0.0;
self.smooth_bass = 0.0;
self.smooth_mid = 0.0;
self.smooth_treble = 0.0;
self.smooth_beat = 0.0;
}
fn accept_sample(&mut self, sample: f32, shared: &AnalyzerShared) {
let sample = if sample.is_finite() {
sample.clamp(-1.5, 1.5)
} else {
0.0
};
self.frame_sum += sample;
self.channel_index += 1;
if self.channel_index < self.channels {
return;
}
let mono = self.frame_sum / self.channels as f32;
self.channel_index = 0;
self.frame_sum = 0.0;
self.accept_frame(mono, shared);
}
fn accept_frame(&mut self, sample: f32, shared: &AnalyzerShared) {
self.low_bass += self.bass_alpha * (sample - self.low_bass);
self.low_mid += self.mid_alpha * (sample - self.low_mid);
let bass = self.low_bass;
let mid = self.low_mid - self.low_bass;
let treble = sample - self.low_mid;
self.full_sq += sample * sample;
self.bass_sq += bass * bass;
self.mid_sq += mid * mid;
self.treble_sq += treble * treble;
self.frame_count += 1;
self.scope_counter += 1;
if self.scope_counter >= self.scope_stride {
self.scope_counter = 0;
shared.push_scope(sample);
}
if self.frame_count >= self.window_frames {
self.publish_window(shared);
}
}
fn publish_window(&mut self, shared: &AnalyzerShared) {
let frames = self.frame_count.max(1) as f32;
let energy = compress_rms((self.full_sq / frames).sqrt(), 7.5);
let bass = compress_rms((self.bass_sq / frames).sqrt(), 11.0);
let mid = compress_rms((self.mid_sq / frames).sqrt(), 15.0);
let treble = compress_rms((self.treble_sq / frames).sqrt(), 22.0);
if self.slow_energy == 0.0 {
self.slow_energy = energy;
} else {
self.slow_energy = self.slow_energy * 0.94 + energy * 0.06;
}
let beat_raw = ((energy - self.slow_energy * 1.18) * 5.5).clamp(0.0, 1.0);
self.smooth_energy = smooth_level(self.smooth_energy, energy, 0.35, 0.82);
self.smooth_bass = smooth_level(self.smooth_bass, bass, 0.30, 0.80);
self.smooth_mid = smooth_level(self.smooth_mid, mid, 0.35, 0.82);
self.smooth_treble = smooth_level(self.smooth_treble, treble, 0.28, 0.76);
self.smooth_beat = smooth_level(self.smooth_beat, beat_raw, 0.18, 0.70);
shared.store_levels(
self.smooth_energy,
self.smooth_bass,
self.smooth_mid,
self.smooth_treble,
self.smooth_beat,
);
self.full_sq = 0.0;
self.bass_sq = 0.0;
self.mid_sq = 0.0;
self.treble_sq = 0.0;
self.frame_count = 0;
}
}
fn lowpass_alpha(cutoff_hz: f32, sample_rate: f32) -> f32 {
1.0 - (-std::f32::consts::TAU * cutoff_hz / sample_rate.max(1.0)).exp()
}
fn compress_rms(rms: f32, scale: f32) -> f32 {
(1.0 - (-rms.max(0.0) * scale).exp()).clamp(0.0, 1.0)
}
fn smooth_level(previous: f32, next: f32, attack: f32, release: f32) -> f32 {
let keep = if next > previous { attack } else { release };
previous * keep + next * (1.0 - keep)
}
fn store_norm(target: &AtomicU32, value: f32) {
target.store(
(value.clamp(0.0, 1.0) * LEVEL_SCALE).round() as u32,
Ordering::Relaxed,
);
}
fn load_norm(source: &AtomicU32) -> f64 {
f64::from(source.load(Ordering::Relaxed)) / f64::from(LEVEL_SCALE)
}
#[cfg(test)]
mod tests {
use super::*;
struct TestSource {
samples: Vec<Sample>,
cursor: usize,
channels: ChannelCount,
sample_rate: SampleRate,
}
impl TestSource {
fn sine(frames: usize, channels: u16, sample_rate: u32, frequency: f32) -> Self {
let channels_count = channels.max(1);
let mut samples = Vec::with_capacity(frames * usize::from(channels_count));
for frame in 0..frames {
let time = frame as f32 / sample_rate as f32;
let sample = (std::f32::consts::TAU * frequency * time).sin() * 0.5;
for _ in 0..channels_count {
samples.push(sample as Sample);
}
}
Self {
samples,
cursor: 0,
channels: ChannelCount::new(channels_count).unwrap(),
sample_rate: SampleRate::new(sample_rate).unwrap(),
}
}
}
impl Iterator for TestSource {
type Item = Sample;
fn next(&mut self) -> Option<Self::Item> {
let sample = self.samples.get(self.cursor).copied()?;
self.cursor += 1;
Some(sample)
}
fn size_hint(&self) -> (usize, Option<usize>) {
let remaining = self.samples.len().saturating_sub(self.cursor);
(remaining, Some(remaining))
}
}
impl Source for TestSource {
fn current_span_len(&self) -> Option<usize> {
Some(self.samples.len().saturating_sub(self.cursor))
}
fn channels(&self) -> ChannelCount {
self.channels
}
fn sample_rate(&self) -> SampleRate {
self.sample_rate
}
fn total_duration(&self) -> Option<Duration> {
let frames = self.samples.len() / usize::from(self.channels.get());
Some(Duration::from_secs_f64(
frames as f64 / f64::from(self.sample_rate.get()),
))
}
}
#[test]
fn analyzed_source_publishes_levels_and_scope() {
let shared = Arc::new(AnalyzerShared::default());
let source = TestSource::sine(4_096, 2, 48_000, 110.0);
let analyzed = AnalyzedSource::new(source, Arc::clone(&shared));
for _ in analyzed {}
let snapshot = shared.snapshot();
assert!(snapshot.sequence > 0);
assert!(snapshot.energy > 0.0);
assert!(snapshot.bass > 0.0);
assert!(snapshot.scope.iter().any(|sample| sample.abs() > 0.001));
}
}
+60 -33
View File
@@ -4,16 +4,23 @@
//! through the callback given to `spawn` — this module knows nothing about
//! the UI or app state.
mod analyzer;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::time::Duration;
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
use stream_download::StreamDownload;
use stream_download::storage::temp::TempStorageProvider;
pub type TrackReader = StreamDownload<TempStorageProvider>;
pub use analyzer::AudioAnalysisSnapshot;
pub trait TrackReadSeek: std::io::Read + std::io::Seek + Send + Sync {}
impl<T> TrackReadSeek for T where T: std::io::Read + std::io::Seek + Send + Sync {}
/// Playback sources are either ordinary files or growing streaming cache files.
pub type TrackReader = Box<dyn TrackReadSeek>;
/// Perceptual volume: cubic mapping from percent to linear amplitude, so
/// equal percent steps sound like equal loudness steps and low percentages
@@ -25,6 +32,8 @@ pub fn amplitude(percent: u8) -> f32 {
#[derive(Debug)]
pub enum PlayerEvent {
/// A newly requested source was decoded and handed to rodio.
Started,
/// A track played to its end. `has_next` is true when a prefetched
/// source was already queued and is now playing gaplessly.
TrackFinished {
@@ -35,17 +44,18 @@ pub enum PlayerEvent {
enum Command {
Play {
reader: Box<TrackReader>,
reader: TrackReader,
byte_len: Option<u64>,
mime_type: Option<String>,
seekable: bool,
volume: f32,
},
/// Append the next track behind the current one without interrupting
/// playback — rodio switches sources back to back (gapless-ish).
Enqueue {
reader: Box<TrackReader>,
reader: TrackReader,
byte_len: Option<u64>,
},
TogglePause,
Pause,
Resume,
Stop,
@@ -58,6 +68,7 @@ enum Command {
pub struct Shared {
position_ms: AtomicU64,
paused: AtomicBool,
analysis: Arc<analyzer::AnalyzerShared>,
}
impl Shared {
@@ -68,6 +79,10 @@ impl Shared {
pub fn paused(&self) -> bool {
self.paused.load(Ordering::Relaxed)
}
pub fn audio_analysis(&self) -> AudioAnalysisSnapshot {
self.analysis.snapshot()
}
}
#[derive(Clone)]
@@ -79,21 +94,26 @@ pub struct Controller {
impl Controller {
pub fn play(&self, reader: TrackReader, byte_len: Option<u64>, volume: f32) {
let _ = self.tx.send(Command::Play {
reader: Box::new(reader),
reader,
byte_len,
mime_type: None,
seekable: true,
volume,
});
}
pub fn play_stream(&self, reader: TrackReader, mime_type: Option<String>, volume: f32) {
let _ = self.tx.send(Command::Play {
reader,
byte_len: None,
mime_type,
seekable: false,
volume,
});
}
pub fn enqueue(&self, reader: TrackReader, byte_len: Option<u64>) {
let _ = self.tx.send(Command::Enqueue {
reader: Box::new(reader),
byte_len,
});
}
pub fn toggle_pause(&self) {
let _ = self.tx.send(Command::TogglePause);
let _ = self.tx.send(Command::Enqueue { reader, byte_len });
}
pub fn pause(&self) {
@@ -144,7 +164,7 @@ fn run(rx: Receiver<Command>, shared: Arc<Shared>, on_event: impl Fn(PlayerEvent
Ok(command) => {
// Commands change the source queue legitimately; resync the
// length so the next tick doesn't read it as a track ending.
handle(command, &mut output, &mut track_loaded, &on_event);
handle(command, &shared, &mut output, &mut track_loaded, &on_event);
last_len = output.as_ref().map_or(0, |out| out.player.len());
}
Err(RecvTimeoutError::Timeout) => {
@@ -174,6 +194,7 @@ fn run(rx: Receiver<Command>, shared: Arc<Shared>, on_event: impl Fn(PlayerEvent
fn handle(
command: Command,
shared: &Arc<Shared>,
output: &mut Option<Output>,
track_loaded: &mut bool,
on_event: &impl Fn(PlayerEvent),
@@ -182,6 +203,8 @@ fn handle(
Command::Play {
reader,
byte_len,
mime_type,
seekable,
volume,
} => {
// The device is opened lazily on first playback so the app works
@@ -205,18 +228,26 @@ fn handle(
let mut builder = Decoder::builder()
.with_data(reader)
.with_seekable(true)
.with_seekable(seekable)
.with_gapless(true);
if let Some(len) = byte_len {
builder = builder.with_byte_len(len);
}
if let Some(mime_type) = mime_type.as_deref() {
builder = builder.with_mime_type(mime_type);
}
match builder.build() {
Ok(decoder) => {
shared.analysis.clear();
out.player.stop();
out.player.set_volume(volume);
out.player.append(decoder);
out.player.append(analyzer::AnalyzedSource::new(
decoder,
Arc::clone(&shared.analysis),
));
out.player.play();
*track_loaded = true;
on_event(PlayerEvent::Started);
}
Err(err) => {
on_event(PlayerEvent::Failed(format!("cannot decode track: {err}")));
@@ -235,7 +266,10 @@ fn handle(
builder = builder.with_byte_len(len);
}
match builder.build() {
Ok(decoder) => out.player.append(decoder),
Ok(decoder) => out.player.append(analyzer::AnalyzedSource::new(
decoder,
Arc::clone(&shared.analysis),
)),
Err(err) => {
on_event(PlayerEvent::Failed(format!(
"cannot decode next track: {err}"
@@ -243,15 +277,6 @@ fn handle(
}
}
}
Command::TogglePause => {
if let Some(out) = output {
if out.player.is_paused() {
out.player.play();
} else {
out.player.pause();
}
}
}
Command::Pause => {
if let Some(out) = output {
out.player.pause();
@@ -263,17 +288,19 @@ fn handle(
}
}
Command::Stop => {
if let Some(out) = output {
if let Some(out) = output.take() {
out.player.stop();
}
shared.analysis.clear();
*track_loaded = false;
}
Command::Seek(position) => {
if let Some(out) = output {
if let Err(err) = out.player.try_seek(position) {
tracing::warn!(%err, "seek failed");
}
if let Some(out) = output
&& let Err(err) = out.player.try_seek(position)
{
tracing::warn!(%err, "seek failed");
}
shared.analysis.clear();
}
Command::SetVolume(volume) => {
if let Some(out) = output {
+243
View File
@@ -0,0 +1,243 @@
use crate::library::models::TrackItem;
pub fn track_content_id(track: &TrackItem) -> Option<String> {
track
.fed
.as_ref()
.and_then(|fed| fed.content_id.as_deref())
.or(track.content_id.as_deref())
.and_then(music_dht::normalize_content_id)
}
pub fn track_can_share(track: &TrackItem) -> bool {
track_content_id(track).is_some() || !track.file_path.trim().is_empty()
}
pub fn track_share_link(track: &TrackItem) -> Option<String> {
let content_id = track_content_id(track).or_else(|| {
(!track.file_path.trim().is_empty())
.then(|| crate::library::audio_content_id(&track.file_path))
.flatten()
})?;
Some(track_share_link_for_content_id(track, &content_id))
}
/// A parsed `frid://` share link.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FridLink {
/// Canonical content id (`b3:<64 hex>`).
pub content_id: String,
/// Human-readable "artists-title" label from the `t` query parameter.
pub label: Option<String>,
}
pub fn parse_frid_link(value: &str) -> Option<FridLink> {
let value = value.trim();
let rest = value.strip_prefix("frid://")?;
let mut parts = rest.splitn(2, ['?', '#']);
let content_id =
music_dht::normalize_content_id(parts.next().unwrap_or_default().trim_end_matches('/'))?;
let label = parts.next().and_then(|query| {
query.split('&').find_map(|pair| {
let value = pair.strip_prefix("t=")?;
let decoded = percent_decode(value);
let decoded = decoded.trim();
(!decoded.is_empty()).then(|| decoded.to_string())
})
});
Some(FridLink { content_id, label })
}
pub fn cached_track_share_link(track: &TrackItem) -> Option<String> {
let content_id = track_content_id(track)?;
Some(track_share_link_for_content_id(track, &content_id))
}
fn track_share_link_for_content_id(track: &TrackItem, content_id: &str) -> String {
let label = track_share_label(track);
if label.is_empty() {
format!("frid://{content_id}")
} else {
format!("frid://{content_id}?t={}", percent_encode(&label))
}
}
fn track_share_label(track: &TrackItem) -> String {
let artists = track.artist_line();
let title = track.title.trim();
match (artists.trim().is_empty(), title.is_empty()) {
(false, false) => format!("{}-{title}", artists.trim()),
(true, false) => title.to_string(),
(false, true) => artists.trim().to_string(),
(true, true) => String::new(),
}
}
fn percent_encode(value: &str) -> String {
let mut out = String::new();
for byte in value.as_bytes() {
match *byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(char::from(*byte))
}
byte => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
fn percent_decode(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' if index + 3 <= bytes.len() => {
let hex = std::str::from_utf8(&bytes[index + 1..index + 3])
.ok()
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
match hex {
Some(byte) => {
out.push(byte);
index += 3;
}
None => {
out.push(b'%');
index += 1;
}
}
}
// Liberal form-encoding acceptance; our encoder never emits '+'.
b'+' => {
out.push(b' ');
index += 1;
}
byte => {
out.push(byte);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::models::ArtistRef;
#[test]
fn share_link_uses_content_id_and_readable_label() {
let track = TrackItem {
id: 1,
title: "Трек".into(),
track_number: None,
disc_number: None,
duration_seconds: 1.0,
artists: vec![ArtistRef {
id: 1,
name: "Артист".into(),
}],
featured_artists: Vec::new(),
release_id: 1,
release_title: "Release".into(),
release_year: None,
file_path: String::new(),
content_id: Some(
"b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
),
cover_path: None,
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
play_count: 0,
fed: None,
};
assert_eq!(
track_share_link(&track).as_deref(),
Some(
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=%D0%90%D1%80%D1%82%D0%B8%D1%81%D1%82-%D0%A2%D1%80%D0%B5%D0%BA"
)
);
}
#[test]
fn parses_frid_content_link() {
let link = parse_frid_link(
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=Artist-Track"
)
.expect("valid link");
assert_eq!(
link.content_id,
"b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
);
assert_eq!(link.label.as_deref(), Some("Artist-Track"));
assert!(parse_frid_link("https://example.com").is_none());
}
#[test]
fn parses_percent_encoded_label() {
let link = parse_frid_link(
"frid://B3:0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef?t=Rammstein-Du%20Riechst%20So%20Gut"
)
.expect("valid link");
assert_eq!(
link.content_id,
"b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
);
assert_eq!(link.label.as_deref(), Some("Rammstein-Du Riechst So Gut"));
// Cyrillic labels and a missing label both survive.
let link = parse_frid_link(
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=%D0%90%D1%80%D1%82%D0%B8%D1%81%D1%82"
)
.expect("valid link");
assert_eq!(link.label.as_deref(), Some("Артист"));
let link = parse_frid_link(
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
)
.expect("valid link");
assert_eq!(link.label, None);
}
#[test]
fn share_link_round_trips_through_parse() {
let track = TrackItem {
id: 1,
title: "Du Riechst So Gut".into(),
track_number: None,
disc_number: None,
duration_seconds: 1.0,
artists: vec![ArtistRef {
id: 1,
name: "Rammstein".into(),
}],
featured_artists: Vec::new(),
release_id: 1,
release_title: "Herzeleid".into(),
release_year: None,
file_path: String::new(),
content_id: Some(
"b3:5ebd9e61e1154a64470e6ee4dd1225550f380dd575d01fd4ffba6a5bd1b34104".into(),
),
cover_path: 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 link = track_share_link(&track).expect("link");
let parsed = parse_frid_link(&link).expect("parses back");
assert_eq!(
parsed.content_id,
"b3:5ebd9e61e1154a64470e6ee4dd1225550f380dd575d01fd4ffba6a5bd1b34104"
);
assert_eq!(parsed.label.as_deref(), Some("Rammstein-Du Riechst So Gut"));
}
}
+141
View File
@@ -0,0 +1,141 @@
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::Path;
use std::sync::{Arc, Condvar, Mutex};
#[derive(Debug, Default)]
struct State {
available: u64,
complete: bool,
}
#[derive(Debug, Default)]
struct Shared {
state: Mutex<State>,
changed: Condvar,
}
#[derive(Debug)]
pub struct GrowingFileReader {
file: File,
pos: u64,
shared: Arc<Shared>,
}
#[derive(Debug)]
pub struct GrowingFileWriter {
shared: Arc<Shared>,
}
pub fn growing_file(path: &Path) -> io::Result<(GrowingFileReader, GrowingFileWriter)> {
let file = File::open(path)?;
let shared = Arc::new(Shared::default());
Ok((
GrowingFileReader {
file,
pos: 0,
shared: Arc::clone(&shared),
},
GrowingFileWriter { shared },
))
}
impl GrowingFileWriter {
pub fn add_available(&self, bytes: u64) {
let mut state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.available = state.available.saturating_add(bytes);
self.shared.changed.notify_all();
}
pub fn finish(&self) {
let mut state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.complete = true;
self.shared.changed.notify_all();
}
}
impl Drop for GrowingFileWriter {
fn drop(&mut self) {
self.finish();
}
}
impl Read for GrowingFileReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
loop {
let mut state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while self.pos >= state.available && !state.complete {
state = self
.shared
.changed
.wait(state)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
if self.pos >= state.available && state.complete {
return Ok(0);
}
let readable = (state.available - self.pos).min(buf.len() as u64) as usize;
drop(state);
let read = self.file.read(&mut buf[..readable])?;
self.pos = self.pos.saturating_add(read as u64);
if read > 0 {
return Ok(read);
}
}
}
}
impl Seek for GrowingFileReader {
fn seek(&mut self, _: SeekFrom) -> io::Result<u64> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"streaming playback is not seekable yet",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[test]
fn growing_reader_reads_available_bytes_then_eof() {
let path =
std::env::temp_dir().join(format!("furumi-growing-reader-{}", std::process::id()));
let _ = std::fs::remove_file(&path);
std::fs::File::create(&path).unwrap();
let (mut reader, writer) = growing_file(&path).unwrap();
let mut output = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
output.write_all(b"fur").unwrap();
writer.add_available(3);
let mut first = [0u8; 3];
reader.read_exact(&mut first).unwrap();
assert_eq!(&first, b"fur");
output.write_all(b"umi").unwrap();
writer.add_available(3);
writer.finish();
let mut rest = Vec::new();
reader.read_to_end(&mut rest).unwrap();
assert_eq!(&rest, b"umi");
drop(output);
let _ = std::fs::remove_file(&path);
}
}
+1266
View File
File diff suppressed because it is too large Load Diff
+815 -76
View File
File diff suppressed because it is too large Load Diff
-230
View File
@@ -1,230 +0,0 @@
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Wrap};
use super::theme;
use crate::app::state::{LoginField, LoginForm, LoginMode};
pub fn draw(frame: &mut Frame, form: &LoginForm) {
match form.mode {
LoginMode::Form => draw_form(frame, form),
LoginMode::SsoPending => draw_sso_pending(frame, form),
}
}
fn draw_form(frame: &mut Frame, form: &LoginForm) {
let area = centered(frame.area(), 52, 19);
let block = Block::bordered()
.title(" Sign in to furumi ")
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(block, area);
// SSO is the primary path: server URL + SSO button up top, the rarely
// used password fallback below a separator.
let [
server,
sso_button,
separator,
username,
password,
signin_button,
message,
hint,
] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(2),
Constraint::Length(1),
])
.areas(inner);
draw_field(
frame,
server,
"Server URL",
&form.server_url,
false,
form.focus == LoginField::ServerUrl,
);
draw_button(
frame,
sso_button,
"[ Continue with SSO ]",
form.focus == LoginField::SsoButton,
);
frame.render_widget(
Paragraph::new(Line::styled("── or sign in with password ──", theme::dim()))
.alignment(Alignment::Center),
separator,
);
draw_field(
frame,
username,
"Username",
&form.username,
false,
form.focus == LoginField::Username,
);
draw_field(
frame,
password,
"Password",
&form.password,
true,
form.focus == LoginField::Password,
);
draw_button(
frame,
signin_button,
"[ Sign in ]",
form.focus == LoginField::SignInButton,
);
draw_message(frame, message, form);
frame.render_widget(
Paragraph::new(Line::styled(
"tab/↑↓ move · enter submit · ctrl-c quit",
theme::dim(),
))
.alignment(Alignment::Center),
hint,
);
}
fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) {
// The URL stays on ONE line (wrapping breaks copy-paste); the dialog is
// as wide as the terminal allows and ctrl-l copies the full link.
let width =
(form.sso_url.len() as u16 + 4).clamp(48, frame.area().width.saturating_sub(2).max(40));
let area = centered(frame.area(), width, 14.min(frame.area().height));
let block = Block::bordered()
.title(" Continue with SSO ")
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(block, area);
let [steps, url_label, url, paste, message, hint] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(2),
Constraint::Length(1),
])
.areas(inner);
let lines = if let Some(port) = form.sso_port {
vec![
Line::raw("1. Finish signing in, in the browser window."),
Line::from(vec![
Span::raw("2. Sign-in completes here automatically "),
Span::styled(format!("(waiting on 127.0.0.1:{port})"), theme::dim()),
]),
Line::raw("3. If it doesn't, paste the code from the page below."),
]
} else {
vec![
Line::raw("1. Finish signing in, in the browser window."),
Line::raw("2. Copy the code shown on the final page."),
Line::raw("3. Paste it below and press Enter."),
]
};
frame.render_widget(Paragraph::new(lines), steps);
frame.render_widget(
Paragraph::new(Line::styled(
"If the browser didn't open — ctrl-l copies this link, ctrl-o retries:",
theme::dim(),
)),
url_label,
);
// One line, never wrapped: a wrapped URL copies with a line break and
// stops working. If it doesn't fit, ctrl-l still copies it whole.
frame.render_widget(
Paragraph::new(Line::styled(form.sso_url.clone(), theme::accent())),
url,
);
draw_field(frame, paste, "Link or code", &form.sso_paste, false, true);
draw_message(frame, message, form);
frame.render_widget(
Paragraph::new(Line::styled(
"enter submit · ctrl-l copy link · esc back · ctrl-c quit",
theme::dim(),
))
.alignment(Alignment::Center),
hint,
);
}
fn draw_field(frame: &mut Frame, area: Rect, label: &str, value: &str, mask: bool, focused: bool) {
let border = if focused {
theme::accent()
} else {
theme::dim()
};
let block = Block::bordered().title(label).border_style(border);
let shown = if mask {
"".repeat(value.chars().count())
} else {
value.to_string()
};
// Keep the tail visible when the value overflows the field.
let width = block.inner(area).width.saturating_sub(1) as usize;
let mut text: String = shown
.chars()
.skip(shown.chars().count().saturating_sub(width))
.collect();
if focused {
text.push('█');
}
frame.render_widget(Paragraph::new(text).block(block), area);
}
fn draw_button(frame: &mut Frame, area: Rect, label: &str, focused: bool) {
let style = if focused {
theme::tab_active()
} else {
theme::dim()
};
frame.render_widget(
Paragraph::new(Line::styled(label, style)).alignment(Alignment::Center),
area,
);
}
fn draw_message(frame: &mut Frame, area: Rect, form: &LoginForm) {
let line = if form.busy {
Line::styled("signing in…", theme::accent())
} else if let Some(error) = &form.error {
Line::styled(error.clone(), Style::new().fg(Color::Red))
} else {
Line::default()
};
frame.render_widget(
Paragraph::new(line)
.wrap(Wrap { trim: true })
.alignment(Alignment::Center),
area,
);
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let [rect] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(area);
let [rect] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(rect);
rect
}
+9 -7
View File
@@ -14,8 +14,8 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
let mode = if logs.follow { "follow" } else { "scroll" };
let block = Block::bordered()
.title(format!(" Logs — {level}+ · {mode} "))
.title_style(theme::header())
.border_style(theme::dim());
.title_style(theme::header_for(state))
.border_style(theme::border_for(state));
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -39,13 +39,15 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
};
let line = Line::from(vec![
Span::styled(format!("{} ", entry.time), theme::dim()),
level_span(entry.level),
level_span(entry.level, state),
Span::styled(format!(" {}: ", short_target(&entry.target)), theme::dim()),
Span::raw(entry.message.clone()),
]);
frame.render_widget(Paragraph::new(line), row);
if view.cursor_row == Some(row_index) {
frame.buffer_mut().set_style(row, theme::tab_active());
frame
.buffer_mut()
.set_style(row, theme::tab_active_for(state));
}
}
@@ -62,7 +64,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
" ↑{} of {} · enter: details · shift-g: follow · v: level ",
view.from_end, view.matched
),
theme::tab_active(),
theme::tab_active_for(state),
))
.alignment(Alignment::Right),
footer,
@@ -85,14 +87,14 @@ fn centered(frame: &mut Frame, area: Rect, text: &str) {
);
}
fn level_span(level: tracing::Level) -> Span<'static> {
fn level_span(level: tracing::Level, state: &AppState) -> Span<'static> {
match level {
tracing::Level::ERROR => Span::styled(
"ERROR",
Style::new().fg(Color::Red).add_modifier(Modifier::BOLD),
),
tracing::Level::WARN => Span::styled("WARN ", Style::new().fg(Color::Yellow)),
tracing::Level::INFO => Span::styled("INFO ", theme::accent()),
tracing::Level::INFO => Span::styled("INFO ", theme::accent_for(state)),
tracing::Level::DEBUG => Span::styled("DEBUG", theme::dim()),
tracing::Level::TRACE => Span::styled("TRACE", theme::dim()),
}
+307 -122
View File
@@ -1,6 +1,6 @@
pub mod art;
mod federation;
mod global;
mod login;
mod logs;
mod playlists;
mod popup;
@@ -12,14 +12,52 @@ use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
use crate::app::state::{AppState, Screen, Tab};
use crate::app::input::LineEdit;
use crate::app::state::{AppState, GlobalView, Loadable, Tab, TrackSelectionScope};
use crate::config::keymap::Keymap;
use crate::library::models::Availability;
pub(crate) fn availability_marker(
availability: Availability,
selected_style: Option<Style>,
) -> (&'static str, Style) {
let (label, style) = match availability {
Availability::Local => ("", Style::new().fg(Color::Green)),
Availability::Mixed => ("", Style::new().fg(Color::Yellow)),
Availability::Remote => ("", theme::accent()),
};
(label, selected_style.unwrap_or(style))
}
pub(crate) fn availability_prefix(availability: Availability) -> Span<'static> {
let (label, style) = availability_marker(availability, None);
Span::styled(format!("{label} "), style)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selected_availability_marker_uses_the_supplied_mode_style() {
let client_style = Style::new().fg(Color::Black).bg(Color::Yellow);
let (label, style) = availability_marker(Availability::Local, Some(client_style));
assert_eq!(label, "");
assert_eq!(style, client_style);
}
}
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
if state.screen == Screen::Login {
login::draw(frame, &state.login);
if state.visualizer.active {
crate::visualizer::draw(frame, state);
if state.shutting_down {
draw_shutdown(frame, state);
}
return;
}
let [tabs_area, main_area, status_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
@@ -32,14 +70,66 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
Tab::Global => global::draw(frame, main_area, state),
Tab::Playlists => playlists::draw(frame, main_area, state),
Tab::Queue => draw_queue(frame, main_area, state),
Tab::Federation => federation::draw(frame, main_area, state),
Tab::Logs => logs::draw(frame, main_area, state),
}
draw_status(frame, status_area, state);
if state.help_visible {
draw_help(frame, keymap);
draw_help(frame, keymap, state);
}
popup::draw(frame, state);
if state.shutting_down {
draw_shutdown(frame, state);
}
}
fn draw_shutdown(frame: &mut Frame, state: &AppState) {
let area = centered(frame.area(), 28, 5);
frame.render_widget(Clear, area);
let block = Block::bordered().border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::styled("Shutting down...", theme::header_for(state)))
.alignment(Alignment::Center),
Rect {
y: inner.y + inner.height / 2,
height: 1,
..inner
},
);
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width,
height,
}
}
pub(crate) fn loading_line(state: &AppState, text: impl Into<String>) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{} ", state.spinner()), theme::accent_for(state)),
Span::styled(text.into(), theme::dim()),
])
}
fn is_waiting_message(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
normalized.contains("loading")
|| normalized.contains("searching")
|| normalized.contains("fetching")
|| normalized.contains("downloading")
|| normalized.contains("locating")
|| normalized.contains("importing")
|| normalized.contains("waiting")
|| normalized.contains("assembling")
|| normalized.contains("resolving")
}
fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
@@ -49,7 +139,7 @@ fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
let tabs = Tabs::new(titles)
.select(state.active_tab.index())
.style(theme::dim())
.highlight_style(theme::tab_active())
.highlight_style(theme::tab_active_for(state))
.divider("");
frame.render_widget(tabs, area);
}
@@ -60,18 +150,54 @@ pub(crate) fn track_row(
frame: &mut Frame,
area: Rect,
state: &AppState,
track: &crate::api::models::TrackItem,
track: &crate::library::models::TrackItem,
index_label: String,
selected: bool,
visual_selected: bool,
) {
let heart = if state.likes.contains(&track.id) {
Span::styled("", theme::accent())
track_row_with_like_marker(
frame,
area,
state,
track,
index_label,
selected,
visual_selected,
true,
);
}
pub(crate) fn track_row_with_like_marker(
frame: &mut Frame,
area: Rect,
state: &AppState,
track: &crate::library::models::TrackItem,
index_label: String,
selected: bool,
visual_selected: bool,
show_like_marker: bool,
) {
let heart = if !show_like_marker {
Span::raw("")
} else if state.track_liked(track) {
Span::styled("", theme::accent_for(state))
} else {
Span::raw(" ")
};
let fed_marker = if track.fed.is_some() {
let availability = if state.track_content_local(track) {
Availability::Local
} else {
Availability::Remote
};
availability_prefix(availability)
} else {
Span::raw("")
};
let line = Line::from(vec![
Span::styled(format!("{index_label:>3} "), theme::dim()),
heart,
fed_marker,
Span::raw(track.title.clone()),
Span::styled(format!(" {}", track.artist_line()), theme::dim()),
]);
@@ -82,13 +208,20 @@ pub(crate) fn track_row(
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
area,
);
if visual_selected {
frame
.buffer_mut()
.set_style(area, theme::selection_for(state));
}
if selected {
frame.buffer_mut().set_style(area, theme::tab_active());
frame
.buffer_mut()
.set_style(area, theme::tab_active_for(state));
}
}
pub(crate) fn track_meta_suffix(
track: &crate::api::models::TrackItem,
track: &crate::library::models::TrackItem,
include_tech: bool,
) -> String {
let has_tech = track.audio_format.is_some()
@@ -125,11 +258,12 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
let player = &state.player;
let block = Block::bordered()
.title(format!(
" Queue — {} tracks · enter: play · shift-c: clear ",
player.queue.len()
" Queue — {} tracks · Mode: {} · enter: play · d: remove · shift-v: select · :clear ",
player.queue.len(),
state.global.filters.source_mode.label()
))
.title_style(theme::header())
.border_style(theme::dim());
.title_style(theme::header_for(state))
.border_style(theme::border_for(state));
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -168,10 +302,21 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
} else {
(index + 1).to_string()
};
track_row(frame, row, state, track, label, index == cursor);
let visual_selected = state
.track_selection
.contains(&TrackSelectionScope::Queue, index);
track_row(
frame,
row,
state,
track,
label,
index == cursor,
visual_selected,
);
// Tracks before the playing one are history: greyed out unless the
// cursor is on them.
if index < player.queue_pos && index != cursor {
if index < player.queue_pos && index != cursor && !visual_selected {
frame.buffer_mut().set_style(row, played_style);
}
}
@@ -184,47 +329,48 @@ fn format_secs(secs: f64) -> String {
/// Playback time, progress bar, queue position, volume and mode flags.
/// Wider consoles get a longer bar and full flags; narrow ones drop pieces.
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
fn player_right_line(state: &AppState, width: u16) -> Line<'static> {
let player = &state.player;
let mut spans: Vec<Span<'static>> = Vec::new();
if let Some(track) = &player.current {
if player.playing {
let bar_width: usize = match width {
0..=59 => 0,
60..=79 => 8,
80..=109 => 14,
_ => 22,
};
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
if bar_width > 0 && track.duration_seconds > 0.0 {
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
let filled = (ratio * bar_width as f64).round() as usize;
spans.push(Span::styled("".repeat(filled), theme::accent()));
spans.push(Span::styled("".repeat(bar_width - filled), theme::dim()));
spans.push(Span::raw(" "));
} else {
spans.push(Span::styled("/ ", theme::dim()));
}
spans.push(Span::raw(track.duration_label()));
if !player.queue.is_empty() && width >= 70 {
spans.push(Span::styled(
format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()),
theme::dim(),
));
}
if let Some(track) = &player.current
&& player.playing
{
let bar_width: usize = match width {
0..=59 => 0,
60..=79 => 8,
80..=109 => 14,
_ => 22,
};
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
if bar_width > 0 && track.duration_seconds > 0.0 {
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
let filled = (ratio * bar_width as f64).round() as usize;
spans.push(Span::styled("".repeat(filled), theme::accent_for(state)));
spans.push(Span::styled("".repeat(bar_width - filled), theme::dim()));
spans.push(Span::raw(" "));
} else {
spans.push(Span::styled("/ ", theme::dim()));
}
spans.push(Span::raw(track.duration_label()));
if !player.queue.is_empty() && width >= 70 {
spans.push(Span::styled(
format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()),
theme::dim(),
));
}
}
if width >= 80 {
let volume_cells = usize::from(player.volume / 10);
spans.extend([
Span::styled(" vol ", theme::dim()),
Span::styled("".repeat(volume_cells), theme::accent()),
Span::styled("".repeat(volume_cells), theme::accent_for(state)),
Span::styled("".repeat(10 - volume_cells), theme::dim()),
Span::raw(format!(" {:3}%", player.volume)),
Span::raw(" "),
]);
// Enabled modes light up as filled chips; disabled stay dim text.
if player.shuffle {
spans.push(Span::styled(" shuffle ", theme::tab_active()));
spans.push(Span::styled(" shuffle ", theme::tab_active_for(state)));
} else {
spans.push(Span::styled("shuffle off", theme::dim()));
}
@@ -234,76 +380,41 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
} else {
spans.push(Span::styled(
format!(" repeat {} ", player.repeat.label()),
theme::tab_active(),
theme::tab_active_for(state),
));
}
} else {
spans.push(Span::styled(format!(" {}%", player.volume), theme::dim()));
}
if width >= 70 {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!(" {} ", state.device_playback.role.label()),
theme::role_pill(state.device_playback.role),
));
spans.push(Span::styled(
format!(" · online {}", state.device_playback.online_devices.max(1)),
theme::dim(),
));
}
// Keep a gap between the flags and the username block to the right.
spans.push(Span::raw(" "));
Line::from(spans)
}
fn truncate_chars(value: &str, max: usize) -> String {
let mut out: String = value.chars().take(max).collect();
if value.chars().count() > max {
out.push('…');
}
out
}
fn device_status_line(state: &AppState) -> Line<'static> {
if state.devices.is_playback_device() {
return Line::from(vec![Span::styled("playing here", theme::accent())]);
}
let name = state
.devices
.active_device_name()
.map(|name| truncate_chars(name, 26))
.unwrap_or_else(|| "remote device".to_string());
Line::from(vec![
Span::styled("controlling ", theme::dim()),
Span::styled(name, theme::accent()),
])
}
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
let [player_row, message_row] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let player = &state.player;
// Layout: track title left, time/progress/flags centered, user right.
// The center block is built first and gets a fixed width; the title
// Layout: track title left, time/progress/flags on the right. The
// right block is built first and gets a fixed width; the title
// truncates into whatever is left.
let center = player_right_line(player, area.width);
let center = player_right_line(state, area.width);
let center_width = (center.width() as u16).min(area.width);
let device_line = device_status_line(state);
let device_width = (device_line.width() as u16).min(32);
let user_line = state.user.as_ref().map(|user| {
Line::from(vec![
Span::styled("", theme::accent()),
Span::raw(user.name.clone()),
])
});
let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16);
let [title_area, right_area, device_area, user_area] = Layout::horizontal([
Constraint::Min(8),
Constraint::Length(center_width),
Constraint::Length(device_width.saturating_add(2)),
Constraint::Length(user_width),
])
.areas(player_row);
frame.render_widget(
Paragraph::new(device_line).alignment(Alignment::Right),
device_area,
);
if let Some(user_line) = user_line {
frame.render_widget(
Paragraph::new(user_line).alignment(Alignment::Right),
user_area,
);
}
let [title_area, right_area] =
Layout::horizontal([Constraint::Min(8), Constraint::Length(center_width)])
.areas(player_row);
let mut spans = Vec::new();
match &player.current {
@@ -311,10 +422,10 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
if player.paused {
spans.push(Span::styled("", theme::dim()));
} else {
spans.push(Span::styled("", theme::accent()));
spans.push(Span::styled("", theme::accent_for(state)));
}
if state.likes.contains(&track.id) {
spans.push(Span::styled("", theme::accent()));
if state.track_liked(track) {
spans.push(Span::styled("", theme::accent_for(state)));
}
spans.push(Span::raw(track.title.clone()));
spans.push(Span::styled(
@@ -331,36 +442,63 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
if state.cmdline.active {
// Vim-style command line takes over the message row.
let line = Line::from(vec![
Span::styled(":", theme::header()),
Span::raw(state.cmdline.input.clone()),
Span::styled("", theme::accent()),
]);
frame.render_widget(Paragraph::new(line), message_row);
let mut spans = vec![Span::styled(":", theme::header_for(state))];
spans.extend(line_edit_spans(
&state.cmdline.input,
usize::from(message_row.width.saturating_sub(2)),
));
frame.render_widget(Paragraph::new(Line::from(spans)), message_row);
draw_version(frame, message_row);
return;
}
let message = match &state.status_message {
Some(message) => Line::styled(message.clone(), theme::accent()),
None => match &state.player.current {
// Idle line doubles as the current track's tech data display.
Some(track) if state.player.playing && !track.tech_label_full().is_empty() => {
Line::styled(track.tech_label_full(), theme::dim())
}
_ => Line::styled("press ? for keybindings", theme::dim()),
Some(message) if is_waiting_message(message) => Line::from(vec![
Span::styled(format!("{} ", state.spinner()), theme::accent_for(state)),
Span::styled(message.clone(), theme::accent_for(state)),
]),
Some(message) => Line::styled(message.clone(), theme::accent_for(state)),
None => match active_artist_peer_search(state) {
Some(message) => loading_line(state, message),
None => match &state.player.current {
// Idle line doubles as the current track's tech data display.
Some(track) if state.player.playing && !track.tech_label_full().is_empty() => {
Line::styled(track.tech_label_full(), theme::dim())
}
_ => Line::styled("press ? for keybindings", theme::dim()),
},
},
};
frame.render_widget(Paragraph::new(message), message_row);
draw_version(frame, message_row);
if let Some(pending) = &state.pending_keys {
let pending = Paragraph::new(Line::styled(format!("{pending}"), theme::header()))
.alignment(Alignment::Right);
let pending = Paragraph::new(Line::styled(
format!("{pending}"),
theme::header_for(state),
))
.alignment(Alignment::Right);
frame.render_widget(pending, message_row);
}
}
fn active_artist_peer_search(state: &AppState) -> Option<String> {
if state.active_tab != Tab::Global {
return None;
}
let Some(GlobalView::Artist { id, .. }) = state.global.stack.last() else {
return None;
};
if !matches!(state.artist_fed_views.get(id), Some(Loadable::Loading)) {
return None;
}
let name = match state.artist_views.get(id) {
Some(Loadable::Ready(detail)) => detail.name.as_str(),
_ => "artist",
};
Some(format!("searching peers for \"{name}\""))
}
fn draw_version(frame: &mut Frame, area: Rect) {
let version = format!("v{}", env!("CARGO_PKG_VERSION"));
frame.render_widget(
@@ -371,7 +509,7 @@ fn draw_version(frame: &mut Frame, area: Rect) {
/// Help window: bindings merged per action (j / down on one row), grouped
/// into titled sections and laid out in two balanced columns.
fn draw_help(frame: &mut Frame, keymap: &Keymap) {
fn draw_help(frame: &mut Frame, keymap: &Keymap, state: &AppState) {
use crate::app::action::{Action, Category};
use crate::config::keymap::KeyContext;
@@ -405,7 +543,7 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) {
if rows.is_empty() {
continue;
}
let mut lines = vec![Line::styled(category.title(), theme::header())];
let mut lines = vec![Line::styled(category.title(), theme::header_for(state))];
for row in rows {
let keys = row.keys.join(" / ");
let context = if row.context == KeyContext::Global {
@@ -415,17 +553,33 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) {
};
let command = row.action.command_hint().unwrap_or("");
lines.push(Line::from(vec![
Span::styled(format!("{keys:<13}"), theme::accent()),
Span::styled(format!("{keys:<13}"), theme::accent_for(state)),
Span::raw(format!(
"{:<24}",
format!("{}{context}", row.action.describe())
)),
Span::styled(command.to_string(), theme::accent()),
Span::styled(command.to_string(), theme::accent_for(state)),
]));
}
lines.push(Line::default());
blocks.push(lines);
}
blocks.push(vec![
Line::styled("Status icons", theme::header_for(state)),
Line::from(vec![
Span::styled("", Style::new().fg(Color::Green)),
Span::raw(" Local on this device"),
]),
Line::from(vec![
Span::styled("", Style::new().fg(Color::Yellow)),
Span::raw(" Local + peer sources"),
]),
Line::from(vec![
Span::styled("", theme::accent_for(state)),
Span::raw(" Network only"),
]),
Line::default(),
]);
// Balance the blocks across two columns.
let total: usize = blocks.iter().map(Vec::len).sum();
@@ -446,8 +600,8 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) {
let block = Block::bordered()
.title(" Keybindings & commands ")
.title_style(theme::header())
.border_style(theme::accent());
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
@@ -481,3 +635,34 @@ fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
.areas(rect);
rect
}
/// Renders a [`LineEdit`] as spans with a visible cursor, windowed so the
/// cursor always stays on screen when the value is wider than `width`.
pub(crate) fn line_edit_spans(edit: &LineEdit, width: usize) -> Vec<Span<'static>> {
let width = width.max(2);
let chars: Vec<char> = edit.as_str().chars().collect();
let cursor = edit.cursor().min(chars.len());
// Window start: keep the cursor within the visible slice (one cell is
// reserved for the cursor block itself when it sits at the end).
let start = (cursor + 1).saturating_sub(width);
let end = (start + width.saturating_sub(1)).min(chars.len());
let before: String = chars[start..cursor].iter().collect();
let (under, after): (String, String) = if cursor < chars.len() {
(
chars[cursor].to_string(),
chars[cursor + 1..end.max(cursor + 1)].iter().collect(),
)
} else {
("".to_string(), String::new())
};
let cursor_style = if cursor < chars.len() {
ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
} else {
theme::accent()
};
vec![
Span::raw(before),
Span::styled(under, cursor_style),
Span::raw(after),
]
}
+36 -37
View File
@@ -4,8 +4,8 @@ use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use super::{theme, track_row};
use crate::app::state::{AppState, Loadable};
use super::{loading_line, theme, track_row_with_like_marker};
use crate::app::state::{AppState, LIKES_PLAYLIST_ID, Loadable, TrackSelectionScope};
use crate::app::update::playlist_tracks;
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
@@ -15,11 +15,11 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
}
}
fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect {
fn bordered(frame: &mut Frame, area: Rect, state: &AppState, title: String) -> Rect {
let block = Block::bordered()
.title(title)
.title_style(theme::header())
.border_style(theme::dim());
.title_style(theme::header_for(state))
.border_style(theme::border_for(state));
let inner = block.inner(area);
frame.render_widget(block, area);
inner
@@ -38,7 +38,15 @@ fn centered_line(frame: &mut Frame, area: Rect, line: Line) {
}
fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
let inner = bordered(frame, area, " Playlists ".to_string());
let inner = bordered(
frame,
area,
state,
format!(
" Playlists · Mode: {} ",
state.global.filters.source_mode.label()
),
);
let selected = state.playlists.selected;
let list = match &state.playlists.list {
@@ -51,11 +59,7 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
);
}
_ => {
return centered_line(
frame,
inner,
Line::styled("loading playlists…", theme::dim()),
);
return centered_line(frame, inner, loading_line(state, "loading playlists…"));
}
};
if list.is_empty() {
@@ -74,30 +78,12 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
height: 1,
};
let marker = if playlist.kind == "likes" {
Span::styled("", theme::accent())
Span::styled("", theme::accent_for(state))
} else {
Span::raw(" ")
};
let mut flags = Vec::new();
if !playlist.is_own {
if let Some(owner) = &playlist.owner_name {
flags.push(format!("by {owner}"));
}
}
if playlist.is_public {
flags.push("public".to_string());
}
let suffix = if flags.is_empty() {
String::new()
} else {
format!(" {}", flags.join(" · "))
};
frame.render_widget(
Paragraph::new(Line::from(vec![
marker,
Span::raw(playlist.title.clone()),
Span::styled(suffix, theme::dim()),
])),
Paragraph::new(Line::from(vec![marker, Span::raw(playlist.title.clone())])),
row,
);
frame.render_widget(
@@ -109,7 +95,9 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
row,
);
if index == selected {
frame.buffer_mut().set_style(row, theme::tab_active());
frame
.buffer_mut()
.set_style(row, theme::tab_active_for(state));
}
}
}
@@ -117,10 +105,17 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
let loadable = state.playlist_views.get(&id);
let title = match loadable {
Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title),
_ => " Playlists ▸ ".to_string(),
Some(Loadable::Ready(detail)) => format!(
" Playlists ▸ {} · Mode: {} ",
detail.title,
state.global.filters.source_mode.label()
),
_ => format!(
" Playlists ▸ … · Mode: {} ",
state.global.filters.source_mode.label()
),
};
let inner = bordered(frame, area, title);
let inner = bordered(frame, area, state, title);
if let Some(Loadable::Failed(error)) = loadable {
return centered_line(
@@ -130,7 +125,7 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
);
}
let Some(tracks) = playlist_tracks(state, id) else {
return centered_line(frame, inner, Line::styled("loading…", theme::dim()));
return centered_line(frame, inner, loading_line(state, "loading…"));
};
if tracks.is_empty() {
return centered_line(
@@ -151,13 +146,17 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
width: inner.width,
height: 1,
};
track_row(
track_row_with_like_marker(
frame,
row,
state,
track,
(index + 1).to_string(),
index == cursor,
state
.track_selection
.contains(&TrackSelectionScope::Playlist(id), index),
id != LIKES_PLAYLIST_ID,
);
}
}
+1385 -103
View File
File diff suppressed because it is too large Load Diff
+61 -4
View File
@@ -1,23 +1,80 @@
use ratatui::style::{Color, Modifier, Style};
use crate::app::state::{AppState, DevicePlaybackRole};
pub const ACCENT: Color = Color::Cyan;
pub const CONTROL_ACCENT: Color = Color::Yellow;
pub const DIM: Color = Color::DarkGray;
pub fn accent() -> Style {
Style::new().fg(ACCENT)
}
pub fn accent_for(state: &AppState) -> Style {
Style::new().fg(accent_color_for(state))
}
pub fn dim() -> Style {
Style::new().fg(DIM)
}
pub fn tab_active() -> Style {
pub fn tab_active_for(state: &AppState) -> Style {
Style::new()
.fg(Color::Black)
.bg(ACCENT)
.bg(accent_color_for(state))
.add_modifier(Modifier::BOLD)
}
pub fn header() -> Style {
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
pub fn danger_button() -> Style {
Style::new()
.fg(Color::White)
.bg(Color::Rgb(96, 0, 24))
.add_modifier(Modifier::BOLD)
}
pub fn selection() -> Style {
Style::new().fg(Color::White).bg(Color::Rgb(24, 68, 72))
}
pub fn selection_for(state: &AppState) -> Style {
if state.device_playback.is_control() {
Style::new().fg(Color::White).bg(Color::Rgb(92, 72, 0))
} else {
selection()
}
}
pub fn header_for(state: &AppState) -> Style {
accent_for(state).add_modifier(Modifier::BOLD)
}
pub fn border_for(state: &AppState) -> Style {
if state.device_playback.is_control() {
Style::new().fg(CONTROL_ACCENT)
} else {
dim()
}
}
pub fn strong_border_for(state: &AppState) -> Style {
accent_for(state)
}
pub fn role_pill(role: DevicePlaybackRole) -> Style {
let bg = match role {
DevicePlaybackRole::Active => Color::Green,
DevicePlaybackRole::Control => CONTROL_ACCENT,
};
Style::new()
.fg(Color::Black)
.bg(bg)
.add_modifier(Modifier::BOLD)
}
fn accent_color_for(state: &AppState) -> Color {
if state.device_playback.is_control() {
CONTROL_ACCENT
} else {
ACCENT
}
}
+393
View File
@@ -0,0 +1,393 @@
// name: Pulsing sphere
// protocol: furumi-visualizer-v2
// bundle-version: 5
//
// A second bundled example with a very different structure from
// scope_spectrum.rhai: instead of a waveform-first visual, it renders a
// hollow terminal "sphere" whose radius, halo and palette respond to audio.
//
// Script entry point:
// fn render(input) -> Array<Map>
//
// Input map fields supplied by Furumi:
// width, height terminal size in cells
// time, position, progress animation time and playback progress
// volume, paused player state
// energy, bass, mid, treble smoothed audio bands in the 0.0..1.0 range
// beat transient pulse estimate in the 0.0..1.0 range
// seed stable per-track number in the 0.0..1.0 range
// samples recent mono waveform samples in the -1.0..1.0 range
// show_clock, clock user setting and formatted clock text
// track_title, track_artist metadata strings
//
// Useful numeric helpers available to scripts:
// to_int, to_float Rhai conversions
// sin, cos, tan, sqrt, abs, pow
// Scripts cannot import modules; keep every visualization self-contained.
//
// Draw command maps returned from render(input):
// #{ op: "clear", bg: 0x000000 }
// #{ op: "cell", x: 10, y: 4, ch: "*", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "hline", x: 0, y: 4, w: 20, ch: "-", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "vline", x: 10, y: 0, h: 12, ch: "|", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "rect", x: 10, y: 6, w: 2, h: 4, ch: "#", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "trace", x: 0, ys: [4, 5, 3], ch: "*", line_ch: "|", fg: 0xffffff, line_fg: 0x558888, bg: 0x000000 }
// #{ op: "text", x: 2, y: 1, text: "hello", fg: 0xffffff, bg: 0x000000 }
//
// This script intentionally uses every command type. The sphere itself stays
// hollow: trace commands draw its spectral outline and orbit, hline draws
// latitudes, rect/vline draw the small energy meter, cells create stars and
// burst nodes, and labels use text.
fn clamp(value, low, high) {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
fn min(left, right) {
if left < right { left } else { right }
}
fn max(left, right) {
if left > right { left } else { right }
}
fn rgb(r, g, b) {
let rr = to_int(clamp(r, 0, 255));
let gg = to_int(clamp(g, 0, 255));
let bb = to_int(clamp(b, 0, 255));
rr * 65536 + gg * 256 + bb
}
fn lerp(left, right, mix) {
left * (1.0 - mix) + right * mix
}
fn sample_at(samples, nx) {
let len = samples.len();
if len <= 0 {
0.0
} else {
let pos = clamp(nx, 0.0, 1.0) * (len - 1);
let left = to_int(pos);
let right = if left + 1 < len { left + 1 } else { left };
let mix = pos - to_float(left);
lerp(samples[left], samples[right], mix)
}
}
fn cell(x, y, ch, fg, bg) {
#{ op: "cell", x: x, y: y, ch: ch, fg: fg, bg: bg }
}
fn hline(x, y, w, ch, fg, bg) {
#{ op: "hline", x: x, y: y, w: w, ch: ch, fg: fg, bg: bg }
}
fn vline(x, y, h, ch, fg, bg) {
#{ op: "vline", x: x, y: y, h: h, ch: ch, fg: fg, bg: bg }
}
fn rect(x, y, w, h, ch, fg, bg) {
#{ op: "rect", x: x, y: y, w: w, h: h, ch: ch, fg: fg, bg: bg }
}
fn trace(x, ys, ch, line_ch, fg, line_fg, bg) {
#{ op: "trace", x: x, ys: ys, ch: ch, line_ch: line_ch, fg: fg, line_fg: line_fg, bg: bg }
}
fn text(x, y, value, fg, bg) {
#{ op: "text", x: x, y: y, text: value, fg: fg, bg: bg }
}
// A time-varying RGB palette. "shade" is usually 0.0..1.0; larger values are
// allowed and clamped by rgb(). Audio bands shift the hue without Rust knowing
// anything about the visual.
fn palette(input, shade, phase) {
let t = input.time + input.seed * 8.0 + phase;
let bass_push = input.bass * 70.0;
let treble_push = input.treble * 65.0;
rgb(
50 + shade * 130 + sin(t * 0.90) * 55 + bass_push,
70 + shade * 115 + sin(t * 0.63 + 2.1) * 55 + input.mid * 55,
95 + shade * 145 + cos(t * 0.72 + 0.7) * 55 + treble_push
)
}
fn wrap01(value) {
let out = value;
while out < 0.0 {
out += 1.0;
}
while out > 1.0 {
out -= 1.0;
}
out
}
fn spectral_band(input, phase) {
if phase < 0.34 {
input.bass
} else if phase < 0.68 {
input.mid
} else {
input.treble
}
}
// Treat the waveform as if it was wrapped around the sphere. The returned
// value is a radial spike amount: low frequencies push broad parts of the
// contour, raw samples add sharp teeth, and beat makes the edge jump outward.
fn edge_spectrum(input, phase) {
let p = wrap01(phase);
let wave = abs(sample_at(input.samples, p));
let neighbor = abs(sample_at(input.samples, wrap01(p + 0.021)));
let band = spectral_band(input, p);
let flutter = abs(sin(input.time * (5.0 + p * 7.0) + p * 38.0 + input.seed * 9.0));
let fine = abs(cos(input.time * (8.0 + p * 4.0) - p * 71.0));
clamp(
wave * (0.62 + input.energy * 0.36)
+ neighbor * 0.24
+ band * 0.46
+ input.beat * (0.30 + flutter * 0.64)
+ fine * input.energy * 0.18,
0.0,
1.55
)
}
fn push_starfield(cmds, input, bg) {
let stars = 34;
let index = 0;
while index < stars {
let x = (index * 37 + to_int(input.seed * 1000.0)) % max(input.width, 1);
let y = (index * 17 + to_int(input.time * 2.0)) % max(input.height, 1);
let speed = 0.7 + to_float(index % 5) * 0.15;
let twinkle = abs(sin(input.time * speed + to_float(index)));
let fg = rgb(
45 + twinkle * 120 + input.treble * 80,
75 + twinkle * 120,
105 + twinkle * 130
);
let ch = if twinkle + input.beat > 1.25 { "*" } else { "." };
cmds.push(cell(x, y, ch, fg, bg));
index += 1;
}
cmds
}
fn push_orbit(cmds, input, cx, cy, rx, ry, bg) {
let width = max(rx * 2 + 9, 3);
let start_x = max(cx - width / 2, 0);
let max_w = input.width - start_x;
let actual_w = min(width, max_w);
let back = [];
let front = [];
let i = 0;
while i < actual_w {
let nx = to_float(i) / to_float(max(actual_w - 1, 1));
let angle = nx * 6.28318 + input.time * 0.85;
let sample = sample_at(input.samples, nx);
let wobble = (sin(angle + input.bass * 2.0) + sample * 0.28)
* to_float(max(ry, 1))
* (0.28 + input.energy * 0.10);
let y_front = to_int(clamp(
to_float(cy) + wobble + input.beat * 1.5,
0.0,
to_float(input.height - 1)
));
let y_back = to_int(clamp(
to_float(cy) - wobble - input.beat * 1.5,
0.0,
to_float(input.height - 1)
));
front.push(y_front);
back.push(y_back);
i += 1;
}
let back_fg = palette(input, 0.18, 3.4);
let front_fg = palette(input, 0.65 + input.beat * 0.25, 0.5);
cmds.push(trace(start_x, back, ".", ".", back_fg, back_fg, bg));
cmds.push(trace(start_x, front, "*", ".", front_fg, front_fg, bg));
cmds
}
fn push_pulse_ring(cmds, input, cx, cy, rx, ry, bg) {
let ring_rx = rx + 2 + to_int(input.beat * 5.0 + input.energy * 2.0);
let ring_ry = ry + 1 + to_int(input.beat * 3.0 + input.bass * 2.0);
let left = max(cx - ring_rx, 0);
let right = min(cx + ring_rx, input.width - 1);
let width = right - left + 1;
if width <= 2 {
return cmds;
}
let top = [];
let bottom = [];
let i = 0;
while i < width {
let nx = if width > 1 { to_float(i) / to_float(width - 1) * 2.0 - 1.0 } else { 0.0 };
let phase = to_float(i) / to_float(max(width - 1, 1));
let inside = max(1.0 - nx * nx, 0.0);
let spike = edge_spectrum(input, phase) * (1.0 + input.energy * 3.0 + input.beat * 2.0);
let y = sqrt(inside) * (to_float(ring_ry) + spike);
top.push(to_int(clamp(to_float(cy) - y, 0.0, to_float(input.height - 1))));
bottom.push(to_int(clamp(to_float(cy) + y, 0.0, to_float(input.height - 1))));
i += 1;
}
let color = palette(input, 0.14 + input.beat * 0.35, 4.8);
cmds.push(trace(left, top, ".", ".", color, color, bg));
cmds.push(trace(left, bottom, ".", ".", color, color, bg));
cmds
}
fn push_sphere(cmds, input, cx, cy, rx, ry, bg) {
let left = max(cx - rx, 0);
let right = min(cx + rx, input.width - 1);
let width = right - left + 1;
if width <= 2 {
return cmds;
}
let top = [];
let bottom = [];
let glow_top = [];
let glow_bottom = [];
let i = 0;
while i < width {
let nx = if width > 1 { to_float(i) / to_float(width - 1) * 2.0 - 1.0 } else { 0.0 };
let inside = max(1.0 - nx * nx, 0.0);
let phase = to_float(i) / to_float(max(width - 1, 1));
let top_spike = edge_spectrum(input, phase) * (1.0 + input.energy * 3.4 + input.beat * 3.0);
let bottom_spike = edge_spectrum(input, 1.0 - phase) * (1.0 + input.energy * 3.4 + input.beat * 3.0);
let edge = sqrt(inside) * to_float(ry);
let top_y = to_int(clamp(to_float(cy) - edge - top_spike, 0.0, to_float(input.height - 1)));
let bottom_y = to_int(clamp(to_float(cy) + edge + bottom_spike, 0.0, to_float(input.height - 1)));
let glow = 1 + to_int(max(top_spike, bottom_spike) * 0.35 + input.beat * 2.0);
top.push(top_y);
bottom.push(bottom_y);
glow_top.push(clamp(top_y - glow, 0, input.height - 1));
glow_bottom.push(clamp(bottom_y + glow, 0, input.height - 1));
i += 1;
}
let glow = palette(input, 0.22 + input.energy * 0.22, 3.1);
let outline = palette(input, 0.82 + input.beat * 0.45, 0.3);
let bridge = palette(input, 0.42 + input.mid * 0.25, 2.2);
cmds.push(trace(left, glow_top, ".", ".", glow, glow, bg));
cmds.push(trace(left, glow_bottom, ".", ".", glow, glow, bg));
cmds.push(trace(left, top, "*", ".", outline, bridge, bg));
cmds.push(trace(left, bottom, "*", ".", outline, bridge, bg));
let equator = palette(input, 0.50 + input.bass * 0.25, 5.6);
cmds.push(hline(left, cy, width, "-", equator, bg));
if ry > 4 {
let latitude = palette(input, 0.35 + input.treble * 0.20, 1.8);
let span = max(width - width / 3, 3);
let lat_x = left + (width - span) / 2;
let lat_y = max(cy - ry / 2, 0);
cmds.push(hline(lat_x, lat_y, span, ".", latitude, bg));
cmds.push(hline(lat_x, min(cy + ry / 2, input.height - 1), span, ".", latitude, bg));
}
let nodes = 42;
let node = 0;
while node < nodes {
let phase = to_float(node) / to_float(nodes);
let angle = phase * 6.28318 + input.time * (0.10 + input.treble * 0.08);
let spectrum = edge_spectrum(input, phase);
let burst = spectrum * (1.4 + input.energy * 4.2 + input.beat * 3.5);
let x = to_int(clamp(to_float(cx) + cos(angle) * (to_float(rx) + burst), 0.0, to_float(input.width - 1)));
let y = to_int(clamp(to_float(cy) + sin(angle) * (to_float(ry) + burst * 0.58), 0.0, to_float(input.height - 1)));
let fg = palette(input, 0.48 + spectrum * 0.36 + input.beat * 0.30, angle);
let ch = if spectrum > 1.05 { "*" } else if spectrum > 0.72 { "+" } else { "." };
cmds.push(cell(x, y, ch, fg, bg));
if spectrum > 1.18 {
let spike_x = to_int(clamp(to_float(cx) + cos(angle) * (to_float(rx) + burst + 1.5), 0.0, to_float(input.width - 1)));
let spike_y = to_int(clamp(to_float(cy) + sin(angle) * (to_float(ry) + (burst + 1.5) * 0.58), 0.0, to_float(input.height - 1)));
cmds.push(cell(spike_x, spike_y, ".", fg, bg));
}
node += 1;
}
cmds
}
fn push_meter(cmds, input, bg) {
if input.height < 5 || input.width < 20 {
return cmds;
}
let y = input.height - 2;
let w = min(input.width - 4, 48);
let x = 2;
let base = rgb(18, 34, 42);
let fill = palette(input, 0.65 + input.energy * 0.35, 5.2);
let filled = to_int(clamp(input.energy * to_float(w), 1.0, to_float(w)));
cmds.push(hline(x, y, w, "-", base, bg));
cmds.push(rect(x, y, filled, 1, " ", fill, fill));
let progress = to_int(clamp(input.progress * to_float(w), 0.0, to_float(w)));
if progress > 0 {
cmds.push(hline(x, y - 1, progress, ".", fill, bg));
cmds.push(vline(min(x + progress, x + w - 1), y - 1, 2, "|", fill, bg));
}
cmds
}
fn push_labels(cmds, input, bg) {
let fg = rgb(180 + input.treble * 60, 230, 245);
let dim = rgb(70, 120 + input.energy * 80, 130 + input.energy * 80);
if input.show_clock {
cmds.push(text(1, 0, " " + input.clock + " ", fg, rgb(2, 18, 24)));
}
if input.track_title != "" && input.height > 4 {
cmds.push(text(2, input.height - 1, input.track_title, fg, bg));
}
if input.track_artist != "" && input.height > 5 {
cmds.push(text(2, input.height - 3, input.track_artist, dim, bg));
}
if input.paused {
cmds.push(text(2, 1, "paused", dim, bg));
}
cmds
}
fn render(input) {
let cmds = [];
let bg = rgb(1, 4, 10);
cmds.push(#{ op: "clear", bg: bg });
if input.width <= 8 || input.height <= 6 {
return cmds;
}
cmds = push_starfield(cmds, input, bg);
let cx = input.width / 2;
let cy = input.height / 2;
let max_ry = max((input.height - 6) / 2, 2);
let max_rx = max(input.width / 3, 4);
let base = min(to_float(max_ry), to_float(max_rx) / 2.0) * 0.78;
let activity = clamp(input.energy * 0.72 + input.bass * 0.50 + input.beat * 1.00, 0.0, 1.18);
let pulse = 0.34
+ activity * 0.58
+ input.volume * 0.04
+ input.beat * 0.18
+ abs(sin(input.time * 2.8 + input.position * 0.07)) * (0.03 + input.energy * 0.07);
let ry = max(to_int(base * pulse), 2);
let rx = max(to_int(to_float(ry) * (1.85 + input.treble * 0.28)), 4);
cmds = push_pulse_ring(cmds, input, cx, cy, rx, ry, bg);
cmds = push_orbit(cmds, input, cx, cy, rx, ry, bg);
cmds = push_sphere(cmds, input, cx, cy, rx, ry, bg);
cmds = push_meter(cmds, input, bg);
cmds = push_labels(cmds, input, bg);
cmds
}
+274
View File
@@ -0,0 +1,274 @@
// name: Scope spectrum
// protocol: furumi-visualizer-v2
// bundle-version: 5
//
// This bundled script is both a preset and an API example. It demonstrates
// every drawing command currently understood by Furumi's visualizer runtime:
// clear, cell, hline, vline, rect, trace and text.
//
// Script entry point:
// fn render(input) -> Array<Map>
//
// Important input fields:
// width, height terminal size in cells
// time, position, progress animation time and playback progress
// volume, paused player state
// energy, bass, mid, treble smoothed audio bands in the 0.0..1.0 range
// beat transient pulse estimate in the 0.0..1.0 range
// samples recent mono waveform samples in the -1.0..1.0 range
// show_clock, clock user setting and formatted clock text
// track_title, track_artist metadata for optional labels
//
// Useful numeric helpers available to scripts:
// to_int, to_float Rhai conversions
// sin, cos, tan, sqrt, abs, pow
// Scripts cannot import modules; keep every visualization self-contained.
//
// Return value: an array of command maps. Colors are 0xRRGGBB integers.
// #{ op: "clear", bg: 0x000000 }
// #{ op: "cell", x: 10, y: 4, ch: "*", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "hline", x: 0, y: 4, w: 20, ch: "-", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "vline", x: 10, y: 0, h: 12, ch: "|", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "rect", x: 10, y: 6, w: 2, h: 4, ch: "#", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "trace", x: 0, ys: [4, 5, 3], ch: "*", line_ch: "|", fg: 0xffffff, line_fg: 0x558888, bg: 0x000000 }
// #{ op: "text", x: 2, y: 1, text: "hello", fg: 0xffffff, bg: 0x000000 }
//
// Performance tip: prefer hline/vline/rect/trace for large shapes and reserve
// cell for isolated details. Crossing the Rhai -> Rust boundary once per cell
// is much slower than returning one bulk command.
fn clamp(value, low, high) {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
fn min(left, right) {
if left < right { left } else { right }
}
fn rgb(r, g, b) {
let rr = to_int(clamp(r, 0, 255));
let gg = to_int(clamp(g, 0, 255));
let bb = to_int(clamp(b, 0, 255));
rr * 65536 + gg * 256 + bb
}
fn lerp(left, right, mix) {
left * (1.0 - mix) + right * mix
}
fn sample_at(samples, nx) {
let len = samples.len();
if len <= 0 {
0.0
} else {
let pos = clamp(nx, 0.0, 1.0) * (len - 1);
let left = to_int(pos);
let right = if left + 1 < len { left + 1 } else { left };
let mix = pos - to_float(left);
lerp(samples[left], samples[right], mix)
}
}
fn cell(x, y, ch, fg, bg) {
#{ op: "cell", x: x, y: y, ch: ch, fg: fg, bg: bg }
}
fn hline(x, y, w, ch, fg, bg) {
#{ op: "hline", x: x, y: y, w: w, ch: ch, fg: fg, bg: bg }
}
fn vline(x, y, h, ch, fg, bg) {
#{ op: "vline", x: x, y: y, h: h, ch: ch, fg: fg, bg: bg }
}
fn rect(x, y, w, h, ch, fg, bg) {
#{ op: "rect", x: x, y: y, w: w, h: h, ch: ch, fg: fg, bg: bg }
}
fn trace(x, ys, ch, line_ch, fg, line_fg, bg) {
#{ op: "trace", x: x, ys: ys, ch: ch, line_ch: line_ch, fg: fg, line_fg: line_fg, bg: bg }
}
fn text(x, y, value, fg, bg) {
#{ op: "text", x: x, y: y, text: value, fg: fg, bg: bg }
}
fn draw_grid(width, height, progress, energy, bg) {
let cmds = [];
let grid = rgb(22, 48 + energy * 30, 54 + energy * 35);
let center = height / 2;
let x = 0;
while x < width {
cmds.push(vline(x, 0, height, ".", grid, bg));
x += 12;
}
let y = 0;
while y < height {
cmds.push(hline(0, y, width, ".", grid, bg));
y += 4;
}
cmds.push(hline(0, center, width, "-", grid, bg));
let cross_x = 0;
while cross_x < width {
let cross_y = 0;
while cross_y < height {
cmds.push(cell(cross_x, cross_y, "+", grid, bg));
cross_y += 4;
}
cmds.push(cell(cross_x, center, "+", grid, bg));
cross_x += 12;
}
let scan_x = to_int(progress * to_float(width - 1));
let scan = rgb(25, 120 + energy * 80, 130 + energy * 80);
cmds.push(vline(scan_x, 0, height, "|", scan, bg));
cmds
}
fn draw_scope(input, scope_height, bg) {
let cmds = [];
let width = input.width;
if width <= 0 || scope_height <= 0 {
return cmds;
}
let center = to_float(scope_height - 1) / 2.0;
let half = center;
let fg = rgb(55 + input.beat * 80, 230, 210 + input.treble * 45);
let bridge = rgb(20, 120 + input.energy * 70, 115 + input.energy * 60);
let amplitude = 0.74 + input.energy * 0.55 + input.beat * 0.10;
let ys = [];
let x = 0;
while x < width {
let nx = if width > 1 { to_float(x) / to_float(width - 1) } else { 0.0 };
let sample = sample_at(input.samples, nx) * amplitude;
let y = to_int(clamp(center - sample * half, 0.0, to_float(scope_height - 1)));
ys.push(y);
x += 1;
}
cmds.push(trace(0, ys, "*", "|", fg, bridge, bg));
cmds
}
fn spectrum_value(freq, input) {
let band = if freq < 0.28 {
input.bass
} else if freq < 0.68 {
input.mid
} else {
input.treble
};
let wobble = abs(sin(input.time * (1.7 + freq * 5.0) + input.seed * 17.0 + freq * 31.0));
let harmonic = abs(cos(input.time * 0.63 - freq * 23.0));
clamp(0.06 + band * (0.30 + wobble * 0.48 + harmonic * 0.18) + input.beat * (1.0 - freq) * 0.35, 0.0, 1.0)
}
fn draw_bars(input, top, height, bg) {
let cmds = [];
if height <= 0 {
return cmds;
}
let width = input.width;
let bar_width = if width >= 80 { 2 } else { 1 };
let bars = width / bar_width;
let bar = 0;
while bar < bars {
let freq = to_float(bar) / to_float(bars);
let value = spectrum_value(freq, input);
let bar_height = to_int(clamp(value * to_float(height), 1.0, to_float(height)));
let fg = rgb(60 + (1.0 - freq) * 80, 160 + value * 95, 210 - freq * 110);
let x = bar * bar_width;
let y = top + height - bar_height;
let w = if bar_width > 1 && x + 1 < width { 2 } else { 1 };
if bar_height > 0 {
cmds.push(rect(x, y, w, bar_height, "#", fg, bg));
}
bar += 1;
}
cmds
}
fn append_all(cmds, more) {
for cmd in more {
cmds.push(cmd);
}
cmds
}
fn track_label(input) {
if input.track_artist != "" && input.track_title != "" {
input.track_artist + " - " + input.track_title
} else if input.track_title != "" {
input.track_title
} else {
input.track_artist
}
}
fn draw_track_badge(input, bg) {
let cmds = [];
let label = track_label(input);
if label == "" || input.width <= 8 || input.height <= 4 {
return cmds;
}
let max_text = input.width - 6;
let value = if label.len() > max_text {
label.sub_string(0, max_text)
} else {
label
};
let badge_width = min(value.len() + 4, input.width);
let x = (input.width - badge_width) / 2;
let y = input.height - 1;
let badge_bg = rgb(0, 30 + input.energy * 36, 38 + input.beat * 42);
let badge_fg = rgb(190 + input.treble * 45, 255, 235);
cmds.push(rect(x, y, badge_width, 1, " ", badge_fg, badge_bg));
cmds.push(text(x + 2, y, value, badge_fg, badge_bg));
cmds
}
fn render(input) {
let cmds = [];
let bg = rgb(0, 0, 0);
cmds.push(#{ op: "clear", bg: bg });
if input.width <= 0 || input.height <= 2 {
return cmds;
}
let content_height = input.height - 2;
let bars_height = if content_height > 12 { 8 } else { content_height / 3 };
let separator = if bars_height > 0 { 1 } else { 0 };
let scope_height = content_height - bars_height - separator;
cmds = append_all(cmds, draw_grid(input.width, scope_height, input.progress, input.energy, bg));
cmds = append_all(cmds, draw_scope(input, scope_height, bg));
if bars_height > 0 {
let sep_y = scope_height;
let sep = rgb(20, 68, 74);
cmds.push(hline(0, sep_y, input.width, "-", sep, bg));
cmds = append_all(cmds, draw_bars(input, sep_y + separator, bars_height, bg));
}
if input.show_clock {
let clock_bg = rgb(0, 26, 30);
let clock_fg = rgb(180, 255, 245);
cmds.push(text(1, 0, " " + input.clock + " ", clock_fg, clock_bg));
}
cmds = append_all(cmds, draw_track_badge(input, bg));
cmds
}
+994
View File
@@ -0,0 +1,994 @@
//! Script-backed fullscreen visualizers.
//!
//! Rust owns the audio tap, script loading, sandboxed execution limits and
//! terminal drawing primitives. Visual math lives in `.rhai` files: every
//! script receives the same input map and returns the same list of draw
//! commands.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Instant, SystemTime};
use anyhow::{Context as _, Result};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Clear, Paragraph};
use rhai::{AST, Array, Dynamic, Engine, FLOAT, INT, Map, Scope};
use serde::{Deserialize, Serialize};
use crate::app::state::AppState;
const PRIMARY_SCRIPT_ID: &str = "scope_spectrum";
const BUNDLE_VERSION: u32 = 5;
struct BundledScript {
id: &'static str,
file_name: &'static str,
display_name: &'static str,
source: &'static str,
}
const BUNDLED_SCRIPTS: &[BundledScript] = &[
BundledScript {
id: PRIMARY_SCRIPT_ID,
file_name: "scope_spectrum.rhai",
display_name: "Scope spectrum",
source: include_str!("visualizations/scope_spectrum.rhai"),
},
BundledScript {
id: "pulsing_sphere",
file_name: "pulsing_sphere.rhai",
display_name: "Pulsing sphere",
source: include_str!("visualizations/pulsing_sphere.rhai"),
},
];
#[derive(Debug, Clone)]
pub struct VisualizerScript {
pub id: String,
pub name: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisualizerConfig {
#[serde(default = "default_script_id")]
pub selected: String,
#[serde(default)]
pub show_clock: bool,
}
impl Default for VisualizerConfig {
fn default() -> Self {
Self {
selected: default_script_id(),
show_clock: false,
}
}
}
pub struct VisualizerState {
pub active: bool,
pub started_at: Option<Instant>,
pub config: VisualizerConfig,
pub scripts: Vec<VisualizerScript>,
pub last_error: Option<String>,
runtime: Mutex<ScriptRuntime>,
}
impl std::fmt::Debug for VisualizerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VisualizerState")
.field("active", &self.active)
.field("started_at", &self.started_at)
.field("config", &self.config)
.field("scripts", &self.scripts)
.field("last_error", &self.last_error)
.finish_non_exhaustive()
}
}
impl Default for VisualizerState {
fn default() -> Self {
Self {
active: false,
started_at: None,
config: VisualizerConfig::default(),
scripts: Vec::new(),
last_error: None,
runtime: Mutex::new(ScriptRuntime::default()),
}
}
}
impl VisualizerState {
pub fn open(&mut self) {
self.active = true;
self.started_at = Some(Instant::now());
}
pub fn close(&mut self) {
self.active = false;
self.started_at = None;
}
pub fn load_library(&mut self) -> Result<()> {
ensure_bundled_scripts()?;
self.config = load_config().unwrap_or_else(|err| {
tracing::warn!(%err, "visualization settings failed to load; using defaults");
VisualizerConfig::default()
});
self.refresh_scripts()?;
self.ensure_selected_script();
self.save_config()?;
Ok(())
}
pub fn refresh_scripts(&mut self) -> Result<()> {
let dir = visualizations_dir()?;
let mut scripts = Vec::new();
for entry in
std::fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("rhai") {
continue;
}
let Some(id) = path.file_stem().and_then(|value| value.to_str()) else {
continue;
};
scripts.push(VisualizerScript {
id: id.to_string(),
name: script_name(&path, id),
path: path.clone(),
});
}
scripts.sort_by(|left, right| {
bundled_order(&left.id)
.unwrap_or(usize::MAX)
.cmp(&bundled_order(&right.id).unwrap_or(usize::MAX))
.then_with(|| left.name.cmp(&right.name))
});
self.scripts = scripts;
self.ensure_selected_script();
Ok(())
}
pub fn select_script(&mut self, index: usize) -> Result<()> {
let Some(script) = self.scripts.get(index) else {
anyhow::bail!("visualization script not found");
};
self.config.selected = script.id.clone();
self.save_config()
}
pub fn toggle_clock(&mut self) -> Result<()> {
self.config.show_clock = !self.config.show_clock;
self.save_config()
}
pub fn selected_script(&self) -> Option<&VisualizerScript> {
self.scripts
.iter()
.find(|script| script.id == self.config.selected)
.or_else(|| self.scripts.first())
}
pub fn selected_script_index(&self) -> Option<usize> {
self.scripts
.iter()
.position(|script| script.id == self.config.selected)
}
pub fn selected_script_path(&self) -> Option<PathBuf> {
self.selected_script().map(|script| script.path.clone())
}
pub fn create_script(&mut self) -> Result<PathBuf> {
ensure_bundled_scripts()?;
let dir = visualizations_dir()?;
let mut number = 1;
let path = loop {
let candidate = dir.join(format!("custom_{number}.rhai"));
if !candidate.exists() {
break candidate;
}
number += 1;
};
let starter = primary_bundled_script();
let content = starter.source.replacen(
"// name: Scope spectrum",
&format!("// name: Custom {number}"),
1,
);
std::fs::write(&path, content).with_context(|| format!("creating {}", path.display()))?;
self.refresh_scripts()?;
let id = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or(PRIMARY_SCRIPT_ID)
.to_string();
self.config.selected = id;
self.save_config()?;
Ok(path)
}
pub fn save_config(&self) -> Result<()> {
let path = visualizer_config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, toml::to_string_pretty(&self.config)?)
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
fn ensure_selected_script(&mut self) {
if self
.scripts
.iter()
.any(|script| script.id == self.config.selected)
{
return;
}
self.config.selected = self
.scripts
.first()
.map(|script| script.id.clone())
.unwrap_or_else(default_script_id);
}
}
#[derive(Debug, Clone, Default)]
pub struct AudioFeatures {
pub elapsed_secs: f64,
pub position_secs: f64,
pub progress: f64,
pub volume: f64,
pub paused: bool,
pub energy: f64,
pub bass: f64,
pub mid: f64,
pub treble: f64,
pub beat: f64,
pub seed: f64,
pub scope: Vec<f64>,
}
pub fn draw(frame: &mut Frame, state: &AppState) {
let area = frame.area();
let Some(script) = state.visualizer.selected_script() else {
frame.render_widget(Clear, area);
draw_message(frame, area, "no visualization scripts found");
return;
};
let input = input_map(area, state);
let commands = {
let mut runtime = state
.visualizer
.runtime
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
runtime.render(&script.path, input)
};
match commands {
Ok(commands) => render_commands(frame, area, &commands),
Err(err) => {
frame.render_widget(Clear, area);
draw_message(frame, area, &format!("visualizer error: {err}"));
}
}
}
fn input_map(area: Rect, state: &AppState) -> Map {
let features = features_from_state(state);
let mut input = Map::new();
insert_int(&mut input, "width", i64::from(area.width));
insert_int(&mut input, "height", i64::from(area.height));
insert_float(&mut input, "time", features.elapsed_secs);
insert_float(&mut input, "position", features.position_secs);
insert_float(&mut input, "progress", features.progress);
insert_float(&mut input, "volume", features.volume);
input.insert("paused".into(), features.paused.into());
insert_float(&mut input, "energy", features.energy);
insert_float(&mut input, "bass", features.bass);
insert_float(&mut input, "mid", features.mid);
insert_float(&mut input, "treble", features.treble);
insert_float(&mut input, "beat", features.beat);
insert_float(&mut input, "seed", features.seed);
insert_int(
&mut input,
"analysis_sequence",
state.player.audio_analysis.sequence as i64,
);
input.insert(
"samples".into(),
features
.scope
.into_iter()
.map(|sample| Dynamic::from_float(sample as FLOAT))
.collect::<Array>()
.into(),
);
input.insert(
"show_clock".into(),
state.visualizer.config.show_clock.into(),
);
input.insert("clock".into(), clock_label().into());
let (title, artist) = state
.player
.current
.as_ref()
.map(|track| (track.title.clone(), track.artist_line()))
.unwrap_or_else(|| ("".to_string(), "".to_string()));
input.insert("track_title".into(), title.into());
input.insert("track_artist".into(), artist.into());
input
}
fn features_from_state(state: &AppState) -> AudioFeatures {
let elapsed_secs = state
.visualizer
.started_at
.map(|started| started.elapsed().as_secs_f64())
.unwrap_or_default();
let position_secs = state.player.position_secs;
let duration_secs = state
.player
.current
.as_ref()
.map(|track| track.duration_seconds.max(0.0))
.unwrap_or_default();
let progress = if duration_secs > 0.0 {
(position_secs / duration_secs).clamp(0.0, 1.0)
} else {
0.0
};
let seed = state
.player
.current
.as_ref()
.map(track_seed)
.unwrap_or(0.17);
let analysis = &state.player.audio_analysis;
let volume = f64::from(state.player.volume.min(100)) / 100.0;
let output_scale = if state.player.volume == 0 {
0.0
} else {
0.35 + volume * 0.65
};
let active_scale = if state.player.paused { 0.12 } else { 1.0 };
let scale = output_scale * active_scale;
AudioFeatures {
elapsed_secs,
position_secs,
progress,
volume,
paused: state.player.paused,
energy: (analysis.energy * scale).clamp(0.0, 1.0),
bass: (analysis.bass * scale).clamp(0.0, 1.0),
mid: (analysis.mid * scale).clamp(0.0, 1.0),
treble: (analysis.treble * scale).clamp(0.0, 1.0),
beat: if state.player.paused {
0.0
} else {
(analysis.beat * output_scale).clamp(0.0, 1.0)
},
seed,
scope: analysis
.scope
.iter()
.map(|sample| (sample * scale).clamp(-1.0, 1.0))
.collect(),
}
}
fn track_seed(track: &crate::library::models::TrackItem) -> f64 {
let mut hash = 0xcbf29ce484222325u64;
let artist_line = track.artist_line();
for byte in track
.title
.bytes()
.chain(track.release_title.bytes())
.chain(artist_line.bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
(hash % 10_000) as f64 / 10_000.0
}
#[derive(Debug)]
struct ScriptRuntime {
engine: Engine,
cached_path: Option<PathBuf>,
cached_modified: Option<SystemTime>,
ast: Option<AST>,
}
impl Default for ScriptRuntime {
fn default() -> Self {
let mut engine = Engine::new();
engine
.set_max_operations(2_000_000)
.set_max_call_levels(32)
.set_max_variables(512)
.set_max_functions(128)
.set_max_modules(0)
.set_max_expr_depths(64, 64)
.set_max_string_size(1_000_000)
.set_max_array_size(120_000)
.set_max_map_size(1_000_000);
engine.disable_symbol("import");
engine.disable_symbol("export");
engine.register_fn("sin", |value: FLOAT| value.sin());
engine.register_fn("cos", |value: FLOAT| value.cos());
engine.register_fn("tan", |value: FLOAT| value.tan());
engine.register_fn("sqrt", |value: FLOAT| value.sqrt());
engine.register_fn("abs", |value: FLOAT| value.abs());
engine.register_fn("pow", |value: FLOAT, power: FLOAT| value.powf(power));
Self {
engine,
cached_path: None,
cached_modified: None,
ast: None,
}
}
}
impl ScriptRuntime {
fn render(&mut self, path: &Path, input: Map) -> std::result::Result<Vec<DrawCommand>, String> {
self.load(path)?;
let Some(ast) = &self.ast else {
return Err("script did not compile".to_string());
};
let mut scope = Scope::new();
let output = self
.engine
.call_fn::<Array>(&mut scope, ast, "render", (input,))
.map_err(|err| err.to_string())?;
let mut commands = Vec::with_capacity(output.len());
for (index, value) in output.into_iter().enumerate() {
commands.push(
DrawCommand::from_dynamic(value)
.map_err(|err| format!("invalid draw command #{index}: {err}"))?,
);
}
Ok(commands)
}
fn load(&mut self, path: &Path) -> std::result::Result<(), String> {
let modified = std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok();
let cache_hit = self.cached_path.as_deref() == Some(path)
&& self.cached_modified == modified
&& self.ast.is_some();
if cache_hit {
return Ok(());
}
let source = std::fs::read_to_string(path).map_err(|err| format!("{err}"))?;
let ast = self
.engine
.compile(&source)
.map_err(|err| err.to_string())?;
self.cached_path = Some(path.to_path_buf());
self.cached_modified = modified;
self.ast = Some(ast);
Ok(())
}
}
#[derive(Debug, Clone)]
enum DrawCommand {
Clear {
bg: Color,
},
Cell {
x: u16,
y: u16,
ch: String,
fg: Color,
bg: Color,
},
HLine {
x: u16,
y: u16,
w: u16,
ch: String,
fg: Color,
bg: Color,
},
VLine {
x: u16,
y: u16,
h: u16,
ch: String,
fg: Color,
bg: Color,
},
Rect {
x: u16,
y: u16,
w: u16,
h: u16,
ch: String,
fg: Color,
bg: Color,
},
Trace {
x: u16,
ys: Vec<u16>,
ch: String,
line_ch: String,
fg: Color,
line_fg: Color,
bg: Color,
},
Text {
x: u16,
y: u16,
text: String,
fg: Color,
bg: Color,
},
}
impl DrawCommand {
fn from_dynamic(value: Dynamic) -> std::result::Result<Self, String> {
let Some(map) = value.try_cast::<Map>() else {
return Err("expected object map".to_string());
};
let op = map_string(&map, "op")
.or_else(|| map_string(&map, "type"))
.ok_or_else(|| "missing op".to_string())?;
let fg = map_color(&map, "fg").unwrap_or(Color::White);
let bg = map_color(&map, "bg").unwrap_or(Color::Black);
match op.as_str() {
"clear" => Ok(DrawCommand::Clear { bg }),
"cell" => Ok(DrawCommand::Cell {
x: map_u16(&map, "x").ok_or_else(|| "cell.x must be an integer".to_string())?,
y: map_u16(&map, "y").ok_or_else(|| "cell.y must be an integer".to_string())?,
ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()),
fg,
bg,
}),
"hline" => Ok(DrawCommand::HLine {
x: map_u16(&map, "x").ok_or_else(|| "hline.x must be an integer".to_string())?,
y: map_u16(&map, "y").ok_or_else(|| "hline.y must be an integer".to_string())?,
w: map_u16(&map, "w").ok_or_else(|| "hline.w must be an integer".to_string())?,
ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()),
fg,
bg,
}),
"vline" => Ok(DrawCommand::VLine {
x: map_u16(&map, "x").ok_or_else(|| "vline.x must be an integer".to_string())?,
y: map_u16(&map, "y").ok_or_else(|| "vline.y must be an integer".to_string())?,
h: map_u16(&map, "h").ok_or_else(|| "vline.h must be an integer".to_string())?,
ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()),
fg,
bg,
}),
"rect" => Ok(DrawCommand::Rect {
x: map_u16(&map, "x").ok_or_else(|| "rect.x must be an integer".to_string())?,
y: map_u16(&map, "y").ok_or_else(|| "rect.y must be an integer".to_string())?,
w: map_u16(&map, "w").ok_or_else(|| "rect.w must be an integer".to_string())?,
h: map_u16(&map, "h").ok_or_else(|| "rect.h must be an integer".to_string())?,
ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()),
fg,
bg,
}),
"trace" => Ok(DrawCommand::Trace {
x: map_u16(&map, "x").unwrap_or(0),
ys: map_u16_array(&map, "ys")
.ok_or_else(|| "trace.ys must be an integer array".to_string())?,
ch: map_string(&map, "ch").unwrap_or_else(|| "*".to_string()),
line_ch: map_string(&map, "line_ch").unwrap_or_else(|| "|".to_string()),
fg,
line_fg: map_color(&map, "line_fg").unwrap_or(fg),
bg,
}),
"text" => Ok(DrawCommand::Text {
x: map_u16(&map, "x").ok_or_else(|| "text.x must be an integer".to_string())?,
y: map_u16(&map, "y").ok_or_else(|| "text.y must be an integer".to_string())?,
text: map_string(&map, "text").unwrap_or_default(),
fg,
bg,
}),
_ => Err(format!("unknown op {op:?}")),
}
}
}
fn render_commands(frame: &mut Frame, area: Rect, commands: &[DrawCommand]) {
for command in commands {
match command {
DrawCommand::Clear { bg } => fill_rect(frame, area, " ", Style::new().bg(*bg)),
DrawCommand::Cell { x, y, ch, fg, bg } => {
write_cell(frame, area, *x, *y, ch, Style::new().fg(*fg).bg(*bg));
}
DrawCommand::HLine {
x,
y,
w,
ch,
fg,
bg,
} => {
let style = Style::new().fg(*fg).bg(*bg);
for offset in 0..*w {
write_cell(frame, area, x.saturating_add(offset), *y, ch, style);
}
}
DrawCommand::VLine {
x,
y,
h,
ch,
fg,
bg,
} => {
let style = Style::new().fg(*fg).bg(*bg);
for offset in 0..*h {
write_cell(frame, area, *x, y.saturating_add(offset), ch, style);
}
}
DrawCommand::Rect {
x,
y,
w,
h,
ch,
fg,
bg,
} => {
let style = Style::new().fg(*fg).bg(*bg);
for row in 0..*h {
for col in 0..*w {
write_cell(
frame,
area,
x.saturating_add(col),
y.saturating_add(row),
ch,
style,
);
}
}
}
DrawCommand::Trace {
x,
ys,
ch,
line_ch,
fg,
line_fg,
bg,
} => {
let line_style = Style::new().fg(*line_fg).bg(*bg);
let point_style = Style::new().fg(*fg).bg(*bg);
let mut previous_y: Option<u16> = None;
for (index, y) in ys.iter().copied().enumerate() {
let Ok(offset) = u16::try_from(index) else {
break;
};
let x = x.saturating_add(offset);
if let Some(previous) = previous_y {
let from = previous.min(y);
let to = previous.max(y);
for y in from..=to {
write_cell(frame, area, x, y, line_ch, line_style);
}
}
write_cell(frame, area, x, y, ch, point_style);
previous_y = Some(y);
}
}
DrawCommand::Text { x, y, text, fg, bg } => {
for (offset, ch) in text.chars().enumerate() {
let Ok(offset) = u16::try_from(offset) else {
break;
};
write_cell(
frame,
area,
x.saturating_add(offset),
*y,
&ch.to_string(),
Style::new().fg(*fg).bg(*bg),
);
}
}
}
}
}
fn fill_rect(frame: &mut Frame, area: Rect, symbol: &'static str, style: Style) {
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
cell.set_symbol(symbol).set_style(style);
}
}
}
}
fn write_cell(frame: &mut Frame, area: Rect, x: u16, y: u16, symbol: &str, style: Style) {
if x >= area.width || y >= area.height {
return;
}
if let Some(cell) = frame
.buffer_mut()
.cell_mut((area.x.saturating_add(x), area.y.saturating_add(y)))
{
cell.set_symbol(symbol).set_style(style);
}
}
fn draw_message(frame: &mut Frame, area: Rect, message: &str) {
frame.render_widget(
Paragraph::new(message.to_string()).style(Style::new().fg(Color::Red).bg(Color::Black)),
area,
);
}
fn map_string(map: &Map, key: &str) -> Option<String> {
map.get(key)?.clone().try_cast::<String>()
}
fn map_u16(map: &Map, key: &str) -> Option<u16> {
let value = map.get(key)?.clone().try_cast::<INT>()?;
u16::try_from(value).ok()
}
fn map_u16_array(map: &Map, key: &str) -> Option<Vec<u16>> {
map.get(key)?
.clone()
.try_cast::<Array>()?
.into_iter()
.map(|value| u16::try_from(value.try_cast::<INT>()?).ok())
.collect()
}
fn map_color(map: &Map, key: &str) -> Option<Color> {
let value = map.get(key)?;
if let Some(rgb) = value.clone().try_cast::<INT>() {
return Some(rgb_color(rgb));
}
let text = value.clone().try_cast::<String>()?;
parse_color(&text)
}
fn rgb_color(value: INT) -> Color {
let value = value.clamp(0, 0x00ff_ffff) as u32;
Color::Rgb(
((value >> 16) & 0xff) as u8,
((value >> 8) & 0xff) as u8,
(value & 0xff) as u8,
)
}
fn parse_color(text: &str) -> Option<Color> {
let hex = text.trim().trim_start_matches('#').trim_start_matches("0x");
let value = u32::from_str_radix(hex, 16).ok()?;
Some(Color::Rgb(
((value >> 16) & 0xff) as u8,
((value >> 8) & 0xff) as u8,
(value & 0xff) as u8,
))
}
fn insert_int(map: &mut Map, key: &str, value: i64) {
map.insert(key.into(), Dynamic::from_int(value as INT));
}
fn insert_float(map: &mut Map, key: &str, value: f64) {
map.insert(key.into(), Dynamic::from_float(value as FLOAT));
}
fn clock_label() -> String {
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
format!(
"{:02}:{:02}:{:02}",
secs / 3600 % 24,
secs / 60 % 60,
secs % 60
)
}
fn default_script_id() -> String {
PRIMARY_SCRIPT_ID.to_string()
}
fn config_dir() -> Result<PathBuf> {
Ok(crate::config::project_dirs()
.map(|dirs| dirs.config_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from(".")))
}
pub fn visualizations_dir() -> Result<PathBuf> {
Ok(config_dir()?.join("visualizations"))
}
fn visualizer_config_path() -> Result<PathBuf> {
Ok(config_dir()?.join("visualizations.toml"))
}
fn primary_bundled_script() -> &'static BundledScript {
&BUNDLED_SCRIPTS[0]
}
fn bundled_order(id: &str) -> Option<usize> {
BUNDLED_SCRIPTS.iter().position(|script| script.id == id)
}
fn ensure_bundled_scripts() -> Result<()> {
let dir = visualizations_dir()?;
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
for script in BUNDLED_SCRIPTS {
let path = dir.join(script.file_name);
let write_default = match std::fs::read_to_string(&path) {
Ok(existing) => should_replace_bundled_script(&existing, script),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())),
};
if write_default {
std::fs::write(&path, script.source)
.with_context(|| format!("writing {}", path.display()))?;
}
}
Ok(())
}
fn should_replace_bundled_script(existing: &str, script: &BundledScript) -> bool {
let name_marker = format!("// name: {}", script.display_name);
let version_marker = format!("// bundle-version: {BUNDLE_VERSION}");
existing
.lines()
.take(3)
.any(|line| line.trim() == name_marker)
&& !existing
.lines()
.take(8)
.any(|line| line.trim() == version_marker)
}
fn load_config() -> Result<VisualizerConfig> {
let path = visualizer_config_path()?;
match std::fs::read_to_string(&path) {
Ok(text) => toml::from_str(&text).with_context(|| format!("parsing {}", path.display())),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(VisualizerConfig::default()),
Err(err) => Err(err).with_context(|| format!("reading {}", path.display())),
}
}
fn script_name(path: &Path, fallback_id: &str) -> String {
if let Ok(text) = std::fs::read_to_string(path) {
for line in text.lines().take(12) {
let Some(name) = line.trim().strip_prefix("// name:") else {
continue;
};
let name = name.trim();
if !name.is_empty() {
return name.to_string();
}
}
}
fallback_id
.split(['_', '-'])
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
fn test_input() -> Map {
let mut input = Map::new();
insert_int(&mut input, "width", 80);
insert_int(&mut input, "height", 24);
insert_float(&mut input, "time", 1.0);
insert_float(&mut input, "position", 3.0);
insert_float(&mut input, "progress", 0.25);
insert_float(&mut input, "volume", 0.8);
input.insert("paused".into(), false.into());
insert_float(&mut input, "energy", 0.7);
insert_float(&mut input, "bass", 0.8);
insert_float(&mut input, "mid", 0.5);
insert_float(&mut input, "treble", 0.3);
insert_float(&mut input, "beat", 0.4);
insert_float(&mut input, "seed", 0.17);
insert_int(&mut input, "analysis_sequence", 1);
input.insert(
"samples".into(),
(0..128)
.map(|index| {
let sample = ((index as f64 / 128.0) * std::f64::consts::TAU).sin();
Dynamic::from_float(sample as FLOAT)
})
.collect::<Array>()
.into(),
);
input.insert("show_clock".into(), true.into());
input.insert("clock".into(), "12:34:56".into());
input.insert("track_title".into(), "track".into());
input.insert("track_artist".into(), "artist".into());
input
}
#[test]
fn bundled_scripts_render_commands() {
for script in BUNDLED_SCRIPTS {
let path = std::env::temp_dir().join(format!(
"furumi-test-{}-{}",
std::process::id(),
script.file_name
));
std::fs::write(&path, script.source).unwrap();
let commands = ScriptRuntime::default()
.render(&path, test_input())
.unwrap();
let _ = std::fs::remove_file(&path);
assert!(!commands.is_empty(), "{} returned no commands", script.id);
assert!(
commands.len() < 220,
"{} returned {} commands",
script.id,
commands.len()
);
assert!(
commands.iter().any(
|command| matches!(command, DrawCommand::Text { text, .. } if text.contains("12:34:56"))
),
"{} did not render the configured clock",
script.id
);
if script.id == PRIMARY_SCRIPT_ID {
assert!(
commands.iter().any(
|command| matches!(command, DrawCommand::Text { text, .. } if text.contains("artist - track"))
),
"{} did not render track metadata",
script.id
);
}
}
}
#[test]
fn stale_default_script_is_migrated() {
let scope = primary_bundled_script();
assert!(should_replace_bundled_script(
"// name: Scope spectrum\nfn rgb(r, g, b) { let rr = clamp(r, 0, 255) as int; }",
scope,
));
assert!(should_replace_bundled_script(
"// name: Scope spectrum\n// protocol: furumi-visualizer-v2\nfn rgb(r, g, b) { let rr = to_int(clamp(r, 0, 255)); }",
scope,
));
assert!(!should_replace_bundled_script(scope.source, scope));
assert!(!should_replace_bundled_script(
"// name: Custom 1\nfn rgb(r, g, b) { let rr = clamp(r, 0, 255) as int; }",
scope,
));
}
}