Compare commits
83
Commits
v0.1.4
...
e95d2e7fe1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e95d2e7fe1 | ||
|
|
03f74cc91e | ||
|
|
c971e81eed | ||
|
|
f73e3e7b95 | ||
|
|
cced546cd5 | ||
|
|
52c2f7f7fe | ||
|
|
9b9f5d9ce5 | ||
|
|
1f9539ffd7 | ||
|
|
d854a4d4e0 | ||
|
|
6175423b0c | ||
|
|
93dc8f02fb | ||
|
|
89c78dcadc | ||
|
|
34153cca9d | ||
|
|
d4c219837b | ||
|
|
1c721b68f5 | ||
|
|
29466d70e8 | ||
|
|
abf9ad782c | ||
|
|
28189bae95 | ||
|
|
a7f41ff205 | ||
|
|
40c318a1d0 | ||
|
|
0d6c89761f | ||
|
|
f55571ab34 | ||
|
|
eb97c6469e | ||
|
|
7ebf07161e | ||
|
|
d36f55b3f6 | ||
|
|
5722e5aae6 | ||
|
|
4e1c52c4e6 | ||
|
|
d64deded3a | ||
|
|
864e84b159 | ||
|
|
9aefaa364e | ||
|
|
c069ed9135 | ||
|
|
3556120e10 | ||
|
|
ada18a4583 | ||
|
|
17eb6a4fee | ||
|
|
76f67372a1 | ||
|
|
f80e10e6ad | ||
|
|
c45f2e700b | ||
|
|
788bc2e01f | ||
|
|
b725ca8d05 | ||
|
|
38e3eb5a12 | ||
|
|
a516887830 | ||
|
|
888038931a | ||
|
|
3d3fd20b57 | ||
|
|
33fa2346f0 | ||
|
|
d62c79b50c | ||
|
|
8605c72f10 | ||
|
|
b14781f5a4 | ||
|
|
389eacd388 | ||
|
|
1065ea5afc | ||
|
|
9352b493a1 | ||
|
|
a737456daf | ||
|
|
be58933e8d | ||
|
|
e83f356005 | ||
|
|
682794755e | ||
|
|
71d31505f9 | ||
|
|
e08eb71ab7 | ||
|
|
fa58b2ce43 | ||
|
|
cc3153cc3a | ||
|
|
f85c806b43 | ||
|
|
b47c271ec0 | ||
|
|
adbb76b031 | ||
|
|
7fe6ddfe05 | ||
|
|
6dbfc83bf6 | ||
|
|
5e346bf535 | ||
|
|
32a038dba6 | ||
|
|
af2542b668 | ||
|
|
5d54894437 | ||
|
|
2128cd2300 | ||
|
|
0b2eaa1496 | ||
|
|
6c808aea5c | ||
|
|
da5d2410ac | ||
|
|
151e2cf337 | ||
|
|
70261a3bf4 | ||
|
|
ff97053156 | ||
|
|
a723fcc18e | ||
|
|
73ee4dab9d | ||
|
|
eb8ed966f1 | ||
|
|
b027db8a09 | ||
|
|
691abe599b | ||
|
|
3df41cfc3b | ||
|
|
b1f8f4cb01 | ||
|
|
98525718d0 | ||
|
|
cf82b203a9 |
@@ -10,6 +10,7 @@ permissions:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_NET_RETRY: 10
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -24,7 +25,7 @@ jobs:
|
||||
archive_ext: tar.gz
|
||||
binary_name: furumi
|
||||
- os: macos-latest
|
||||
asset_name: furumi-macos-x86_64
|
||||
asset_name: furumi-macos-aarch64
|
||||
archive_ext: tar.gz
|
||||
binary_name: furumi
|
||||
- os: windows-latest
|
||||
@@ -43,8 +44,11 @@ jobs:
|
||||
- name: Show Rust version
|
||||
run: rustc --version && cargo --version
|
||||
|
||||
- name: Fetch locked dependencies
|
||||
run: cargo fetch --locked
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --locked
|
||||
run: cargo build --release --locked --offline
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
@@ -57,6 +61,7 @@ jobs:
|
||||
mkdir -p "$package_dir"
|
||||
cp "target/release/${{ matrix.binary_name }}" "$package_dir/"
|
||||
cp README.md "$package_dir/"
|
||||
cp LICENSE "$package_dir/"
|
||||
|
||||
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||
(cd dist && 7z a "../${archive_name}" "${{ matrix.asset_name }}")
|
||||
|
||||
@@ -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.
|
||||
+344
-208
@@ -1,240 +1,376 @@
|
||||
# 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.
|
||||
|
||||
### Federation Jam control
|
||||
|
||||
Jam is a third, deliberately narrow authority boundary. A host creates an
|
||||
opaque `frid://j/...` runtime capability and remains the only node producing
|
||||
audio. Other TUI peers use a dedicated Jam ALPN to submit the same portable
|
||||
playback commands used by connected-device control and receive the host's
|
||||
playback snapshot. They receive queue metadata, not audio.
|
||||
|
||||
Jam never exchanges trusted membership, likes, playlists, or listening
|
||||
history. Volume remains local. Commands carry unique IDs and are retried until
|
||||
the host acknowledges them, while inactive participants expire from the
|
||||
runtime session. Regenerating the capability or restarting the host invalidates
|
||||
the previous link.
|
||||
|
||||
## 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.
|
||||
|
||||
## Music-similarity indexing
|
||||
|
||||
Similarity search is an optional local capability and is disabled by default.
|
||||
When enabled, a background pipeline downloads a selected ONNX model, verifies
|
||||
its pinned SHA-256 digest, decodes durable local tracks, and stores normalized
|
||||
embeddings in the library SQLite database. Embeddings are keyed by an exact
|
||||
fingerprint of the model artifact and preprocessing profile. Old profile rows
|
||||
remain available while a new profile is calculated, and the in-memory exact
|
||||
cosine index switches only after the replacement profile is usable.
|
||||
|
||||
The SQLite rows are the canonical derived store. The in-memory index can be
|
||||
discarded and rebuilt, and neither is required for import, browsing, or
|
||||
playback. Remote/cache-only tracks are never scheduled for local embedding.
|
||||
|
||||
Federated similarity uses a separate versioned direct-stream protocol. With
|
||||
explicit privacy consent, the requester sends only a normalized embedding and
|
||||
its profile fingerprint to a bounded set of known peers. It does not publish
|
||||
queries to the DHT. Each peer searches its own active local index and returns a
|
||||
bounded metadata result with a compact embedding SimHash. The requester uses
|
||||
that signature to suppress near-duplicate recordings across peers without
|
||||
receiving every result vector. Fan-out, concurrency, message sizes, and
|
||||
timeouts are bounded; incompatible profiles are rejected. This direct
|
||||
peer-selection layer can later be replaced by DHT routing without changing
|
||||
local storage or ranking.
|
||||
|
||||
## 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 |
|
||||
| Versioned track embeddings | SQLite | Durable, locally rebuildable similarity data |
|
||||
| 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;
|
||||
- `similarity.rs` owns model acquisition, preprocessing, background indexing,
|
||||
and the replaceable exact in-memory index;
|
||||
- `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.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Furumi are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Optional offline music-similarity search for local tracks, backed by
|
||||
versioned SQLite embeddings and a replaceable exact in-memory cosine index.
|
||||
- Automatic SHA-256-verified download and local inference for the first ONNX
|
||||
embedding model, with retained model/profile generations and background
|
||||
backfilling of existing library tracks.
|
||||
- Similarity settings for enablement, model/profile information, numeric worker
|
||||
count, derived-data cleanup, processing progress, and federation privacy
|
||||
consent.
|
||||
- Track-seeded similarity search from the track-information popup, including
|
||||
bounded federated queries to compatible known peers.
|
||||
- The `furumi-fd/similarity/1` protocol in the visible protocol-version status.
|
||||
|
||||
### Changed
|
||||
|
||||
- Similarity wire types, bounds, validation, and stream framing now come from
|
||||
the shared `music-dht 0.3.1` API so native, web, and future clients can
|
||||
interoperate without sharing an embedding implementation.
|
||||
- A similarity result page keeps the source track first as query context while
|
||||
excluding it from the actual nearest-neighbor ranking, labels the mode as
|
||||
`Search similar to`, and suppresses near-identical embeddings across releases
|
||||
and federated peer responses.
|
||||
- Preprocessing profiles now open a read-only details window describing their
|
||||
audio selection, resampling, spectrogram, patching, and aggregation contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Current-track information (`Shift+I`) now uses the enriched queue entry, so
|
||||
it shows the same complete metadata and similarity action as `I` on that
|
||||
track in the queue.
|
||||
|
||||
## [0.2.5] - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- Command history navigation with the Up and Down arrow keys.
|
||||
- A copy-friendly plain terminal view for connection tickets, device and Jam
|
||||
invites, and track-sharing links.
|
||||
- A configurable permanent music directory with write validation and an
|
||||
optional safe migration of existing Furumi-managed files.
|
||||
- Queue reordering for one track or a `Shift+V` selection with `Alt+K` and
|
||||
`Alt+J`.
|
||||
- Reproducible Nix/devenv tooling with Rust 1.97 and ALSA development files.
|
||||
|
||||
### Changed
|
||||
|
||||
- Sequential `a` actions now build one ordered play-next block instead of
|
||||
reversing independently inserted tracks.
|
||||
- Federated artist images and release covers are stored beside permanent music
|
||||
in an `Artist/Release` directory tree.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Running a development build next to another Furumi instance no longer lets
|
||||
an MPRIS name collision disable terminal raw mode and freeze keyboard input.
|
||||
- `nix develop` now keeps devenv state outside the read-only Nix store and
|
||||
isolates Rust 1.97 build artifacts from other toolchains.
|
||||
- Music-directory validation and migration now reject overlapping changes,
|
||||
resolve canonical paths, and produce Windows-portable managed filenames.
|
||||
|
||||
[Unreleased]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.5...HEAD
|
||||
[0.2.5]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.4...v0.2.5
|
||||
Generated
+2925
-875
File diff suppressed because it is too large
Load Diff
+22
-7
@@ -1,7 +1,10 @@
|
||||
[package]
|
||||
name = "furumi_tui"
|
||||
version = "0.1.4"
|
||||
version = "0.2.6"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
description = "A federated P2P player for personal music libraries"
|
||||
license = "WTFPL"
|
||||
|
||||
[[bin]]
|
||||
name = "furumi"
|
||||
@@ -9,32 +12,44 @@ 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 = "0.3.1"
|
||||
ratatui = "0.30.1"
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
|
||||
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
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"] }
|
||||
rustfft = "6.4.1"
|
||||
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
sha2 = "0.10.9"
|
||||
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"] }
|
||||
tract-onnx = "0.23.4"
|
||||
unicode-width = "0.2.2"
|
||||
|
||||
[patch.crates-io]
|
||||
# souvlaki 0.8.3 still depends on the unmaintained block 0.1.6. Keep its API
|
||||
# intact while using an opaque inhabited FFI type accepted by current Rust.
|
||||
block = { path = "vendor/block" }
|
||||
|
||||
[target.'cfg(target_os="macos")'.dependencies]
|
||||
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"
|
||||
|
||||
@@ -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.
|
||||
@@ -1,186 +1,176 @@
|
||||
# furumi
|
||||
|
||||

|
||||

|
||||
|
||||
`furumi` is a cross-platform terminal client for a furumusic server. It
|
||||
provides a fast TUI for browsing the library, playing music, managing the
|
||||
queue and playlists, controlling devices, and inspecting logs without leaving
|
||||
the terminal.
|
||||
**Your music. Your devices. Your network.**
|
||||
|
||||
## Features
|
||||
Furumi is a federated P2P player for your personal music library. Every
|
||||
running player is a complete, self-sufficient music library: it can import,
|
||||
organize, search, and play your collection without an account, a cloud
|
||||
backend, or a central service.
|
||||
|
||||
- Browse the full artist library in tile or table view.
|
||||
- Open artist pages, releases, and track lists from inside the TUI.
|
||||
- Search artists, releases, and tracks with `/`.
|
||||
- Play local audio with seek, volume, shuffle, repeat, and like controls.
|
||||
- Add tracks next in queue, append them to the queue, or clear the queue.
|
||||
- Browse playlists, liked tracks, and add tracks to playlists.
|
||||
- Pick the active playback device and control remote devices.
|
||||
- Use OS media keys through MPRIS/system media controls.
|
||||
- Inspect live in-app logs and a persistent log file.
|
||||
- Customize key bindings with a TOML keymap.
|
||||
Connect Furumi on your desktop, laptop, or another device and they become one
|
||||
personal music network. Playback, likes, playlists, and library state can move
|
||||
between your devices, while missing tracks can be requested directly from
|
||||
another player.
|
||||
|
||||
## Installation
|
||||
Furumi runs on **Linux, macOS, and Windows**.
|
||||
|
||||
Requires Rust 1.88+.
|
||||
## Why Furumi?
|
||||
|
||||
Music you own should not disappear because a subscription ended, a catalog
|
||||
changed, a service was censored, or a server went offline.
|
||||
|
||||
Furumi is built around a different model:
|
||||
|
||||
- your library remains under your control;
|
||||
- every player works independently;
|
||||
- there is no central account or single point of failure;
|
||||
- connecting more players improves availability instead of creating a new
|
||||
dependency;
|
||||
- federation is optional and simple to configure.
|
||||
|
||||
A single Furumi instance is already useful. A group of instances becomes a
|
||||
resilient network for your music.
|
||||
|
||||
## How it works
|
||||
|
||||
Each player maintains its own local library and publishes a searchable view of
|
||||
it into Furumi's DHT network. Discovery does not depend on a central index.
|
||||
|
||||
Clients connect directly over P2P transport built on
|
||||
[iroh](https://www.iroh.computer/). Furumi adds a synchronization protocol on
|
||||
top of that transport to keep trusted devices consistent even when they are
|
||||
not always online.
|
||||
|
||||
In practice:
|
||||
|
||||
1. Import music into any Furumi player.
|
||||
2. Pair your other players or join a federation network.
|
||||
3. Devices discover available libraries through the DHT.
|
||||
4. Likes, playlists, playback state, and library metadata synchronize between
|
||||
trusted clients.
|
||||
5. If a track is missing locally, Furumi can fetch it directly from another
|
||||
client.
|
||||
|
||||
There is no coordination server in the middle. Local players remain usable
|
||||
when peers are offline, and the network becomes more capable as peers appear.
|
||||
|
||||
## The player
|
||||
|
||||
Furumi includes a full-featured terminal interface for browsing artists and
|
||||
releases, searching, managing playlists and the queue, controlling playback,
|
||||
inspecting connected devices, and configuring federation.
|
||||
|
||||
The interface supports keyboard-driven navigation, multi-key combinations,
|
||||
context-aware bindings, and user-defined rebinding through TOML. Built-in
|
||||
audio visualizations, OS media controls, gapless queue playback, and local
|
||||
library management are included. Visualizations are runtime-loadable Rhai
|
||||
scripts executed in a resource-limited sandbox, so they can be added or edited
|
||||
without rebuilding the player.
|
||||
|
||||
Optional similarity search calculates versioned embeddings for local tracks
|
||||
in the background and keeps them in SQLite. It works offline; after a separate
|
||||
privacy consent it can also ask a bounded set of federation peers for matches.
|
||||
The first selectable model is downloaded on demand and is licensed separately
|
||||
by MTG under CC BY-NC-SA 4.0 (a proprietary license is also available from
|
||||
MTG); Furumi itself remains WTFPL.
|
||||
|
||||
## Install
|
||||
|
||||
### macOS
|
||||
|
||||
On Apple Silicon Macs, install Furumi from the Homebrew tap:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
brew install house-of-vanity/tap/furumi
|
||||
```
|
||||
|
||||
Run it with:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
The release binary is named `furumi`:
|
||||
On Debian or Ubuntu, install the Linux audio build dependencies first:
|
||||
|
||||
```bash
|
||||
cargo run --release --bin furumi
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Audio output needs the system ALSA library. PipeWire and PulseAudio are used
|
||||
through the ALSA compatibility layer at runtime.
|
||||
|
||||
```bash
|
||||
# Debian / Ubuntu
|
||||
sudo apt install libasound2-dev pkg-config
|
||||
|
||||
# Fedora
|
||||
sudo dnf install alsa-lib-devel pkgconf-pkg-config
|
||||
|
||||
# Arch
|
||||
sudo pacman -S alsa-lib pkgconf
|
||||
```
|
||||
|
||||
Everything else is handled by Rust dependencies: TLS uses `rustls`, MPRIS uses
|
||||
`zbus`, and image/audio decoding is provided by Rust crates.
|
||||
Equivalent ALSA development packages are required on other Linux
|
||||
distributions. macOS and Windows require no additional system packages.
|
||||
|
||||
### macOS and Windows
|
||||
|
||||
No extra system packages are required.
|
||||
|
||||
## First Run
|
||||
|
||||
On startup, `furumi` opens the login screen:
|
||||
|
||||
1. Enter your furumusic server URL.
|
||||
2. Sign in with username/password or SSO.
|
||||
3. After a successful login, the session is saved locally.
|
||||
|
||||
The SSO flow opens your browser automatically. If the loopback callback is not
|
||||
available, `furumi` shows the URL and accepts either a pasted `furumi://...`
|
||||
callback link or the short `furu_mx_...` code.
|
||||
|
||||
## Controls
|
||||
|
||||
Common key bindings:
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `?` | Show key binding help |
|
||||
| `q`, `Ctrl-C` | Quit |
|
||||
| `Tab`, `Shift-Tab` | Next / previous tab |
|
||||
| `1`...`4` | Jump to a tab |
|
||||
| `j` / `k`, arrows | Move down / up |
|
||||
| `h` / `l`, arrows | Move left / right |
|
||||
| `Enter` | Open or select item |
|
||||
| `Esc`, `Backspace` | Go back |
|
||||
| `Space` | Play / pause |
|
||||
| `n`, `p` | Next / previous track |
|
||||
| `.`, `,` | Seek 10 seconds forward / backward |
|
||||
| `+`, `-` | Volume up / down |
|
||||
| `s` | Toggle shuffle |
|
||||
| `r` | Cycle repeat mode |
|
||||
| `x` | Like / unlike |
|
||||
| `a` | Add track next |
|
||||
| `Shift-A` | Add track to the end of the queue |
|
||||
| `Shift-P` | Add track to a playlist |
|
||||
| `Shift-D` | Open device picker |
|
||||
| `v` | Toggle tile/table view |
|
||||
| `/` | Search |
|
||||
| `:` | Open command line |
|
||||
|
||||
Command line examples:
|
||||
Import a music directory from Furumi's command line:
|
||||
|
||||
```text
|
||||
:q
|
||||
:logout
|
||||
:volume 40
|
||||
:seek +30
|
||||
:seek -10
|
||||
:seek 1:30
|
||||
:shuffle
|
||||
:repeat off
|
||||
:repeat one
|
||||
:repeat all
|
||||
:clear
|
||||
:next
|
||||
:prev
|
||||
:play
|
||||
:pause
|
||||
:devices
|
||||
:logs debug
|
||||
:import /path/to/music
|
||||
```
|
||||
|
||||
## Configuration
|
||||
Federation, trusted-device pairing, and key bindings are configured directly
|
||||
inside the player.
|
||||
|
||||
`furumi` stores configuration in the platform app config directory:
|
||||
### Now playing in tmux
|
||||
|
||||
- Linux: `~/.config/furumi`
|
||||
- macOS: `~/Library/Application Support/furumi`
|
||||
- Windows: `%APPDATA%\furumi`
|
||||
|
||||
Important files:
|
||||
|
||||
- `credentials.json` - saved login session. On Unix it is written with `0600`
|
||||
permissions.
|
||||
- `device_id` - stable identifier for this TUI client during device sync.
|
||||
- `keymap.toml` - user key binding overrides.
|
||||
|
||||
See [`src/config/default_keymap.toml`](src/config/default_keymap.toml) for the
|
||||
default format. Example:
|
||||
|
||||
```toml
|
||||
[[keymaps]]
|
||||
key_sequence = "ctrl-n"
|
||||
command = "NextTrack"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "ctrl-f"
|
||||
command = { SeekForward = { seconds = 30 } }
|
||||
```
|
||||
|
||||
A user binding replaces the default binding with the same key sequence and
|
||||
context.
|
||||
|
||||
## Logs
|
||||
|
||||
The Logs tab shows a live in-memory ring buffer inside the TUI. You can jump
|
||||
to it and set the level filter with:
|
||||
|
||||
```text
|
||||
:logs error
|
||||
:logs warn
|
||||
:logs info
|
||||
:logs debug
|
||||
:logs trace
|
||||
```
|
||||
|
||||
The persistent log file is written to the platform cache directory as
|
||||
`furumi-cli.log`. File logging is filtered by `RUST_LOG`:
|
||||
While Furumi is running, a second invocation can print a cheap, single-line
|
||||
playback snapshot without opening the TUI or library:
|
||||
|
||||
```bash
|
||||
RUST_LOG=furumi_tui=debug cargo run --release --bin furumi
|
||||
furumi --status
|
||||
# ▶ Artist — Track 1:23/4:05
|
||||
```
|
||||
|
||||
For example, add this to `.tmux.conf`:
|
||||
|
||||
```tmux
|
||||
set -g status-interval 1
|
||||
set -g status-right '#(furumi --status) | %H:%M'
|
||||
```
|
||||
|
||||
`furumi --status-json` returns the same snapshot as JSON, including playback
|
||||
state, title, artist, album, position, duration, and volume. Both commands
|
||||
print nothing when Furumi is stopped or no track is loaded. On Linux, Furumi
|
||||
also exposes the existing MPRIS player `cy.hexor.furumi`, which can be queried
|
||||
with tools such as `playerctl`.
|
||||
|
||||
## Architecture
|
||||
|
||||
At a glance:
|
||||
Furumi is a Rust application built with:
|
||||
|
||||
- UI: `ratatui` + `crossterm`.
|
||||
- Runtime: `tokio`.
|
||||
- HTTP: `reqwest` + `rustls`.
|
||||
- Audio: `rodio` + `stream-download`.
|
||||
- Keymap config: `crokey` + TOML.
|
||||
- State model: one `AppState`, events, and an update loop.
|
||||
- `ratatui` and `crossterm` for the cross-platform TUI;
|
||||
- `rodio` for local audio playback;
|
||||
- SQLite for the personal library and synchronization state;
|
||||
- tract ONNX inference for optional local music embeddings;
|
||||
- a dedicated DHT for decentralized discovery;
|
||||
- iroh-based P2P streams for client-to-client communication;
|
||||
- an offline-tolerant operation log for trusted-device synchronization;
|
||||
- Rhai for programmable audio visualizations.
|
||||
|
||||
See [`ARCHITECTURE.md`](ARCHITECTURE.md) for more detail.
|
||||
More detail is available in [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, design discussions, and patches are welcome. Before submitting a
|
||||
change, run:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo check --all-targets
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Furumi is released under the
|
||||
[Do What The Fuck You Want To Public License, Version 2](LICENSE).
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
sourceRoot = builtins.toString ./.;
|
||||
sourceKey = builtins.substring 0 12 (builtins.hashString "sha256" sourceRoot);
|
||||
transientDotfile = "/tmp/furumi-tui-devenv-${sourceKey}";
|
||||
rustToolchain = builtins.fromTOML (builtins.readFile ./rust-toolchain.toml);
|
||||
rustVersion = rustToolchain.toolchain.channel;
|
||||
in
|
||||
{
|
||||
# Pure flakes only expose their read-only store source. Keep devenv's own
|
||||
# task/profile state writable, then restore the real checkout path in-shell.
|
||||
devenv.root = sourceRoot;
|
||||
devenv.dotfile = transientDotfile;
|
||||
devenv.state = "${transientDotfile}/state";
|
||||
|
||||
languages.rust = {
|
||||
enable = true;
|
||||
toolchainFile = ./rust-toolchain.toml;
|
||||
};
|
||||
|
||||
packages = [
|
||||
pkgs.pkg-config
|
||||
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.alsa-lib
|
||||
];
|
||||
|
||||
env.RUST_BACKTRACE = "1";
|
||||
|
||||
enterShell = lib.mkAfter ''
|
||||
export DEVENV_ROOT="$PWD"
|
||||
# Keep Nix builds separate from artifacts produced by rustup or another
|
||||
# devenv generation. Cargo metadata is not compatible across compilers.
|
||||
export CARGO_TARGET_DIR="$PWD/target/devenv-rust-${rustVersion}"
|
||||
export PATH="${config.languages.rust.toolchainPackage}/bin:$PATH"
|
||||
export RUSTC="${config.languages.rust.toolchainPackage}/bin/rustc"
|
||||
export RUSTDOC="${config.languages.rust.toolchainPackage}/bin/rustdoc"
|
||||
hash -r
|
||||
'';
|
||||
}
|
||||
Generated
+399
@@ -0,0 +1,399 @@
|
||||
{
|
||||
"nodes": {
|
||||
"cachix": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"git-hooks": [
|
||||
"devenv",
|
||||
"git-hooks"
|
||||
],
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1777487137,
|
||||
"narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "a66a440c321d35f7193472c317f42a55ccd1cb93",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crate2nix": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1772186516,
|
||||
"narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=",
|
||||
"owner": "rossng",
|
||||
"repo": "crate2nix",
|
||||
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rossng",
|
||||
"repo": "crate2nix",
|
||||
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv": {
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
"crate2nix": "crate2nix",
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-parts": "flake-parts",
|
||||
"ghostty": "ghostty",
|
||||
"git-hooks": "git-hooks",
|
||||
"nix": "nix",
|
||||
"nixd": "nixd",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785616853,
|
||||
"narHash": "sha256-WKA53CfbSwXh/QuGHc3gsWoLNHYaKw4xgvkc9ceRgUA=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "a3feed9ca96c66548496e8af2804d2a3a045d22e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"ghostty": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1784602798,
|
||||
"narHash": "sha256-298x90knBUWX5GHGXh2SKsAKvStjU2ri9UgOGoF79/8=",
|
||||
"owner": "ghostty-org",
|
||||
"repo": "ghostty",
|
||||
"rev": "88b4cd047fa627cdca6781bc7e7dc8b75a2cecb9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ghostty-org",
|
||||
"repo": "ghostty",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782908218,
|
||||
"narHash": "sha256-wLMOrPgVyeF3XmP+qfYcLqnVdTxikdcSvbIY7rA9jTA=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "9f7e99119ece7705299595299f3b031f39356de1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"mk-shell-bin": {
|
||||
"locked": {
|
||||
"lastModified": 1677004959,
|
||||
"narHash": "sha256-/uEkr1UkJrh11vD02aqufCxtbF5YnhRTIKlx5kyvf+I=",
|
||||
"owner": "rrbutani",
|
||||
"repo": "nix-mk-shell-bin",
|
||||
"rev": "ff5d8bd4d68a347be5042e2f16caee391cd75887",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rrbutani",
|
||||
"repo": "nix-mk-shell-bin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"flake-parts": [
|
||||
"devenv",
|
||||
"flake-parts"
|
||||
],
|
||||
"git-hooks-nix": [
|
||||
"devenv",
|
||||
"git-hooks"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"nixpkgs-23-11": [
|
||||
"devenv"
|
||||
],
|
||||
"nixpkgs-regression": [
|
||||
"devenv"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785349663,
|
||||
"narHash": "sha256-JSD8lPe5kalvPKx5X+inX8ZZdGLeXkAbd3Jiv7UDf+I=",
|
||||
"owner": "cachix",
|
||||
"repo": "nix",
|
||||
"rev": "f33db89fd6db6edc337d93212f6628ab6d25f407",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "devenv-2.34",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix2container": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1775487831,
|
||||
"narHash": "sha256-2lguQpLPQaxpQCJjXhmEEAfabwsAhkP29Z7fgLzHARA=",
|
||||
"owner": "nlewo",
|
||||
"repo": "nix2container",
|
||||
"rev": "76be9608a7f4d6c985d28b0e7be903ae2547df3e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nlewo",
|
||||
"repo": "nix2container",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixd": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
"devenv",
|
||||
"flake-parts"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783935112,
|
||||
"narHash": "sha256-IAQ14nteIKXAz4cd75UcZrsHGEVJ7QNrkUwX9rmeZ/Y=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"rev": "a64cd33e53b316b6b092ea0a966640cd2309bf3d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772624091,
|
||||
"narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "80bdc1e5ce51f56b19791b52b2901187931f5353",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1782614948,
|
||||
"narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1785571196,
|
||||
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"mk-shell-bin": "mk-shell-bin",
|
||||
"nix2container": "nix2container",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"rust-overlay": "rust-overlay_2"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782875958,
|
||||
"narHash": "sha256-5eqDcnBjb1424HRQdnhuhNOBZguq1Z2tqSa2OMF/m2c=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "13139aefa973f3d96c60c0fbab801de058ae25ca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"rust-overlay_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785562362,
|
||||
"narHash": "sha256-J15aBa3d6B1SUUAQydQ06wFjPzrrcVceb6jkdfwfGls=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "5f29c219a7655519f8a9f8c6968064b82c17cc93",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixd",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1780220602,
|
||||
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
description = "Furumi TUI development environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
devenv.url = "github:cachix/devenv";
|
||||
devenv.inputs.nixpkgs.follows = "nixpkgs";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
nix2container.url = "github:nlewo/nix2container";
|
||||
nix2container.inputs.nixpkgs.follows = "nixpkgs";
|
||||
mk-shell-bin.url = "github:rrbutani/nix-mk-shell-bin";
|
||||
};
|
||||
|
||||
outputs = inputs@{ flake-parts, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
|
||||
imports = [ inputs.devenv.flakeModule ];
|
||||
|
||||
perSystem = { ... }: {
|
||||
devenv.shells.default = {
|
||||
imports = [ ./devenv.nix ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 118 KiB |
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.97.0"
|
||||
profile = "minimal"
|
||||
components = ["clippy", "rust-src", "rustfmt"]
|
||||
-274
@@ -1,274 +0,0 @@
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
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 seconds_until_access_expiry(&self) -> i64 {
|
||||
self.expires_at_epoch_seconds - now_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 Some(path) = session_path() else {
|
||||
tracing::warn!("cannot determine config directory; no stored auth session loaded");
|
||||
return None;
|
||||
};
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||
tracing::debug!(path = %path.display(), "credentials file not found");
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %path.display(), %err, "failed to read credentials file");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::from_str::<AuthSession>(&text) {
|
||||
Ok(session) => {
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
server = %session.server_base_url,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"loaded stored auth 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)?;
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
server = %session.server_base_url,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"persisting auth session"
|
||||
);
|
||||
write_private(&path, &text).with_context(|| format!("writing {}", path.display()))?;
|
||||
tracing::debug!(path = %path.display(), "auth session persisted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_session() {
|
||||
if let Some(path) = session_path() {
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => tracing::info!(path = %path.display(), "deleted stored auth session"),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||
tracing::debug!(path = %path.display(), "stored auth session already absent");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %path.display(), %err, "failed to delete stored auth session")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -1,778 +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 device_name = device_name();
|
||||
tracing::info!(%base_url, %device_name, "password login request started");
|
||||
let response = match http
|
||||
.post(format!("{base_url}/api/auth/password"))
|
||||
.json(&PasswordLoginRequest {
|
||||
username,
|
||||
password,
|
||||
device_name,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %err, "password login request failed");
|
||||
return Err(ApiError::Network(err));
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
let login: LoginResponse = match parse_response(response).await {
|
||||
Ok(login) => login,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %status, %err, "password login rejected");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let session = AuthSession::new(base_url.to_string(), login.user, login.tokens);
|
||||
tracing::info!(
|
||||
%base_url,
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"password login succeeded"
|
||||
);
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn login_sso_exchange(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
code: &str,
|
||||
) -> Result<AuthSession, ApiError> {
|
||||
let device_name = device_name();
|
||||
tracing::info!(%base_url, %device_name, "SSO exchange request started");
|
||||
let response = match http
|
||||
.post(format!("{base_url}/api/auth/sso/exchange"))
|
||||
.json(&SsoExchangeRequest { code, device_name })
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %err, "SSO exchange request failed");
|
||||
return Err(ApiError::Network(err));
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
let login: LoginResponse = match parse_response(response).await {
|
||||
Ok(login) => login,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %status, %err, "SSO exchange rejected");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let session = AuthSession::new(base_url.to_string(), login.user, login.tokens);
|
||||
tracing::info!(
|
||||
%base_url,
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"SSO exchange succeeded"
|
||||
);
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
tracing::info!(%base_url, "refresh token request started");
|
||||
let response = match http
|
||||
.post(format!("{base_url}/api/auth/refresh"))
|
||||
.json(&RefreshRequest { refresh_token })
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %err, "refresh token request failed");
|
||||
return Err(ApiError::Network(err));
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
tracing::warn!(%base_url, %status, "refresh token rejected by server");
|
||||
return Err(ApiError::SessionExpired);
|
||||
}
|
||||
let tokens: TokensResponse = match parse_response(response).await {
|
||||
Ok(tokens) => tokens,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %status, %err, "refresh token request returned error");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
%base_url,
|
||||
%status,
|
||||
expires_in_seconds = tokens.expires_in_seconds,
|
||||
"refresh token request succeeded"
|
||||
);
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
fn stream_error_is_auth_related(err: &ApiError) -> bool {
|
||||
let ApiError::Server(message) = err else {
|
||||
return false;
|
||||
};
|
||||
let message = message.to_ascii_lowercase();
|
||||
message.contains("401")
|
||||
|| message.contains("unauthorized")
|
||||
|| message.contains("authentication")
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let url: reqwest::Url = format!("{}{path}", self.base_url)
|
||||
.parse()
|
||||
.map_err(|err| ApiError::Server(format!("bad stream url: {err}")))?;
|
||||
tracing::info!(path, "opening authenticated stream");
|
||||
let token = self.fresh_access_token().await?;
|
||||
match self.open_stream_with_token(url.clone(), &token).await {
|
||||
Ok(stream) => {
|
||||
tracing::debug!(path, "authenticated stream opened");
|
||||
Ok(stream)
|
||||
}
|
||||
Err(err) if stream_error_is_auth_related(&err) => {
|
||||
tracing::warn!(path, %err, "stream rejected bearer token; refreshing and retrying");
|
||||
let retry_token = self.refresh_after_rejection(&token).await?;
|
||||
let result = self.open_stream_with_token(url, &retry_token).await;
|
||||
if let Err(retry_err) = &result {
|
||||
tracing::warn!(path, %retry_err, "stream open failed after auth refresh");
|
||||
}
|
||||
result
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(path, %err, "stream open failed");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_stream_with_token(
|
||||
&self,
|
||||
url: reqwest::Url,
|
||||
token: &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 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 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())
|
||||
};
|
||||
tracing::info!(base_url = %self.base_url, "logout request started");
|
||||
let response = self
|
||||
.http
|
||||
.post(format!("{}/api/auth/logout", self.base_url))
|
||||
.bearer_auth(access_token)
|
||||
.json(&LogoutRequest {
|
||||
refresh_token: &refresh_token,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LogoutResponse {
|
||||
revoked: bool,
|
||||
}
|
||||
let body: LogoutResponse = match parse_response(response).await {
|
||||
Ok(body) => body,
|
||||
Err(err) => {
|
||||
tracing::warn!(base_url = %self.base_url, %status, %err, "logout request failed");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
tracing::info!(base_url = %self.base_url, %status, revoked = body.revoked, "logout request succeeded");
|
||||
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 {
|
||||
tracing::warn!(%url, "authenticated request returned 401; refreshing token and retrying");
|
||||
let token = self.refresh_after_rejection(&token).await?;
|
||||
let response = build(&self.http, url, &token).send().await?;
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
tracing::warn!(%url, "authenticated request still returned 401 after token refresh");
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn fresh_access_token(&self) -> Result<String, ApiError> {
|
||||
let mut session = self.session.lock().await;
|
||||
let seconds_until_expiry = session.seconds_until_access_expiry();
|
||||
if session.access_token_expired() {
|
||||
tracing::info!(
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry,
|
||||
"access token expired or near expiry; refreshing"
|
||||
);
|
||||
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 {
|
||||
tracing::info!(
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
"rejected access token was already rotated by another task"
|
||||
);
|
||||
return Ok(session.access_token.clone());
|
||||
}
|
||||
tracing::warn!(
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"access token was rejected; refreshing"
|
||||
);
|
||||
self.refresh_locked(&mut session).await?;
|
||||
Ok(session.access_token.clone())
|
||||
}
|
||||
|
||||
async fn refresh_locked(&self, session: &mut AuthSession) -> Result<(), ApiError> {
|
||||
let user_id = session.user.id;
|
||||
let user = session.user.name.clone();
|
||||
let previous_expires_at = session.expires_at_epoch_seconds;
|
||||
let previous_seconds_until_expiry = session.seconds_until_access_expiry();
|
||||
tracing::info!(
|
||||
user_id,
|
||||
user = %user,
|
||||
server = %self.base_url,
|
||||
previous_expires_at_epoch_seconds = previous_expires_at,
|
||||
previous_seconds_until_expiry,
|
||||
"refreshing access token"
|
||||
);
|
||||
let result = refresh_tokens(&self.http, &self.base_url, &session.refresh_token).await;
|
||||
match result {
|
||||
Ok(tokens) => {
|
||||
let expires_in_seconds = tokens.expires_in_seconds;
|
||||
session.apply_tokens(tokens);
|
||||
if let Err(err) = auth::save_session(session) {
|
||||
tracing::warn!(%err, "failed to persist rotated tokens");
|
||||
}
|
||||
tracing::info!(
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
server = %self.base_url,
|
||||
expires_in_seconds,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"access token refreshed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(ApiError::SessionExpired) => {
|
||||
tracing::warn!(
|
||||
user_id,
|
||||
user = %user,
|
||||
server = %self.base_url,
|
||||
"refresh token expired or rejected; clearing stored session"
|
||||
);
|
||||
auth::delete_session();
|
||||
Err(ApiError::SessionExpired)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
user_id,
|
||||
user = %user,
|
||||
server = %self.base_url,
|
||||
%err,
|
||||
"access token refresh failed"
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
@@ -1,314 +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>,
|
||||
#[serde(default)]
|
||||
pub disc_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>,
|
||||
#[serde(default)]
|
||||
pub uploader_name: 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>,
|
||||
pub lastfm_listeners: Option<i64>,
|
||||
#[allow(dead_code, reason = "popularity column later")]
|
||||
pub lastfm_playcount: Option<i64>,
|
||||
pub lastfm_rating: Option<f64>,
|
||||
pub lastfm_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
+50
-13
@@ -21,28 +21,42 @@ 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,
|
||||
MoveQueueUp,
|
||||
MoveQueueDown,
|
||||
/// 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.
|
||||
@@ -50,15 +64,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,
|
||||
@@ -68,6 +84,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",
|
||||
@@ -86,16 +103,23 @@ 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::MoveQueueUp
|
||||
| Action::MoveQueueDown
|
||||
| Action::DownloadSelected
|
||||
| Action::RemoveFromQueue
|
||||
| Action::ClearQueue
|
||||
| Action::AddToPlaylist
|
||||
| Action::NewPlaylist
|
||||
| Action::ToggleLike
|
||||
| Action::ToggleTrackSelection
|
||||
| Action::OpenTrackInfo => Category::Queue,
|
||||
| Action::OpenTrackInfo
|
||||
| Action::OpenCurrentTrackInfo => Category::Queue,
|
||||
Action::MoveUp
|
||||
| Action::MoveDown
|
||||
| Action::MoveLeft
|
||||
@@ -111,9 +135,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +148,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"),
|
||||
@@ -130,7 +157,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,
|
||||
@@ -162,22 +190,31 @@ 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::MoveQueueUp => "Queue: move track/selection up".into(),
|
||||
Action::MoveQueueDown => "Queue: move track/selection down".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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+166
-36
@@ -2,13 +2,13 @@ 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;
|
||||
use crate::app::state::{AppState, GlobalView, SearchState, Tab};
|
||||
use crate::library::models::SearchResults;
|
||||
|
||||
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180);
|
||||
const SEARCH_LIMIT: i64 = 12;
|
||||
@@ -18,32 +18,32 @@ 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;
|
||||
KeyCode::Up => {
|
||||
if state.cmdline.history_previous() {
|
||||
after_change(state, runtime);
|
||||
}
|
||||
after_change(state, runtime);
|
||||
}
|
||||
KeyCode::Char(c) if is_typing(key) => {
|
||||
state.cmdline.input.push(c);
|
||||
after_change(state, runtime);
|
||||
KeyCode::Down => {
|
||||
if state.cmdline.history_next() {
|
||||
after_change(state, runtime);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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 +59,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 +72,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 +97,19 @@ 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) {
|
||||
state.search.similarity_source = None;
|
||||
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,25 +118,124 @@ 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 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn schedule_similarity_search(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
track: &crate::library::models::TrackItem,
|
||||
) {
|
||||
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let artist = track.artist_line();
|
||||
state.search.query = if artist.is_empty() {
|
||||
track.title.clone()
|
||||
} else {
|
||||
format!("{} — {artist}", track.title)
|
||||
};
|
||||
state.search.similarity_source = Some(track.id);
|
||||
state.search.loading = true;
|
||||
state.search.results = None;
|
||||
state.search.fed_tracks.clear();
|
||||
state.search.fed_artists.clear();
|
||||
state.search.fed_loading = false;
|
||||
state.active_tab = crate::app::state::Tab::Global;
|
||||
if let Some(crate::app::state::GlobalView::Search { cursor }) = state.global.stack.last_mut() {
|
||||
*cursor = 0;
|
||||
} else {
|
||||
state
|
||||
.global
|
||||
.stack
|
||||
.push(crate::app::state::GlobalView::Search { cursor: 0 });
|
||||
}
|
||||
|
||||
let similarity = Arc::clone(&runtime.similarity);
|
||||
let tx = runtime.event_tx.clone();
|
||||
let track_id = track.id;
|
||||
let source_track = track.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = similarity
|
||||
.search_track(track_id, 49)
|
||||
.map(|(matches, query)| {
|
||||
let mut tracks = Vec::with_capacity(1 + matches.len());
|
||||
tracks.push(source_track);
|
||||
tracks.extend(matches.into_iter().map(|found| found.track));
|
||||
(
|
||||
SearchResults {
|
||||
artists: Vec::new(),
|
||||
releases: Vec::new(),
|
||||
tracks,
|
||||
},
|
||||
query,
|
||||
)
|
||||
});
|
||||
let (result, query) = match result {
|
||||
Ok((results, query)) => (Ok(results), Some(query)),
|
||||
Err(err) => (Err(format!("{err:#}")), None),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
let _ = tx.send(AppEvent::SimilaritySearchLoaded { seq, result, query });
|
||||
});
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if state.search.similarity_source.is_some() {
|
||||
return;
|
||||
}
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
/// Enter: close the line. Live commands already took effect (their view
|
||||
/// stays open); one-shot commands execute here.
|
||||
fn commit(state: &mut AppState, runtime: &mut Runtime) {
|
||||
let remembered = state.cmdline.input.as_str().to_string();
|
||||
let parsed = command::parse(&state.cmdline.input);
|
||||
state.cmdline.remember(&remembered);
|
||||
close(state);
|
||||
match parsed {
|
||||
Parsed::Empty => {}
|
||||
@@ -161,7 +263,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 +298,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 +309,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);
|
||||
@@ -216,6 +345,7 @@ fn close(state: &mut AppState) {
|
||||
state.cmdline.active = false;
|
||||
state.cmdline.input.clear();
|
||||
state.cmdline.live = false;
|
||||
state.cmdline.begin_history_navigation();
|
||||
}
|
||||
|
||||
/// Pop the live search view if this command-line session opened it.
|
||||
|
||||
+51
-8
@@ -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))));
|
||||
}
|
||||
|
||||
|
||||
+137
-26
@@ -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,17 @@ pub enum AppEvent {
|
||||
seq: u64,
|
||||
result: Result<SearchResults, String>,
|
||||
},
|
||||
/// Artwork fetched and decoded for the shared art cache.
|
||||
/// Local similar-track search completed. It uses the same sequence as
|
||||
/// text search so stale pages cannot overwrite a newer request.
|
||||
SimilaritySearchLoaded {
|
||||
seq: u64,
|
||||
result: Result<SearchResults, String>,
|
||||
query: Option<crate::similarity::QueryVector>,
|
||||
},
|
||||
SimilarityStatus(crate::similarity::SimilarityStatus),
|
||||
/// `None` is emitted after clearing every stored embedding.
|
||||
SimilarityProfileActivated(Option<String>),
|
||||
/// Artwork loaded and decoded for the shared art cache.
|
||||
ArtLoaded {
|
||||
key: String,
|
||||
art: Option<Arc<ArtImage>>,
|
||||
@@ -41,7 +50,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 +60,30 @@ 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>),
|
||||
MusicDirectoryValidated(Result<std::path::PathBuf, String>),
|
||||
MusicDirectoryChanged(Result<crate::library::MusicRelocationStats, 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 +91,101 @@ 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),
|
||||
/// Current lifecycle/status of the federation Jam.
|
||||
JamStatus(crate::jam::JamStatus),
|
||||
/// Host playback snapshot received by a Jam participant.
|
||||
JamPlayback(crate::devices::PlaybackSnapshot),
|
||||
/// Participant command accepted by this Jam host.
|
||||
JamCommand(crate::devices::PlaybackCommand),
|
||||
/// Result of creating/regenerating a Jam capability.
|
||||
JamInvite(Result<String, String>),
|
||||
/// Result of joining a Jam capability.
|
||||
JamJoined(Result<String, String>),
|
||||
}
|
||||
|
||||
@@ -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(), "металл");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+3289
-996
File diff suppressed because it is too large
Load Diff
+1222
-118
File diff suppressed because it is too large
Load Diff
-134
@@ -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);
|
||||
}
|
||||
}
|
||||
+1420
-156
File diff suppressed because it is too large
Load Diff
+1766
-748
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -56,10 +64,19 @@ key_sequence = "shift-a"
|
||||
command = "QueueAddLast"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-c"
|
||||
command = "ClearQueue"
|
||||
key_sequence = "alt-k"
|
||||
command = "MoveQueueUp"
|
||||
context = "queue"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "alt-j"
|
||||
command = "MoveQueueDown"
|
||||
context = "queue"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-c"
|
||||
command = "OpenConnectedDevices"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "d"
|
||||
command = "RemoveFromQueue"
|
||||
@@ -78,11 +95,6 @@ command = "GoToRelease"
|
||||
key_sequence = "shift-p"
|
||||
command = "AddToPlaylist"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "n"
|
||||
command = "NewPlaylist"
|
||||
context = "playlists"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "j"
|
||||
command = "MoveDown"
|
||||
@@ -191,6 +203,14 @@ 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"
|
||||
@@ -204,17 +224,30 @@ key_sequence = "i"
|
||||
command = "OpenTrackInfo"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-l"
|
||||
command = "Logout"
|
||||
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
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LibrarySourceMode {
|
||||
Local,
|
||||
My,
|
||||
#[default]
|
||||
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 SimilaritySettings {
|
||||
/// Local embedding/search master switch. Network participation follows
|
||||
/// federation and additionally requires the explicit privacy consent.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_similarity_model")]
|
||||
pub model: String,
|
||||
#[serde(default = "default_similarity_profile")]
|
||||
pub profile: String,
|
||||
#[serde(default = "default_similarity_workers")]
|
||||
pub workers: usize,
|
||||
#[serde(default)]
|
||||
pub federation_consent: bool,
|
||||
/// Exact fingerprint of the last fully usable profile. Keeping this
|
||||
/// separate from the selected target lets an old index serve searches
|
||||
/// while a newly selected model/profile is being calculated.
|
||||
#[serde(default)]
|
||||
pub active_profile: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SimilaritySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
model: default_similarity_model(),
|
||||
profile: default_similarity_profile(),
|
||||
workers: default_similarity_workers(),
|
||||
federation_consent: false,
|
||||
active_profile: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AppSettings {
|
||||
#[serde(default = "default_volume")]
|
||||
pub volume: u8,
|
||||
#[serde(default)]
|
||||
pub library: LibraryFilters,
|
||||
/// Root used for music materialized from federation peers.
|
||||
#[serde(default = "default_music_dir")]
|
||||
pub music_dir: PathBuf,
|
||||
#[serde(default)]
|
||||
pub similarity: SimilaritySettings,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
volume: default_volume(),
|
||||
library: LibraryFilters::default(),
|
||||
music_dir: default_music_dir(),
|
||||
similarity: SimilaritySettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
pub fn normalized(mut self) -> Self {
|
||||
self.volume = self.volume.min(100);
|
||||
if self.music_dir.as_os_str().is_empty() {
|
||||
self.music_dir = default_music_dir();
|
||||
}
|
||||
if self.similarity.model.trim().is_empty() {
|
||||
self.similarity.model = default_similarity_model();
|
||||
}
|
||||
if self.similarity.profile.trim().is_empty() {
|
||||
self.similarity.profile = default_similarity_profile();
|
||||
}
|
||||
self.similarity.workers = self.similarity.workers.clamp(1, 16);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn default_volume() -> u8 {
|
||||
80
|
||||
}
|
||||
|
||||
fn default_similarity_model() -> String {
|
||||
"discogs-effnet-bsdynamic-1".to_string()
|
||||
}
|
||||
|
||||
fn default_similarity_profile() -> String {
|
||||
"furumi-full-track-v1".to_string()
|
||||
}
|
||||
|
||||
fn default_similarity_workers() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|count| (count.get() / 2).clamp(1, 4))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// The historical permanent-download location, kept as the default for
|
||||
/// backward compatibility with existing installations.
|
||||
pub fn default_music_dir() -> PathBuf {
|
||||
crate::config::project_dirs()
|
||||
.map(|dirs| dirs.data_dir().join("federation-media"))
|
||||
.unwrap_or_else(|| PathBuf::from("federation-media"))
|
||||
}
|
||||
|
||||
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::Global);
|
||||
assert_eq!(settings.music_dir, default_music_dir());
|
||||
}
|
||||
}
|
||||
+3637
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,558 @@
|
||||
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 playback_commands_are_caught_up_only_by_their_target_while_fresh() {
|
||||
let sync = test_sync();
|
||||
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.record_playback_command("dev_target", command.clone())
|
||||
.unwrap();
|
||||
sync.record_playback_command("dev_other", command).unwrap();
|
||||
|
||||
let target_ops = sync.ops_for_peer("dev_target").unwrap();
|
||||
assert_eq!(
|
||||
target_ops
|
||||
.iter()
|
||||
.filter(|op| matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
sync.ops_for_peer("dev_unknown")
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|op| !matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
|
||||
);
|
||||
|
||||
lock(&sync.conn)
|
||||
.execute(
|
||||
"UPDATE sync_ops
|
||||
SET hlc_ms = ?1
|
||||
WHERE kind = 'playback_command'",
|
||||
[now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS + 1)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
sync.ops_for_peer("dev_target")
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|op| !matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_device_trust_reactivates_revoked_device() {
|
||||
let sync = test_sync();
|
||||
let device_id = "dev_readd";
|
||||
|
||||
sync.apply_device_trusted(device_id, 10).unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_revoked(device_id, 20, "dev_owner", 0)
|
||||
.unwrap();
|
||||
assert!(device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_trusted(device_id, 30).unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_revoked(device_id, 25, "dev_owner", 0)
|
||||
.unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_profile(
|
||||
&DeviceProfileWire {
|
||||
device_id: device_id.to_string(),
|
||||
name: "readded".to_string(),
|
||||
client_version: CLIENT_VERSION.to_string(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
endpoint_id: String::new(),
|
||||
endpoint_ticket: String::new(),
|
||||
revoked: true,
|
||||
revoke_cutoff_seq: Some(0),
|
||||
updated_at_ms: 20,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_gc_waits_for_every_active_remote_ack() {
|
||||
let sync = test_sync();
|
||||
let origin = sync.ensure_identity().unwrap().device_id;
|
||||
sync.apply_device_trusted("dev_a", 1).unwrap();
|
||||
sync.apply_device_trusted("dev_b", 1).unwrap();
|
||||
|
||||
sync.record_local_op(SyncOpPayload::PlaylistDeleted {
|
||||
playlist_id: "pl_deleted".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
{
|
||||
let conn = lock(&sync.conn);
|
||||
let tombstones: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tombstones, 1);
|
||||
}
|
||||
|
||||
let ack = BTreeMap::from([(origin, 1)]);
|
||||
sync.note_peer_vector("dev_a", &ack).unwrap();
|
||||
sync.gc_tombstones().unwrap();
|
||||
{
|
||||
let conn = lock(&sync.conn);
|
||||
let tombstones: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tombstones, 1);
|
||||
}
|
||||
|
||||
sync.note_peer_vector("dev_b", &ack).unwrap();
|
||||
sync.gc_tombstones().unwrap();
|
||||
let conn = lock(&sync.conn);
|
||||
let tombstones: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tombstones, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_carries_deleted_playlists_to_repair_stale_peers() {
|
||||
let source = test_sync();
|
||||
let source_playlist = source.library.create_playlist("Gone").unwrap();
|
||||
let playlist_sync_id = source
|
||||
.library
|
||||
.ensure_playlist_sync_id(source_playlist.id)
|
||||
.unwrap();
|
||||
source
|
||||
.apply_playlist_state(&playlist_sync_id, "Gone", false, 10, "dev_remote:1")
|
||||
.unwrap();
|
||||
source
|
||||
.apply_playlist_state(&playlist_sync_id, "", true, 20, "dev_remote:2")
|
||||
.unwrap();
|
||||
let snapshot = source.snapshot().unwrap();
|
||||
assert!(
|
||||
snapshot
|
||||
.deleted_playlists
|
||||
.iter()
|
||||
.any(|playlist| playlist.playlist_id == playlist_sync_id)
|
||||
);
|
||||
|
||||
let peer = test_sync();
|
||||
let peer_playlist = peer
|
||||
.library
|
||||
.upsert_synced_playlist(&playlist_sync_id, "Gone")
|
||||
.unwrap();
|
||||
assert!(peer.library.playlist(peer_playlist).is_ok());
|
||||
|
||||
peer.apply_snapshot(snapshot).unwrap();
|
||||
assert!(
|
||||
!peer
|
||||
.library
|
||||
.playlists()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|playlist| playlist.title == "Gone")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_fed_like_metadata_repairs_existing_like_state() {
|
||||
let sync = test_sync();
|
||||
let content_id = format!("b3:{}", "a".repeat(64));
|
||||
let fed = test_fed_track(&content_id);
|
||||
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
|
||||
|
||||
assert!(
|
||||
sync.apply_like_state(&content_id, true, None, 10, "dev_remote:1")
|
||||
.unwrap()
|
||||
);
|
||||
assert!(sync.library.fed_like_ids().unwrap().is_empty());
|
||||
|
||||
assert!(
|
||||
sync.apply_like_state(&content_id, true, Some(&synced), 10, "dev_remote:1")
|
||||
.unwrap()
|
||||
);
|
||||
let keys = sync.library.fed_like_ids().unwrap();
|
||||
assert!(keys.contains(&fed.item_id));
|
||||
assert!(keys.contains(&content_id));
|
||||
|
||||
assert!(
|
||||
sync.apply_like_state(&content_id, false, None, 11, "dev_remote:2")
|
||||
.unwrap()
|
||||
);
|
||||
assert!(sync.library.fed_like_ids().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_fed_likes_are_ordered_by_hlc_not_receive_time() {
|
||||
let sync = test_sync();
|
||||
let old_content_id = format!("b3:{}", "c".repeat(64));
|
||||
let new_content_id = format!("b3:{}", "d".repeat(64));
|
||||
let mut old_fed = test_fed_track(&old_content_id);
|
||||
old_fed.item_id = "fed_old".to_string();
|
||||
old_fed.title = "Old Fed".to_string();
|
||||
let mut new_fed = test_fed_track(&new_content_id);
|
||||
new_fed.item_id = "fed_new".to_string();
|
||||
new_fed.title = "New Fed".to_string();
|
||||
|
||||
let new_synced = SyncedFedTrack::from_fed(&new_fed).unwrap();
|
||||
let old_synced = SyncedFedTrack::from_fed(&old_fed).unwrap();
|
||||
sync.apply_like_state(&new_content_id, true, Some(&new_synced), 20, "dev_remote:2")
|
||||
.unwrap();
|
||||
sync.apply_like_state(&old_content_id, true, Some(&old_synced), 10, "dev_remote:1")
|
||||
.unwrap();
|
||||
|
||||
let titles: Vec<String> = sync
|
||||
.library
|
||||
.playlist(crate::library::LIKES_PLAYLIST_ID)
|
||||
.unwrap()
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| track.title)
|
||||
.collect();
|
||||
assert_eq!(titles, vec!["New Fed", "Old Fed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_playlist_item_metadata_creates_pending_fed_track() {
|
||||
let sync = test_sync();
|
||||
let playlist = sync.library.create_playlist("Remote Mix").unwrap();
|
||||
let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap();
|
||||
let content_id = format!("b3:{}", "b".repeat(64));
|
||||
let fed = test_fed_track(&content_id);
|
||||
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
true,
|
||||
3,
|
||||
Some(&synced),
|
||||
10,
|
||||
"dev_remote:2",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let detail = sync.library.playlist(playlist.id).unwrap();
|
||||
assert_eq!(detail.tracks.len(), 1);
|
||||
assert!(detail.tracks[0].is_fed_pending());
|
||||
assert_eq!(detail.tracks[0].title, fed.title);
|
||||
|
||||
let conn = lock(&sync.conn);
|
||||
assert_eq!(
|
||||
sync.unresolved_playlist_item_count_with_conn(&conn)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
false,
|
||||
0,
|
||||
None,
|
||||
11,
|
||||
"dev_remote:3",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_synced_playlist_item_metadata_repairs_pending_fed_track() {
|
||||
let sync = test_sync();
|
||||
let playlist = sync.library.create_playlist("Remote Mix").unwrap();
|
||||
let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap();
|
||||
let content_id = format!("b3:{}", "e".repeat(64));
|
||||
let fed = test_fed_track(&content_id);
|
||||
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
true,
|
||||
7,
|
||||
None,
|
||||
10,
|
||||
"dev_remote:2",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0);
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
true,
|
||||
7,
|
||||
Some(&synced),
|
||||
10,
|
||||
"dev_remote:2",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let detail = sync.library.playlist(playlist.id).unwrap();
|
||||
assert_eq!(detail.tracks.len(), 1);
|
||||
assert!(detail.tracks[0].is_fed_pending());
|
||||
assert_eq!(detail.tracks[0].title, fed.title);
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
//! 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";
|
||||
/// Version of the audio transfer stream protocol.
|
||||
pub const AUDIO_PROTOCOL_VERSION: u16 = 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}");
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Informational publication and observation of protocol versions.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
pub use music_dht::capabilities::CAPABILITIES_ALPN;
|
||||
use music_dht::capabilities::{
|
||||
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, read_message,
|
||||
write_message,
|
||||
};
|
||||
use music_dht::{ByteStream, EndpointId, MusicDhtService, StreamAcceptor};
|
||||
|
||||
const PROBE_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ProtocolVersions {
|
||||
pub local: BTreeMap<String, u16>,
|
||||
pub observed: BTreeMap<String, u16>,
|
||||
pub observed_peers: usize,
|
||||
pub newer: Vec<NewerProtocol>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewerProtocol {
|
||||
pub id: String,
|
||||
pub local: u16,
|
||||
pub observed: u16,
|
||||
}
|
||||
|
||||
impl ProtocolVersions {
|
||||
pub fn snapshot(observed: &ObservedVersions) -> Self {
|
||||
let local = local_manifest().protocols;
|
||||
let observed_versions = lock(&observed.versions).clone();
|
||||
let newer = observed_versions
|
||||
.iter()
|
||||
.filter_map(|(id, remote)| {
|
||||
let local_version = local.get(id)?;
|
||||
(*remote > *local_version).then(|| NewerProtocol {
|
||||
id: id.clone(),
|
||||
local: *local_version,
|
||||
observed: *remote,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
local,
|
||||
observed: observed_versions,
|
||||
observed_peers: lock(&observed.peers).len(),
|
||||
newer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ObservedVersions {
|
||||
versions: Mutex<BTreeMap<String, u16>>,
|
||||
peers: Mutex<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
fn local_manifest() -> CapabilityManifest {
|
||||
CapabilityManifest::frid("furumi", env!("CARGO_PKG_VERSION"))
|
||||
.with_protocol("audio", super::audio::AUDIO_PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
pub async fn serve(mut acceptor: StreamAcceptor) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = serve_one(stream).await {
|
||||
tracing::debug!("capability stream failed: {error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(mut stream: ByteStream) -> Result<()> {
|
||||
let response = match read_message(&mut stream).await? {
|
||||
CapabilityMessage::Get {
|
||||
version: CAPABILITIES_PROTOCOL_VERSION,
|
||||
} => CapabilityMessage::Manifest {
|
||||
manifest: local_manifest(),
|
||||
},
|
||||
CapabilityMessage::Get { version } => CapabilityMessage::Error {
|
||||
message: format!("unsupported capability protocol {version}"),
|
||||
},
|
||||
_ => CapabilityMessage::Error {
|
||||
message: "expected capability request".to_string(),
|
||||
},
|
||||
};
|
||||
write_message(&mut stream, &response).await?;
|
||||
stream.send.finish()?;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn probe_loop(service: Arc<MusicDhtService>, observed: Arc<ObservedVersions>) {
|
||||
let mut interval = tokio::time::interval(PROBE_INTERVAL);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let peers = service
|
||||
.connected_peers()
|
||||
.into_iter()
|
||||
.chain(
|
||||
service
|
||||
.known_peers()
|
||||
.into_iter()
|
||||
.map(|contact| contact.peer_id),
|
||||
)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for peer in peers {
|
||||
if let Err(error) = probe_peer(&service, peer, &observed).await {
|
||||
tracing::trace!(%peer, "peer capability probe unavailable: {error:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_peer(
|
||||
service: &MusicDhtService,
|
||||
peer: EndpointId,
|
||||
observed: &ObservedVersions,
|
||||
) -> Result<()> {
|
||||
let mut stream = service.open_stream(peer, CAPABILITIES_ALPN).await?;
|
||||
write_message(
|
||||
&mut stream,
|
||||
&CapabilityMessage::Get {
|
||||
version: CAPABILITIES_PROTOCOL_VERSION,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let response = tokio::time::timeout(Duration::from_secs(5), read_message(&mut stream))
|
||||
.await
|
||||
.context("capability request timed out")??;
|
||||
let CapabilityMessage::Manifest { manifest } = response else {
|
||||
anyhow::bail!("peer did not return a capability manifest");
|
||||
};
|
||||
manifest.validate()?;
|
||||
{
|
||||
let mut versions = lock(&observed.versions);
|
||||
for (id, version) in manifest.protocols {
|
||||
versions
|
||||
.entry(id)
|
||||
.and_modify(|current| *current = (*current).max(version))
|
||||
.or_insert(version);
|
||||
}
|
||||
}
|
||||
lock(&observed.peers).insert(peer.to_string(), manifest.application_version);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_manifest_lists_every_player_protocol() {
|
||||
let manifest = local_manifest();
|
||||
for id in [
|
||||
"federation_net",
|
||||
"ticket",
|
||||
"rendezvous",
|
||||
"music_dht",
|
||||
"catalog",
|
||||
"audio",
|
||||
"similarity",
|
||||
"device_sync",
|
||||
"jam",
|
||||
] {
|
||||
assert!(manifest.protocols.contains_key(id), "missing {id}");
|
||||
}
|
||||
manifest.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_reports_only_strictly_newer_versions() {
|
||||
let observed = ObservedVersions::default();
|
||||
lock(&observed.versions).insert("music_dht".to_string(), 99);
|
||||
lock(&observed.versions).insert("jam".to_string(), crate::jam::PROTOCOL_VERSION);
|
||||
let snapshot = ProtocolVersions::snapshot(&observed);
|
||||
assert_eq!(snapshot.newer.len(), 1);
|
||||
assert_eq!(snapshot.newer[0].id, "music_dht");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,277 @@
|
||||
//! Furumi policy and local-index adapter for the shared similarity protocol.
|
||||
//!
|
||||
//! `music_dht::similarity` owns the versioned wire contract and framing. This
|
||||
//! module owns application policy: consent, peer fan-out, local index access,
|
||||
//! result conversion, deduplication, and ranking limits.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use futures_util::stream::{self, StreamExt as _};
|
||||
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
|
||||
|
||||
use crate::federation::{FedSearchResults, FedTrack, TransportStats};
|
||||
use crate::similarity::{Manager, QueryVector};
|
||||
|
||||
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||
|
||||
const MAX_QUERY_PEERS: usize = 16;
|
||||
const QUERY_CONCURRENCY: usize = 6;
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_PER_ARTIST: usize = 3;
|
||||
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||
|
||||
pub async fn serve_peers(
|
||||
mut acceptor: StreamAcceptor,
|
||||
similarity: Arc<Manager>,
|
||||
own: EndpointId,
|
||||
transport: Arc<TransportStats>,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let similarity = Arc::clone(&similarity);
|
||||
let transport = Arc::clone(&transport);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_one(stream, similarity, own, transport).await {
|
||||
tracing::warn!(peer = %peer, "similarity request failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(
|
||||
mut stream: ByteStream,
|
||||
similarity: Arc<Manager>,
|
||||
own: EndpointId,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<()> {
|
||||
super::record_stream_transport(&transport, "similarity", "inbound", "open", &stream);
|
||||
let request = wire::read_request(&mut stream).await?;
|
||||
let response = if !similarity.network_allowed() {
|
||||
SimilarityResponse::refused("similarity federation is disabled or has no privacy consent")?
|
||||
} else {
|
||||
let profile = request.profile_id;
|
||||
let vector = request.vector;
|
||||
let limit = request.limit;
|
||||
let matches = tokio::task::spawn_blocking(move || {
|
||||
similarity.search_vector(&profile, &vector, None, None, limit)
|
||||
})
|
||||
.await
|
||||
.context("local similarity task failed")
|
||||
.and_then(|result| result);
|
||||
match matches {
|
||||
Ok(matches) => {
|
||||
let hits = matches
|
||||
.into_iter()
|
||||
.filter_map(|found| {
|
||||
let track = found.track;
|
||||
let hit = SimilarityHit {
|
||||
score: found.score,
|
||||
item_id: super::audio::hex_encode(
|
||||
ItemId::derive(
|
||||
&own,
|
||||
ItemKind::Track,
|
||||
&format!("track:{}", track.id),
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
title: track.title,
|
||||
artist_names: track
|
||||
.artists
|
||||
.into_iter()
|
||||
.map(|artist| artist.name)
|
||||
.collect(),
|
||||
featured_artist_names: track
|
||||
.featured_artists
|
||||
.into_iter()
|
||||
.map(|artist| artist.name)
|
||||
.collect(),
|
||||
year: track.release_year,
|
||||
duration_seconds: Some(track.duration_seconds.round() as i64),
|
||||
content_id: track.content_id,
|
||||
release_title: Some(track.release_title),
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
embedding_signature: Some(found.embedding_signature),
|
||||
};
|
||||
match hit.validate() {
|
||||
Ok(()) => Some(hit),
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "invalid local similarity metadata skipped");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
SimilarityResponse::success(hits)?
|
||||
}
|
||||
Err(err) => {
|
||||
SimilarityResponse::refused(format!("similarity query is unavailable: {err:#}"))?
|
||||
}
|
||||
}
|
||||
};
|
||||
wire::write_response(&mut stream, &response).await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
super::record_stream_transport(&transport, "similarity", "inbound", "done", &stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
service: Arc<MusicDhtService>,
|
||||
query: QueryVector,
|
||||
limit: usize,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<FedSearchResults> {
|
||||
let own = service.endpoint_id();
|
||||
let mut peers = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for peer in service
|
||||
.connected_peers()
|
||||
.into_iter()
|
||||
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
||||
{
|
||||
if peer != own && seen.insert(peer) {
|
||||
peers.push(peer);
|
||||
}
|
||||
if peers.len() >= MAX_QUERY_PEERS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let query_signature = wire::embedding_signature(&query.vector)?;
|
||||
let request = Arc::new(SimilarityRequest::new(
|
||||
query.profile_id,
|
||||
query.vector,
|
||||
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
||||
)?);
|
||||
|
||||
let responses = stream::iter(peers.into_iter().map(|peer| {
|
||||
let service = Arc::clone(&service);
|
||||
let request = Arc::clone(&request);
|
||||
let transport = Arc::clone(&transport);
|
||||
async move {
|
||||
tokio::time::timeout(
|
||||
QUERY_TIMEOUT,
|
||||
query_peer(service, peer, &request, transport),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(QUERY_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
let mut hits = Vec::new();
|
||||
for response in responses {
|
||||
match response {
|
||||
Ok(peer_hits) => hits.extend(peer_hits),
|
||||
Err(err) => tracing::debug!(%err, "similarity peer query skipped"),
|
||||
}
|
||||
}
|
||||
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||
let mut dedup = HashSet::new();
|
||||
let mut embedding_signatures = vec![query_signature];
|
||||
let mut artist_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut tracks = Vec::new();
|
||||
for (track, _, embedding_signature) in hits {
|
||||
if query
|
||||
.source_content_id
|
||||
.as_deref()
|
||||
.is_some_and(|source| track.content_id.as_deref() == Some(source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let key = track
|
||||
.content_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}:{}", track.owner, track.item_id));
|
||||
if !dedup.insert(key) {
|
||||
continue;
|
||||
}
|
||||
if embedding_signature.is_some_and(|candidate| {
|
||||
embedding_signatures.iter().any(|existing| {
|
||||
wire::signature_distance(&candidate, existing)
|
||||
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||
})
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let artist = track
|
||||
.artist_names
|
||||
.first()
|
||||
.map(|name| music_dht::normalize_name(name))
|
||||
.unwrap_or_default();
|
||||
let count = artist_counts.entry(artist.clone()).or_default();
|
||||
if !artist.is_empty() && *count >= MAX_PER_ARTIST {
|
||||
continue;
|
||||
}
|
||||
*count += 1;
|
||||
if let Some(signature) = embedding_signature {
|
||||
embedding_signatures.push(signature);
|
||||
}
|
||||
tracks.push(track);
|
||||
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(FedSearchResults {
|
||||
artists: Vec::new(),
|
||||
tracks,
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_peer(
|
||||
service: Arc<MusicDhtService>,
|
||||
owner: EndpointId,
|
||||
request: &SimilarityRequest,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<
|
||||
Vec<(
|
||||
FedTrack,
|
||||
f32,
|
||||
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
||||
)>,
|
||||
> {
|
||||
let mut stream = service
|
||||
.open_stream(owner, SIMILARITY_ALPN)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("cannot reach similarity peer: {err}"))?;
|
||||
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
|
||||
let response = wire::exchange(&mut stream, request).await?;
|
||||
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
||||
anyhow::ensure!(
|
||||
response.ok,
|
||||
"peer refused similarity query: {}",
|
||||
response.error.unwrap_or_default()
|
||||
);
|
||||
Ok(response
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| {
|
||||
let score = hit.score;
|
||||
let embedding_signature = hit.embedding_signature;
|
||||
(
|
||||
FedTrack {
|
||||
item_id: hit.item_id,
|
||||
owner: owner.to_string(),
|
||||
own: false,
|
||||
title: hit.title,
|
||||
artist_names: hit.artist_names,
|
||||
featured_artist_names: hit.featured_artist_names,
|
||||
year: hit.year,
|
||||
duration_seconds: hit.duration_seconds,
|
||||
content_id: hit.content_id,
|
||||
release_title: hit.release_title,
|
||||
track_number: hit.track_number,
|
||||
disc_number: hit.disc_number,
|
||||
},
|
||||
score,
|
||||
embedding_signature,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -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, "Ежемесячные");
|
||||
}
|
||||
+728
@@ -0,0 +1,728 @@
|
||||
//! Capability-based Jam playback control for independent federation peers.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::{ByteStream, MusicDhtService, PeerTicket, StreamAcceptor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::app::event::AppEvent;
|
||||
use crate::devices::{PlaybackCommand, PlaybackSnapshot};
|
||||
|
||||
pub const JAM_ALPN: &[u8] = b"furumi/jam/1";
|
||||
pub const PROTOCOL_VERSION: u16 = 1;
|
||||
const MAX_LINE: usize = 8 * 1024 * 1024;
|
||||
const MAX_COMMANDS: usize = 128;
|
||||
const PARTICIPANT_TTL_MS: i64 = 30 * 60 * 1_000;
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum JamRole {
|
||||
None,
|
||||
Host,
|
||||
Participant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JamStatus {
|
||||
pub role: JamRole,
|
||||
pub host_name: Option<String>,
|
||||
pub invite: Option<String>,
|
||||
pub participants: Vec<JamParticipant>,
|
||||
pub connected: bool,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for JamStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
role: JamRole::None,
|
||||
host_name: None,
|
||||
invite: None,
|
||||
participants: Vec::new(),
|
||||
connected: false,
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JamParticipant {
|
||||
pub participant_id: String,
|
||||
pub name: String,
|
||||
pub last_seen_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct JamInvite {
|
||||
v: u16,
|
||||
#[serde(rename = "t")]
|
||||
ticket: String,
|
||||
#[serde(rename = "j")]
|
||||
jam_id: String,
|
||||
#[serde(rename = "s")]
|
||||
secret: String,
|
||||
#[serde(rename = "d")]
|
||||
host_device_id: String,
|
||||
#[serde(rename = "n")]
|
||||
host_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct JamCommand {
|
||||
command_id: String,
|
||||
participant_id: String,
|
||||
command: PlaybackCommand,
|
||||
sent_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum WireMessage {
|
||||
Poll {
|
||||
version: u16,
|
||||
jam_id: String,
|
||||
secret: String,
|
||||
participant: JamParticipant,
|
||||
#[serde(default)]
|
||||
commands: Vec<JamCommand>,
|
||||
},
|
||||
Snapshot {
|
||||
accepted: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
acknowledged_command_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
playback: Option<PlaybackSnapshot>,
|
||||
#[serde(default)]
|
||||
participants: Vec<JamParticipant>,
|
||||
host_time_ms: i64,
|
||||
},
|
||||
Leave {
|
||||
version: u16,
|
||||
jam_id: String,
|
||||
secret: String,
|
||||
participant_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HostState {
|
||||
invite: Option<JamInvite>,
|
||||
invite_uri: Option<String>,
|
||||
playback: Option<PlaybackSnapshot>,
|
||||
participants: HashMap<String, JamParticipant>,
|
||||
seen_commands: HashSet<String>,
|
||||
seen_order: VecDeque<String>,
|
||||
}
|
||||
|
||||
struct JoinedState {
|
||||
invite: JamInvite,
|
||||
participant: JamParticipant,
|
||||
pending: VecDeque<JamCommand>,
|
||||
participants: Vec<JamParticipant>,
|
||||
connected: bool,
|
||||
last_error: Option<String>,
|
||||
last_activity_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
host: HostState,
|
||||
joined: Option<JoinedState>,
|
||||
}
|
||||
|
||||
pub struct JamManager {
|
||||
state: Mutex<State>,
|
||||
event_tx: mpsc::UnboundedSender<AppEvent>,
|
||||
}
|
||||
|
||||
impl JamManager {
|
||||
pub fn new(event_tx: mpsc::UnboundedSender<AppEvent>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
state: Mutex::new(State::default()),
|
||||
event_tx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_or_regenerate(
|
||||
&self,
|
||||
service: Arc<MusicDhtService>,
|
||||
host_device_id: String,
|
||||
host_name: String,
|
||||
) -> Result<String> {
|
||||
let invite = JamInvite {
|
||||
v: PROTOCOL_VERSION,
|
||||
ticket: service.ticket().await?.to_string(),
|
||||
jam_id: format!("jam_{}", random_hex(12)),
|
||||
secret: random_hex(32),
|
||||
host_device_id,
|
||||
host_name,
|
||||
};
|
||||
let uri = encode_invite(&invite)?;
|
||||
let mut state = lock(&self.state);
|
||||
state.joined = None;
|
||||
state.host = HostState {
|
||||
invite: Some(invite),
|
||||
invite_uri: Some(uri.clone()),
|
||||
..HostState::default()
|
||||
};
|
||||
Ok(uri)
|
||||
}
|
||||
|
||||
pub fn join(&self, uri: &str, participant_id: String, name: String) -> Result<()> {
|
||||
let invite = parse_invite(uri)?;
|
||||
let mut state = lock(&self.state);
|
||||
state.host = HostState::default();
|
||||
state.joined = Some(JoinedState {
|
||||
invite,
|
||||
participant: JamParticipant {
|
||||
participant_id,
|
||||
name,
|
||||
last_seen_ms: now_ms(),
|
||||
},
|
||||
pending: VecDeque::new(),
|
||||
participants: Vec::new(),
|
||||
connected: false,
|
||||
last_error: None,
|
||||
last_activity_ms: now_ms(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn leave(&self) {
|
||||
let mut state = lock(&self.state);
|
||||
state.joined = None;
|
||||
state.host = HostState::default();
|
||||
}
|
||||
|
||||
pub fn status(&self) -> JamStatus {
|
||||
let state = lock(&self.state);
|
||||
if let Some(joined) = &state.joined {
|
||||
return JamStatus {
|
||||
role: JamRole::Participant,
|
||||
host_name: Some(joined.invite.host_name.clone()),
|
||||
invite: None,
|
||||
participants: joined.participants.clone(),
|
||||
connected: joined.connected,
|
||||
last_error: joined.last_error.clone(),
|
||||
};
|
||||
}
|
||||
if let Some(invite) = &state.host.invite {
|
||||
return JamStatus {
|
||||
role: JamRole::Host,
|
||||
host_name: Some(invite.host_name.clone()),
|
||||
invite: state.host.invite_uri.clone(),
|
||||
participants: state.host.participants.values().cloned().collect(),
|
||||
connected: true,
|
||||
last_error: None,
|
||||
};
|
||||
}
|
||||
JamStatus::default()
|
||||
}
|
||||
|
||||
pub fn publish_host_playback(&self, snapshot: PlaybackSnapshot) {
|
||||
let mut state = lock(&self.state);
|
||||
if state.host.invite.is_some() {
|
||||
state.host.playback = Some(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit_command(&self, command: PlaybackCommand) -> Result<()> {
|
||||
let mut state = lock(&self.state);
|
||||
let joined = state
|
||||
.joined
|
||||
.as_mut()
|
||||
.context("this player is not controlling a Jam")?;
|
||||
if joined.pending.len() >= MAX_COMMANDS {
|
||||
joined.pending.pop_front();
|
||||
}
|
||||
joined.pending.push_back(JamCommand {
|
||||
command_id: format!("cmd_{}", random_hex(16)),
|
||||
participant_id: joined.participant.participant_id.clone(),
|
||||
command,
|
||||
sent_at_ms: now_ms(),
|
||||
});
|
||||
joined.last_activity_ms = now_ms();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn poll_once(&self, service: Arc<MusicDhtService>) -> Result<()> {
|
||||
let (invite, participant, commands) = {
|
||||
let state = lock(&self.state);
|
||||
let joined = state.joined.as_ref().context("not joined")?;
|
||||
(
|
||||
joined.invite.clone(),
|
||||
joined.participant.clone(),
|
||||
joined.pending.iter().cloned().collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
let ticket: PeerTicket = invite.ticket.parse().context("invalid Jam host ticket")?;
|
||||
let peer = service.connect(ticket).await?;
|
||||
let mut stream = service.open_stream(peer, JAM_ALPN).await?;
|
||||
write_message(
|
||||
&mut stream,
|
||||
&WireMessage::Poll {
|
||||
version: PROTOCOL_VERSION,
|
||||
jam_id: invite.jam_id.clone(),
|
||||
secret: invite.secret.clone(),
|
||||
participant,
|
||||
commands,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let response = read_message(&mut stream).await?;
|
||||
let WireMessage::Snapshot {
|
||||
accepted,
|
||||
error,
|
||||
acknowledged_command_ids,
|
||||
playback,
|
||||
participants,
|
||||
..
|
||||
} = response
|
||||
else {
|
||||
anyhow::bail!("unexpected Jam response");
|
||||
};
|
||||
anyhow::ensure!(
|
||||
accepted,
|
||||
"{}",
|
||||
error.unwrap_or_else(|| "Jam refused".into())
|
||||
);
|
||||
{
|
||||
let mut state = lock(&self.state);
|
||||
let Some(joined) = state.joined.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
if joined.invite.jam_id != invite.jam_id {
|
||||
return Ok(());
|
||||
}
|
||||
let acknowledged = acknowledged_command_ids.into_iter().collect::<HashSet<_>>();
|
||||
joined
|
||||
.pending
|
||||
.retain(|command| !acknowledged.contains(&command.command_id));
|
||||
joined.participants = participants;
|
||||
joined.connected = true;
|
||||
joined.last_error = None;
|
||||
joined.participant.last_seen_ms = now_ms();
|
||||
if playback
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| snapshot.state.playing && !snapshot.state.paused)
|
||||
{
|
||||
joined.last_activity_ms = now_ms();
|
||||
}
|
||||
}
|
||||
if let Some(playback) = playback {
|
||||
let _ = self.event_tx.send(AppEvent::JamPlayback(playback));
|
||||
}
|
||||
let _ = self.event_tx.send(AppEvent::JamStatus(self.status()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve_peers(mut acceptor: StreamAcceptor, manager: Arc<JamManager>) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = serve_one(stream, manager).await {
|
||||
tracing::debug!("Jam stream failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(mut stream: ByteStream, manager: Arc<JamManager>) -> Result<()> {
|
||||
match read_message(&mut stream).await? {
|
||||
WireMessage::Poll {
|
||||
version,
|
||||
jam_id,
|
||||
secret,
|
||||
mut participant,
|
||||
commands,
|
||||
} => {
|
||||
let (accepted, error, acknowledged, playback, participants) = {
|
||||
let mut state = lock(&manager.state);
|
||||
let valid =
|
||||
version == PROTOCOL_VERSION
|
||||
&& state.host.invite.as_ref().is_some_and(|invite| {
|
||||
invite.jam_id == jam_id && invite.secret == secret
|
||||
});
|
||||
if !valid {
|
||||
(
|
||||
false,
|
||||
Some("invalid Jam capability".to_string()),
|
||||
vec![],
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
} else {
|
||||
let now = now_ms();
|
||||
state.host.participants.retain(|_, row| {
|
||||
now.saturating_sub(row.last_seen_ms) <= PARTICIPANT_TTL_MS
|
||||
});
|
||||
participant.last_seen_ms = now;
|
||||
state
|
||||
.host
|
||||
.participants
|
||||
.insert(participant.participant_id.clone(), participant);
|
||||
let mut acknowledged = Vec::new();
|
||||
for command in commands.into_iter().take(MAX_COMMANDS) {
|
||||
acknowledged.push(command.command_id.clone());
|
||||
if state.host.seen_commands.insert(command.command_id.clone()) {
|
||||
state.host.seen_order.push_back(command.command_id.clone());
|
||||
let _ = manager.event_tx.send(AppEvent::JamCommand(command.command));
|
||||
}
|
||||
}
|
||||
while state.host.seen_order.len() > 4096 {
|
||||
if let Some(id) = state.host.seen_order.pop_front() {
|
||||
state.host.seen_commands.remove(&id);
|
||||
}
|
||||
}
|
||||
(
|
||||
true,
|
||||
None,
|
||||
acknowledged,
|
||||
state.host.playback.clone(),
|
||||
state.host.participants.values().cloned().collect(),
|
||||
)
|
||||
}
|
||||
};
|
||||
write_message(
|
||||
&mut stream,
|
||||
&WireMessage::Snapshot {
|
||||
accepted,
|
||||
error,
|
||||
acknowledged_command_ids: acknowledged,
|
||||
playback,
|
||||
participants,
|
||||
host_time_ms: now_ms(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
|
||||
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
||||
}
|
||||
WireMessage::Leave {
|
||||
version,
|
||||
jam_id,
|
||||
secret,
|
||||
participant_id,
|
||||
} => {
|
||||
let mut state = lock(&manager.state);
|
||||
if version == PROTOCOL_VERSION
|
||||
&& state
|
||||
.host
|
||||
.invite
|
||||
.as_ref()
|
||||
.is_some_and(|invite| invite.jam_id == jam_id && invite.secret == secret)
|
||||
{
|
||||
state.host.participants.remove(&participant_id);
|
||||
}
|
||||
drop(state);
|
||||
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
||||
}
|
||||
WireMessage::Snapshot { .. } => anyhow::bail!("unexpected Jam snapshot"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn poll_loop(manager: Arc<JamManager>, service: Arc<MusicDhtService>) {
|
||||
let mut interval = tokio::time::interval(POLL_INTERVAL);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if manager.status().role != JamRole::Participant {
|
||||
continue;
|
||||
}
|
||||
let expired = {
|
||||
let state = lock(&manager.state);
|
||||
state.joined.as_ref().is_some_and(|joined| {
|
||||
now_ms().saturating_sub(joined.last_activity_ms) > PARTICIPANT_TTL_MS
|
||||
})
|
||||
};
|
||||
if expired {
|
||||
manager.leave();
|
||||
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
||||
let _ = manager.event_tx.send(AppEvent::StatusMessage(
|
||||
"Jam ended after 30 minutes without playback or commands".into(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = manager.poll_once(Arc::clone(&service)).await {
|
||||
{
|
||||
let mut state = lock(&manager.state);
|
||||
if let Some(joined) = state.joined.as_mut() {
|
||||
joined.connected = false;
|
||||
joined.last_error = Some(format!("{err:#}"));
|
||||
}
|
||||
}
|
||||
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_message(stream: &mut ByteStream, message: &WireMessage) -> Result<()> {
|
||||
let mut bytes = serde_json::to_vec(message)?;
|
||||
anyhow::ensure!(bytes.len() <= MAX_LINE, "Jam message is too large");
|
||||
bytes.push(b'\n');
|
||||
stream.send.write_all(&bytes).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_message(stream: &mut ByteStream) -> Result<WireMessage> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut byte = [0_u8; 1];
|
||||
loop {
|
||||
let read = stream.recv.read(&mut byte).await?;
|
||||
if read.unwrap_or(0) == 0 || byte[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
bytes.push(byte[0]);
|
||||
anyhow::ensure!(bytes.len() <= MAX_LINE, "Jam message is too large");
|
||||
}
|
||||
anyhow::ensure!(!bytes.is_empty(), "empty Jam message");
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
|
||||
fn encode_invite(invite: &JamInvite) -> Result<String> {
|
||||
Ok(format!(
|
||||
"frid://j/{}",
|
||||
base64url_encode(&serde_json::to_vec(invite)?)
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_invite(uri: &str) -> Result<JamInvite> {
|
||||
let encoded = uri
|
||||
.trim()
|
||||
.strip_prefix("frid://j/")
|
||||
.context("expected frid://j invite")?;
|
||||
let invite: JamInvite = serde_json::from_slice(&base64url_decode(encoded)?)?;
|
||||
anyhow::ensure!(
|
||||
invite.v == PROTOCOL_VERSION,
|
||||
"unsupported Jam invite version"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!invite.ticket.is_empty()
|
||||
&& !invite.jam_id.is_empty()
|
||||
&& invite.secret.len() >= 16
|
||||
&& !invite.host_device_id.is_empty(),
|
||||
"incomplete Jam invite"
|
||||
);
|
||||
Ok(invite)
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn random_hex(bytes: usize) -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
let seed = format!(
|
||||
"{}:{}:{}",
|
||||
now_ms(),
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let hash = blake3::hash(seed.as_bytes()).to_hex().to_string();
|
||||
hash[..(bytes * 2).min(hash.len())].to_string()
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn base64url_encode(bytes: &[u8]) -> String {
|
||||
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let mut out = String::new();
|
||||
for chunk in bytes.chunks(3) {
|
||||
let b0 = chunk[0];
|
||||
let b1 = chunk.get(1).copied().unwrap_or(0);
|
||||
let b2 = chunk.get(2).copied().unwrap_or(0);
|
||||
out.push(TABLE[(b0 >> 2) as usize] as char);
|
||||
out.push(TABLE[(((b0 & 3) << 4) | (b1 >> 4)) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
out.push(TABLE[(((b1 & 15) << 2) | (b2 >> 6)) as usize] as char);
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
out.push(TABLE[(b2 & 63) as usize] as char);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base64url_decode(value: &str) -> Result<Vec<u8>> {
|
||||
fn decode(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'A'..=b'Z' => Some(byte - b'A'),
|
||||
b'a'..=b'z' => Some(byte - b'a' + 26),
|
||||
b'0'..=b'9' => Some(byte - b'0' + 52),
|
||||
b'-' => Some(62),
|
||||
b'_' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let bytes = value.as_bytes();
|
||||
anyhow::ensure!(bytes.len() % 4 != 1, "invalid base64url Jam invite");
|
||||
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let a = decode(bytes[i]).context("invalid base64url Jam invite")?;
|
||||
let b = decode(*bytes.get(i + 1).context("truncated Jam invite")?)
|
||||
.context("invalid base64url Jam invite")?;
|
||||
let c = bytes.get(i + 2).and_then(|byte| decode(*byte));
|
||||
let d = bytes.get(i + 3).and_then(|byte| decode(*byte));
|
||||
out.push((a << 2) | (b >> 4));
|
||||
if let Some(c) = c {
|
||||
out.push((b << 4) | (c >> 2));
|
||||
if let Some(d) = d {
|
||||
out.push((c << 6) | d);
|
||||
}
|
||||
}
|
||||
i += 4;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn invite_round_trips_and_is_separate_from_pairing() {
|
||||
let invite = JamInvite {
|
||||
v: PROTOCOL_VERSION,
|
||||
ticket: "ticket".into(),
|
||||
jam_id: "jam_test".into(),
|
||||
secret: "0123456789abcdef".into(),
|
||||
host_device_id: "dev_host".into(),
|
||||
host_name: "Host".into(),
|
||||
};
|
||||
let uri = encode_invite(&invite).unwrap();
|
||||
assert!(uri.starts_with("frid://j/"));
|
||||
assert_eq!(parse_invite(&uri).unwrap().jam_id, "jam_test");
|
||||
assert!(parse_invite("frid://i/abcd").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn participant_receives_host_state_and_host_receives_command() {
|
||||
let unique = random_hex(8);
|
||||
let host_dir = std::env::temp_dir().join(format!("furumi-jam-host-{unique}"));
|
||||
let guest_dir = std::env::temp_dir().join(format!("furumi-jam-guest-{unique}"));
|
||||
std::fs::create_dir_all(&host_dir).unwrap();
|
||||
std::fs::create_dir_all(&guest_dir).unwrap();
|
||||
let network = music_dht::NetworkId::from_name(&format!("jam-test-{unique}"));
|
||||
let host_config = music_dht::MusicDhtConfig::builder()
|
||||
.data_dir(&host_dir)
|
||||
.network_id(network)
|
||||
.stream_protocol(JAM_ALPN)
|
||||
.build()
|
||||
.unwrap();
|
||||
let guest_config = music_dht::MusicDhtConfig::builder()
|
||||
.data_dir(&guest_dir)
|
||||
.network_id(network)
|
||||
.stream_protocol(JAM_ALPN)
|
||||
.build()
|
||||
.unwrap();
|
||||
let (host_service, mut host_events) = music_dht::MusicDhtService::start(host_config)
|
||||
.await
|
||||
.unwrap();
|
||||
let (guest_service, mut guest_events) = music_dht::MusicDhtService::start(guest_config)
|
||||
.await
|
||||
.unwrap();
|
||||
let host_service = Arc::new(host_service);
|
||||
let guest_service = Arc::new(guest_service);
|
||||
let host_drain = tokio::spawn(async move { while host_events.recv().await.is_some() {} });
|
||||
let guest_drain = tokio::spawn(async move { while guest_events.recv().await.is_some() {} });
|
||||
|
||||
let (host_tx, mut host_rx) = mpsc::unbounded_channel();
|
||||
let (guest_tx, mut guest_rx) = mpsc::unbounded_channel();
|
||||
let host = JamManager::new(host_tx);
|
||||
let guest = JamManager::new(guest_tx);
|
||||
let invite = host
|
||||
.create_or_regenerate(Arc::clone(&host_service), "dev_host".into(), "Host".into())
|
||||
.await
|
||||
.unwrap();
|
||||
guest
|
||||
.join(&invite, "dev_guest".into(), "Guest".into())
|
||||
.unwrap();
|
||||
|
||||
let playback_state = crate::devices::PlaybackStateWire {
|
||||
queue: Vec::new(),
|
||||
queue_pos: 0,
|
||||
playing: false,
|
||||
paused: false,
|
||||
idle_since_ms: Some(now_ms()),
|
||||
position_secs: 0.0,
|
||||
volume: 73,
|
||||
shuffle: false,
|
||||
repeat: crate::devices::PlaybackRepeat::Off,
|
||||
};
|
||||
host.publish_host_playback(PlaybackSnapshot {
|
||||
device_id: "dev_host".into(),
|
||||
device_name: "Host".into(),
|
||||
active: true,
|
||||
updated_at_ms: now_ms(),
|
||||
state: playback_state.clone(),
|
||||
});
|
||||
let acceptor = host_service.stream_acceptor(JAM_ALPN).unwrap();
|
||||
let server = tokio::spawn(serve_peers(acceptor, Arc::clone(&host)));
|
||||
|
||||
guest.poll_once(Arc::clone(&guest_service)).await.unwrap();
|
||||
let playback = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
if let Some(AppEvent::JamPlayback(snapshot)) = guest_rx.recv().await {
|
||||
break snapshot;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(playback.device_id, "dev_host");
|
||||
assert_eq!(playback.state.volume, 73);
|
||||
|
||||
guest
|
||||
.submit_command(PlaybackCommand::SetState {
|
||||
state: crate::devices::PlaybackStateWire {
|
||||
paused: true,
|
||||
..playback_state
|
||||
},
|
||||
seek: false,
|
||||
})
|
||||
.unwrap();
|
||||
guest.poll_once(Arc::clone(&guest_service)).await.unwrap();
|
||||
let command = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
if let Some(AppEvent::JamCommand(command)) = host_rx.recv().await {
|
||||
break command;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
command,
|
||||
PlaybackCommand::SetState {
|
||||
state: crate::devices::PlaybackStateWire { paused: true, .. },
|
||||
seek: false
|
||||
}
|
||||
));
|
||||
|
||||
server.abort();
|
||||
host_drain.abort();
|
||||
guest_drain.abort();
|
||||
host_service.shutdown().await.unwrap();
|
||||
guest_service.shutdown().await.unwrap();
|
||||
let _ = std::fs::remove_dir_all(host_dir);
|
||||
let _ = std::fs::remove_dir_all(guest_dir);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+3481
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_test_dir(label: &str) -> std::path::PathBuf {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("furumi-{label}-{}-{unique}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_music_relocation_builds_artist_release_tree_and_updates_paths() {
|
||||
let root = unique_test_dir("music-relocation");
|
||||
let old = root.join("old");
|
||||
let new = root.join("new");
|
||||
let covers = root.join("covers");
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::create_dir_all(&covers).unwrap();
|
||||
let audio = old.join("legacy.flac");
|
||||
let cover = covers.join("release.jpg");
|
||||
let artist_image = covers.join("artist.png");
|
||||
std::fs::write(&audio, b"audio").unwrap();
|
||||
std::fs::write(&cover, b"cover").unwrap();
|
||||
std::fs::write(&artist_image, b"artist").unwrap();
|
||||
|
||||
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();
|
||||
let library = Library {
|
||||
conn: Mutex::new(conn),
|
||||
db_path: root.join("library.db"),
|
||||
covers_dir: covers,
|
||||
};
|
||||
let track_id = import::upsert_track(
|
||||
&library,
|
||||
&import::TrackImport {
|
||||
file_path: audio.to_string_lossy().into_owned(),
|
||||
title: "Song".into(),
|
||||
artists: vec!["Artist".into()],
|
||||
featured_artists: vec![],
|
||||
album_artists: vec!["Artist".into()],
|
||||
release_title: "Release".into(),
|
||||
release_type: Some("album".into()),
|
||||
year: Some(2026),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
duration_seconds: 1.0,
|
||||
audio_format: Some("flac".into()),
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: Some(5),
|
||||
cover: None,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
let (release_id, artist_id): (i64, i64) = library
|
||||
.lock()
|
||||
.query_row(
|
||||
"SELECT t.release_id, ta.artist_id
|
||||
FROM tracks t JOIN track_artists ta ON ta.track_id = t.id
|
||||
WHERE t.id = ?1 AND ta.role = 'main'",
|
||||
[track_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
library
|
||||
.lock()
|
||||
.execute(
|
||||
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
|
||||
params![release_id, cover.to_string_lossy()],
|
||||
)
|
||||
.unwrap();
|
||||
library
|
||||
.lock()
|
||||
.execute(
|
||||
"UPDATE artists SET image_path = ?2 WHERE id = ?1",
|
||||
params![artist_id, artist_image.to_string_lossy()],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let stats = library.relocate_managed_music(&old, &new).unwrap();
|
||||
assert_eq!(stats.tracks, 1);
|
||||
assert_eq!(stats.images, 2);
|
||||
let track = library.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
||||
assert_eq!(
|
||||
track.file_path,
|
||||
new.join("Artist/Release/legacy.flac").to_string_lossy()
|
||||
);
|
||||
assert_eq!(
|
||||
track.cover_path.as_deref(),
|
||||
Some(
|
||||
new.join("Artist/Release/cover.jpg")
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
)
|
||||
);
|
||||
let image: String = library
|
||||
.lock()
|
||||
.query_row(
|
||||
"SELECT image_path FROM artists WHERE id = ?1",
|
||||
[artist_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(image, new.join("Artist/artist.png").to_string_lossy());
|
||||
assert!(!audio.exists());
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_directory_validation_rejects_a_file_without_touching_it() {
|
||||
let root = unique_test_dir("music-validation");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let file = root.join("not-a-directory");
|
||||
std::fs::write(&file, b"keep").unwrap();
|
||||
|
||||
assert!(Library::validate_music_directory(&file).is_err());
|
||||
assert_eq!(std::fs::read(&file).unwrap(), b"keep");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_music_names_are_portable_to_windows() {
|
||||
assert_eq!(storage_name("Artist/Name", "fallback"), "Artist_Name");
|
||||
assert_eq!(storage_name("CON", "fallback"), "_CON");
|
||||
assert_eq!(storage_name("lpt9.live", "fallback"), "_lpt9.live");
|
||||
assert_eq!(storage_name("...", "fallback"), "fallback");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_embeddings_round_trip_and_keep_profiles_separate() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
for profile in ["profile-a", "profile-b"] {
|
||||
lib.ensure_similarity_profile(profile, "model", "1", "sha", "prep", 3)
|
||||
.unwrap();
|
||||
}
|
||||
let track = lib
|
||||
.pending_similarity_tracks("profile-a")
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|track| track.id == track_id)
|
||||
.unwrap();
|
||||
lib.store_similarity_embedding(&track, "profile-a", &[0.1, 0.2, 0.3])
|
||||
.unwrap();
|
||||
lib.store_similarity_embedding(&track, "profile-b", &[0.3, 0.2, 0.1])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
lib.similarity_embedding(track_id, "profile-a").unwrap(),
|
||||
Some(vec![0.1, 0.2, 0.3])
|
||||
);
|
||||
assert_eq!(
|
||||
lib.similarity_embedding(track_id, "profile-b").unwrap(),
|
||||
Some(vec![0.3, 0.2, 0.1])
|
||||
);
|
||||
let stats = lib.similarity_storage_stats("profile-a").unwrap();
|
||||
assert_eq!(stats.total_tracks, 1);
|
||||
assert_eq!(stats.embedded_tracks, 1);
|
||||
assert_eq!(stats.stored_vectors, 2);
|
||||
assert_eq!(stats.stored_bytes, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_content_id_invalidates_only_the_stale_embedding() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
|
||||
.unwrap();
|
||||
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
|
||||
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
|
||||
.unwrap();
|
||||
lib.lock()
|
||||
.execute(
|
||||
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
|
||||
params![track_id, format!("b3:{}", "f".repeat(64))],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(lib.pending_similarity_tracks("profile").unwrap().len(), 1);
|
||||
assert_eq!(lib.similarity_embedding(track_id, "profile").unwrap(), None);
|
||||
assert!(lib.load_similarity_index("profile").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_embeddings_preserves_the_library() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
|
||||
.unwrap();
|
||||
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
|
||||
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
|
||||
.unwrap();
|
||||
|
||||
lib.clear_similarity_embeddings().unwrap();
|
||||
|
||||
assert_eq!(lib.tracks_by_ids(&[track_id]).unwrap().len(), 1);
|
||||
assert!(
|
||||
lib.similarity_embedding(track_id, "profile")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
+78
-2
@@ -1,10 +1,18 @@
|
||||
mod api;
|
||||
mod app;
|
||||
mod art;
|
||||
mod config;
|
||||
mod devices;
|
||||
mod federation;
|
||||
mod jam;
|
||||
mod library;
|
||||
mod media;
|
||||
mod player;
|
||||
mod share;
|
||||
mod similarity;
|
||||
mod status;
|
||||
mod streaming;
|
||||
mod ui;
|
||||
mod visualizer;
|
||||
|
||||
use std::io;
|
||||
|
||||
@@ -14,7 +22,40 @@ use crossterm::event::{
|
||||
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||
};
|
||||
|
||||
const HELP: &str = "\
|
||||
Furumi — federated terminal music player
|
||||
|
||||
Usage:
|
||||
furumi [OPTION]
|
||||
|
||||
Options:
|
||||
-h, --help Show this help
|
||||
-V, --version Show version
|
||||
--status Print a one-line now-playing status
|
||||
--status-json
|
||||
Print now-playing status as JSON
|
||||
|
||||
tmux:
|
||||
set -g status-right '#(furumi --status)'
|
||||
";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args_os().collect();
|
||||
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||
print!("{HELP}");
|
||||
return Ok(());
|
||||
}
|
||||
if args.iter().any(|arg| arg == "--version" || arg == "-V") {
|
||||
println!("furumi {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
if args
|
||||
.iter()
|
||||
.any(|arg| arg == "--status" || arg == "--status-json")
|
||||
{
|
||||
return status::print(args.iter().any(|arg| arg == "--status-json"));
|
||||
}
|
||||
|
||||
let mut startup_warning = None;
|
||||
if let Err(err) = config::logging::init() {
|
||||
startup_warning = Some(format!("logging disabled: {err:#}"));
|
||||
@@ -122,7 +163,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
|
||||
|
||||
+52
-1
@@ -99,9 +99,10 @@ fn create_controls() -> Option<MediaControls> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let hwnd = None;
|
||||
|
||||
let dbus_name = platform_dbus_name();
|
||||
let config = PlatformConfig {
|
||||
display_name: "Furumi",
|
||||
dbus_name: "cy.hexor.furumi",
|
||||
dbus_name: &dbus_name,
|
||||
hwnd,
|
||||
};
|
||||
match MediaControls::new(config) {
|
||||
@@ -113,6 +114,41 @@ fn create_controls() -> Option<MediaControls> {
|
||||
}
|
||||
}
|
||||
|
||||
/// MPRIS well-known names must be unique on the session bus. Developers often
|
||||
/// run a checkout next to an installed Furumi, and souvlaki 0.8.3 reports a
|
||||
/// duplicate name by panicking in its private service thread. Ratatui's global
|
||||
/// panic hook then restores the terminal even though the app thread is still
|
||||
/// alive, leaving a frozen UI in canonical/echo mode.
|
||||
///
|
||||
/// Give every Unix MPRIS instance its own valid bus-name component. Other
|
||||
/// backends ignore `dbus_name`, so retain the stable application identifier
|
||||
/// there.
|
||||
fn platform_dbus_name() -> String {
|
||||
#[cfg(all(
|
||||
unix,
|
||||
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||
))]
|
||||
{
|
||||
mpris_dbus_name(std::process::id())
|
||||
}
|
||||
|
||||
#[cfg(not(all(
|
||||
unix,
|
||||
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||
)))]
|
||||
{
|
||||
"cy.hexor.furumi".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
unix,
|
||||
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||
))]
|
||||
fn mpris_dbus_name(process_id: u32) -> String {
|
||||
format!("cy.hexor.furumi.instance{process_id}")
|
||||
}
|
||||
|
||||
/// An invisible top-level window owning the SMTC session. Created on the
|
||||
/// main thread, which also pumps its messages in `pump_platform_events`.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -217,3 +253,18 @@ fn pump_platform_events() {
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn pump_platform_events() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(all(
|
||||
unix,
|
||||
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||
))]
|
||||
#[test]
|
||||
fn mpris_names_are_distinct_between_processes() {
|
||||
let first = super::mpris_dbus_name(41);
|
||||
let second = super::mpris_dbus_name(42);
|
||||
assert_eq!(first, "cy.hexor.furumi.instance41");
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+57
-19
@@ -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
|
||||
@@ -37,14 +44,16 @@ 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>,
|
||||
},
|
||||
Pause,
|
||||
@@ -59,6 +68,7 @@ enum Command {
|
||||
pub struct Shared {
|
||||
position_ms: AtomicU64,
|
||||
paused: AtomicBool,
|
||||
analysis: Arc<analyzer::AnalyzerShared>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
@@ -69,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)]
|
||||
@@ -80,17 +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,
|
||||
});
|
||||
let _ = self.tx.send(Command::Enqueue { reader, byte_len });
|
||||
}
|
||||
|
||||
pub fn pause(&self) {
|
||||
@@ -141,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) => {
|
||||
@@ -171,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),
|
||||
@@ -179,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
|
||||
@@ -202,16 +228,23 @@ 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);
|
||||
@@ -233,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}"
|
||||
@@ -252,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
@@ -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"));
|
||||
}
|
||||
}
|
||||
+1047
File diff suppressed because it is too large
Load Diff
+190
@@ -0,0 +1,190 @@
|
||||
//! Cheap now-playing export for status bars and other polling clients.
|
||||
//!
|
||||
//! The UI only sends snapshots through a bounded channel. A dedicated thread
|
||||
//! performs the filesystem writes so a slow filesystem cannot stall drawing
|
||||
//! or input handling.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, SyncSender, TrySendError};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::app::state::PlayerBar;
|
||||
|
||||
const STALE_AFTER_MS: u64 = 5_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PlaybackStatus {
|
||||
pub playing: bool,
|
||||
pub paused: bool,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub position_secs: f64,
|
||||
pub duration_secs: f64,
|
||||
pub volume: u8,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
impl PlaybackStatus {
|
||||
pub fn from_player(player: &PlayerBar) -> Option<Self> {
|
||||
let track = player.current.as_ref()?;
|
||||
player.playing.then(|| Self {
|
||||
playing: true,
|
||||
paused: player.paused,
|
||||
title: track.title.clone(),
|
||||
artist: track.artist_line(),
|
||||
album: track.release_title.clone(),
|
||||
position_secs: player.position_secs.max(0.0),
|
||||
duration_secs: track.duration_seconds.max(0.0),
|
||||
volume: player.volume,
|
||||
updated_at_ms: now_ms(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn one_line(&self) -> String {
|
||||
let icon = if self.paused { "⏸" } else { "▶" };
|
||||
let track = if self.artist.is_empty() {
|
||||
self.title.clone()
|
||||
} else {
|
||||
format!("{} — {}", self.artist, self.title)
|
||||
};
|
||||
format!(
|
||||
"{icon} {track} {}/{}",
|
||||
duration(self.position_secs),
|
||||
duration(self.duration_secs)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Publisher {
|
||||
tx: Option<SyncSender<Option<PlaybackStatus>>>,
|
||||
}
|
||||
|
||||
impl Publisher {
|
||||
pub fn spawn() -> Self {
|
||||
let (tx, rx) = mpsc::sync_channel(1);
|
||||
std::thread::Builder::new()
|
||||
.name("playback-status".to_string())
|
||||
.spawn(move || {
|
||||
while let Ok(snapshot) = rx.recv() {
|
||||
if let Some(snapshot) = snapshot {
|
||||
if let Err(err) = write(&snapshot) {
|
||||
tracing::debug!(?err, "writing playback status failed");
|
||||
}
|
||||
} else {
|
||||
remove();
|
||||
}
|
||||
}
|
||||
remove();
|
||||
})
|
||||
.ok();
|
||||
Self { tx: Some(tx) }
|
||||
}
|
||||
|
||||
pub fn publish(&self, snapshot: Option<PlaybackStatus>) {
|
||||
let Some(tx) = &self.tx else { return };
|
||||
match tx.try_send(snapshot) {
|
||||
Ok(()) | Err(TrySendError::Full(_)) => {}
|
||||
Err(TrySendError::Disconnected(_)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Publisher {
|
||||
fn drop(&mut self) {
|
||||
self.tx.take();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print(json: bool) -> anyhow::Result<()> {
|
||||
let Some(status) = read()? else {
|
||||
return Ok(());
|
||||
};
|
||||
if json {
|
||||
println!("{}", serde_json::to_string(&status)?);
|
||||
} else {
|
||||
println!("{}", status.one_line());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read() -> anyhow::Result<Option<PlaybackStatus>> {
|
||||
let Some(path) = path() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = match fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let status: PlaybackStatus = serde_json::from_slice(&bytes)?;
|
||||
if now_ms().saturating_sub(status.updated_at_ms) > STALE_AFTER_MS {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(status))
|
||||
}
|
||||
|
||||
fn write(status: &PlaybackStatus) -> anyhow::Result<()> {
|
||||
let Some(path) = path() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(dir) = path.parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
fs::create_dir_all(dir)?;
|
||||
let temporary = dir.join("playback-status.tmp");
|
||||
fs::write(&temporary, serde_json::to_vec(status)?)?;
|
||||
#[cfg(windows)]
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
fs::rename(temporary, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove() {
|
||||
if let Some(path) = path() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
crate::config::project_dirs().map(|dirs| dirs.cache_dir().join("playback-status.json"))
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn duration(seconds: f64) -> String {
|
||||
let total = seconds.max(0.0).round() as u64;
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn one_line_contains_state_metadata_and_progress() {
|
||||
let status = PlaybackStatus {
|
||||
playing: true,
|
||||
paused: true,
|
||||
title: "Track".into(),
|
||||
artist: "Artist".into(),
|
||||
album: "Release".into(),
|
||||
position_secs: 83.0,
|
||||
duration_secs: 245.0,
|
||||
volume: 80,
|
||||
updated_at_ms: 0,
|
||||
};
|
||||
assert_eq!(status.one_line(), "⏸ Artist — Track 1:23/4:05");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+792
-73
File diff suppressed because it is too large
Load Diff
-230
@@ -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
@@ -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()),
|
||||
}
|
||||
|
||||
+291
-121
@@ -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, TrackSelectionScope};
|
||||
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,19 +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()),
|
||||
]);
|
||||
@@ -84,15 +209,19 @@ pub(crate) fn track_row(
|
||||
area,
|
||||
);
|
||||
if visual_selected {
|
||||
frame.buffer_mut().set_style(area, theme::selection());
|
||||
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()
|
||||
@@ -129,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 · d: remove · shift-v: select · shift-c: clear ",
|
||||
player.queue.len()
|
||||
" Queue — {} tracks · Mode: {} · enter: play · alt-j/k: move · 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);
|
||||
|
||||
@@ -199,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()));
|
||||
}
|
||||
@@ -249,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 {
|
||||
@@ -326,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(
|
||||
@@ -346,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(
|
||||
@@ -386,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;
|
||||
|
||||
@@ -420,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 {
|
||||
@@ -430,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();
|
||||
@@ -461,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);
|
||||
@@ -496,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),
|
||||
]
|
||||
}
|
||||
|
||||
+33
-37
@@ -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, TrackSelectionScope};
|
||||
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,7 +146,7 @@ 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,
|
||||
@@ -161,6 +156,7 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
state
|
||||
.track_selection
|
||||
.contains(&TrackSelectionScope::Playlist(id), index),
|
||||
id != LIKES_PLAYLIST_ID,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1419
-123
File diff suppressed because it is too large
Load Diff
+59
-4
@@ -1,20 +1,35 @@
|
||||
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 JAM_ACCENT: Color = Color::Magenta;
|
||||
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 danger_button() -> Style {
|
||||
Style::new()
|
||||
.fg(Color::White)
|
||||
.bg(Color::Rgb(96, 0, 24))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
@@ -22,6 +37,46 @@ pub fn selection() -> Style {
|
||||
Style::new().fg(Color::White).bg(Color::Rgb(24, 68, 72))
|
||||
}
|
||||
|
||||
pub fn header() -> Style {
|
||||
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
|
||||
pub fn selection_for(state: &AppState) -> Style {
|
||||
match state.device_playback.role {
|
||||
DevicePlaybackRole::Jam => Style::new().fg(Color::White).bg(Color::Rgb(80, 24, 96)),
|
||||
DevicePlaybackRole::Control => Style::new().fg(Color::White).bg(Color::Rgb(92, 72, 0)),
|
||||
DevicePlaybackRole::Active => selection(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header_for(state: &AppState) -> Style {
|
||||
accent_for(state).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn border_for(state: &AppState) -> Style {
|
||||
match state.device_playback.role {
|
||||
DevicePlaybackRole::Jam => Style::new().fg(JAM_ACCENT),
|
||||
DevicePlaybackRole::Control => Style::new().fg(CONTROL_ACCENT),
|
||||
DevicePlaybackRole::Active => 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,
|
||||
DevicePlaybackRole::Jam => JAM_ACCENT,
|
||||
};
|
||||
Style::new()
|
||||
.fg(Color::Black)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn accent_color_for(state: &AppState) -> Color {
|
||||
match state.device_playback.role {
|
||||
DevicePlaybackRole::Jam => JAM_ACCENT,
|
||||
DevicePlaybackRole::Control => CONTROL_ACCENT,
|
||||
DevicePlaybackRole::Active => ACCENT,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
authors = ["Steven Sheldon"]
|
||||
|
||||
description = "Rust interface for Apple's C language extension of blocks."
|
||||
keywords = ["blocks", "osx", "ios", "objective-c"]
|
||||
readme = "README.md"
|
||||
repository = "http://github.com/SSheldon/rust-block"
|
||||
documentation = "http://ssheldon.github.io/rust-objc/block/"
|
||||
license = "MIT"
|
||||
|
||||
exclude = [
|
||||
".gitignore",
|
||||
".travis.yml",
|
||||
"travis_install.sh",
|
||||
"travis_test.sh",
|
||||
"tests-ios/**",
|
||||
]
|
||||
|
||||
[dev-dependencies.objc_test_utils]
|
||||
version = "0.0"
|
||||
path = "test_utils"
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
Rust interface for Apple's C language extension of blocks.
|
||||
|
||||
For more information on the specifics of the block implementation, see
|
||||
Clang's documentation: http://clang.llvm.org/docs/Block-ABI-Apple.html
|
||||
|
||||
## Invoking blocks
|
||||
|
||||
The `Block` struct is used for invoking blocks from Objective-C. For example,
|
||||
consider this Objective-C function:
|
||||
|
||||
``` objc
|
||||
int32_t sum(int32_t (^block)(int32_t, int32_t)) {
|
||||
return block(5, 8);
|
||||
}
|
||||
```
|
||||
|
||||
We could write it in Rust as the following:
|
||||
|
||||
``` rust
|
||||
unsafe fn sum(block: &Block<(i32, i32), i32>) -> i32 {
|
||||
block.call((5, 8))
|
||||
}
|
||||
```
|
||||
|
||||
Note the extra parentheses in the `call` method, since the arguments must be
|
||||
passed as a tuple.
|
||||
|
||||
## Creating blocks
|
||||
|
||||
Creating a block to pass to Objective-C can be done with the `ConcreteBlock`
|
||||
struct. For example, to create a block that adds two `i32`s, we could write:
|
||||
|
||||
``` rust
|
||||
let block = ConcreteBlock::new(|a: i32, b: i32| a + b);
|
||||
let block = block.copy();
|
||||
assert!(unsafe { block.call((5, 8)) } == 13);
|
||||
```
|
||||
|
||||
It is important to copy your block to the heap (with the `copy` method) before
|
||||
passing it to Objective-C; this is because our `ConcreteBlock` is only meant
|
||||
to be copied once, and we can enforce this in Rust, but if Objective-C code
|
||||
were to copy it twice we could have a double free.
|
||||
Vendored
+399
@@ -0,0 +1,399 @@
|
||||
/*!
|
||||
A Rust interface for Objective-C blocks.
|
||||
|
||||
For more information on the specifics of the block implementation, see
|
||||
Clang's documentation: http://clang.llvm.org/docs/Block-ABI-Apple.html
|
||||
|
||||
# Invoking blocks
|
||||
|
||||
The `Block` struct is used for invoking blocks from Objective-C. For example,
|
||||
consider this Objective-C function:
|
||||
|
||||
``` objc
|
||||
int32_t sum(int32_t (^block)(int32_t, int32_t)) {
|
||||
return block(5, 8);
|
||||
}
|
||||
```
|
||||
|
||||
We could write it in Rust as the following:
|
||||
|
||||
```
|
||||
# use block::Block;
|
||||
unsafe fn sum(block: &Block<(i32, i32), i32>) -> i32 {
|
||||
block.call((5, 8))
|
||||
}
|
||||
```
|
||||
|
||||
Note the extra parentheses in the `call` method, since the arguments must be
|
||||
passed as a tuple.
|
||||
|
||||
# Creating blocks
|
||||
|
||||
Creating a block to pass to Objective-C can be done with the `ConcreteBlock`
|
||||
struct. For example, to create a block that adds two `i32`s, we could write:
|
||||
|
||||
```
|
||||
# use block::ConcreteBlock;
|
||||
let block = ConcreteBlock::new(|a: i32, b: i32| a + b);
|
||||
let block = block.copy();
|
||||
assert!(unsafe { block.call((5, 8)) } == 13);
|
||||
```
|
||||
|
||||
It is important to copy your block to the heap (with the `copy` method) before
|
||||
passing it to Objective-C; this is because our `ConcreteBlock` is only meant
|
||||
to be copied once, and we can enforce this in Rust, but if Objective-C code
|
||||
were to copy it twice we could have a double free.
|
||||
*/
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::mem;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::os::raw::{c_int, c_ulong, c_void};
|
||||
use std::ptr;
|
||||
|
||||
#[repr(C)]
|
||||
struct Class {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
#[cfg_attr(any(target_os = "macos", target_os = "ios"),
|
||||
link(name = "System", kind = "dylib"))]
|
||||
#[cfg_attr(not(any(target_os = "macos", target_os = "ios")),
|
||||
link(name = "BlocksRuntime", kind = "dylib"))]
|
||||
extern "C" {
|
||||
static _NSConcreteStackBlock: Class;
|
||||
|
||||
fn _Block_copy(block: *const c_void) -> *mut c_void;
|
||||
fn _Block_release(block: *const c_void);
|
||||
}
|
||||
|
||||
/// Types that may be used as the arguments to an Objective-C block.
|
||||
pub trait BlockArguments: Sized {
|
||||
/// Calls the given `Block` with self as the arguments.
|
||||
///
|
||||
/// Unsafe because `block` must point to a valid `Block` and this invokes
|
||||
/// foreign code whose safety the compiler cannot verify.
|
||||
unsafe fn call_block<R>(self, block: *mut Block<Self, R>) -> R;
|
||||
}
|
||||
|
||||
macro_rules! block_args_impl {
|
||||
($($a:ident : $t:ident),*) => (
|
||||
impl<$($t),*> BlockArguments for ($($t,)*) {
|
||||
unsafe fn call_block<R>(self, block: *mut Block<Self, R>) -> R {
|
||||
let invoke: unsafe extern "C" fn(*mut Block<Self, R> $(, $t)*) -> R = {
|
||||
let base = block as *mut BlockBase<Self, R>;
|
||||
mem::transmute((*base).invoke)
|
||||
};
|
||||
let ($($a,)*) = self;
|
||||
invoke(block $(, $a)*)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
block_args_impl!();
|
||||
block_args_impl!(a: A);
|
||||
block_args_impl!(a: A, b: B);
|
||||
block_args_impl!(a: A, b: B, c: C);
|
||||
block_args_impl!(a: A, b: B, c: C, d: D);
|
||||
block_args_impl!(a: A, b: B, c: C, d: D, e: E);
|
||||
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F);
|
||||
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G);
|
||||
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H);
|
||||
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I);
|
||||
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J);
|
||||
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K);
|
||||
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L);
|
||||
|
||||
#[repr(C)]
|
||||
struct BlockBase<A, R> {
|
||||
isa: *const Class,
|
||||
flags: c_int,
|
||||
_reserved: c_int,
|
||||
invoke: unsafe extern "C" fn(*mut Block<A, R>, ...) -> R,
|
||||
}
|
||||
|
||||
/// An Objective-C block that takes arguments of `A` when called and
|
||||
/// returns a value of `R`.
|
||||
#[repr(C)]
|
||||
pub struct Block<A, R> {
|
||||
_base: PhantomData<BlockBase<A, R>>,
|
||||
}
|
||||
|
||||
impl<A: BlockArguments, R> Block<A, R> where A: BlockArguments {
|
||||
/// Call self with the given arguments.
|
||||
///
|
||||
/// Unsafe because this invokes foreign code that the caller must verify
|
||||
/// doesn't violate any of Rust's safety rules. For example, if this block
|
||||
/// is shared with multiple references, the caller must ensure that calling
|
||||
/// it will not cause a data race.
|
||||
pub unsafe fn call(&self, args: A) -> R {
|
||||
args.call_block(self as *const _ as *mut _)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference-counted Objective-C block.
|
||||
pub struct RcBlock<A, R> {
|
||||
ptr: *mut Block<A, R>,
|
||||
}
|
||||
|
||||
impl<A, R> RcBlock<A, R> {
|
||||
/// Construct an `RcBlock` for the given block without copying it.
|
||||
/// The caller must ensure the block has a +1 reference count.
|
||||
///
|
||||
/// Unsafe because `ptr` must point to a valid `Block` and must have a +1
|
||||
/// reference count or it will be overreleased when the `RcBlock` is
|
||||
/// dropped.
|
||||
pub unsafe fn new(ptr: *mut Block<A, R>) -> Self {
|
||||
RcBlock { ptr: ptr }
|
||||
}
|
||||
|
||||
/// Constructs an `RcBlock` by copying the given block.
|
||||
///
|
||||
/// Unsafe because `ptr` must point to a valid `Block`.
|
||||
pub unsafe fn copy(ptr: *mut Block<A, R>) -> Self {
|
||||
let ptr = _Block_copy(ptr as *const c_void) as *mut Block<A, R>;
|
||||
RcBlock { ptr: ptr }
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, R> Clone for RcBlock<A, R> {
|
||||
fn clone(&self) -> RcBlock<A, R> {
|
||||
unsafe {
|
||||
RcBlock::copy(self.ptr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, R> Deref for RcBlock<A, R> {
|
||||
type Target = Block<A, R>;
|
||||
|
||||
fn deref(&self) -> &Block<A, R> {
|
||||
unsafe { &*self.ptr }
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, R> Drop for RcBlock<A, R> {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
_Block_release(self.ptr as *const c_void);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Types that may be converted into a `ConcreteBlock`.
|
||||
pub trait IntoConcreteBlock<A>: Sized where A: BlockArguments {
|
||||
/// The return type of the resulting `ConcreteBlock`.
|
||||
type Ret;
|
||||
|
||||
/// Consumes self to create a `ConcreteBlock`.
|
||||
fn into_concrete_block(self) -> ConcreteBlock<A, Self::Ret, Self>;
|
||||
}
|
||||
|
||||
macro_rules! concrete_block_impl {
|
||||
($f:ident) => (
|
||||
concrete_block_impl!($f,);
|
||||
);
|
||||
($f:ident, $($a:ident : $t:ident),*) => (
|
||||
impl<$($t,)* R, X> IntoConcreteBlock<($($t,)*)> for X
|
||||
where X: Fn($($t,)*) -> R {
|
||||
type Ret = R;
|
||||
|
||||
fn into_concrete_block(self) -> ConcreteBlock<($($t,)*), R, X> {
|
||||
unsafe extern "C" fn $f<$($t,)* R, X>(
|
||||
block_ptr: *mut ConcreteBlock<($($t,)*), R, X>
|
||||
$(, $a: $t)*) -> R
|
||||
where X: Fn($($t,)*) -> R {
|
||||
let block = &*block_ptr;
|
||||
(block.closure)($($a),*)
|
||||
}
|
||||
|
||||
let f: unsafe extern "C" fn(*mut ConcreteBlock<($($t,)*), R, X> $(, $a: $t)*) -> R = $f;
|
||||
unsafe {
|
||||
ConcreteBlock::with_invoke(mem::transmute(f), self)
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
concrete_block_impl!(concrete_block_invoke_args0);
|
||||
concrete_block_impl!(concrete_block_invoke_args1, a: A);
|
||||
concrete_block_impl!(concrete_block_invoke_args2, a: A, b: B);
|
||||
concrete_block_impl!(concrete_block_invoke_args3, a: A, b: B, c: C);
|
||||
concrete_block_impl!(concrete_block_invoke_args4, a: A, b: B, c: C, d: D);
|
||||
concrete_block_impl!(concrete_block_invoke_args5, a: A, b: B, c: C, d: D, e: E);
|
||||
concrete_block_impl!(concrete_block_invoke_args6, a: A, b: B, c: C, d: D, e: E, f: F);
|
||||
concrete_block_impl!(concrete_block_invoke_args7, a: A, b: B, c: C, d: D, e: E, f: F, g: G);
|
||||
concrete_block_impl!(concrete_block_invoke_args8, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H);
|
||||
concrete_block_impl!(concrete_block_invoke_args9, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I);
|
||||
concrete_block_impl!(concrete_block_invoke_args10, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J);
|
||||
concrete_block_impl!(concrete_block_invoke_args11, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K);
|
||||
concrete_block_impl!(concrete_block_invoke_args12, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L);
|
||||
|
||||
/// An Objective-C block whose size is known at compile time and may be
|
||||
/// constructed on the stack.
|
||||
#[repr(C)]
|
||||
pub struct ConcreteBlock<A, R, F> {
|
||||
base: BlockBase<A, R>,
|
||||
descriptor: Box<BlockDescriptor<ConcreteBlock<A, R, F>>>,
|
||||
closure: F,
|
||||
}
|
||||
|
||||
impl<A, R, F> ConcreteBlock<A, R, F>
|
||||
where A: BlockArguments, F: IntoConcreteBlock<A, Ret=R> {
|
||||
/// Constructs a `ConcreteBlock` with the given closure.
|
||||
/// When the block is called, it will return the value that results from
|
||||
/// calling the closure.
|
||||
pub fn new(closure: F) -> Self {
|
||||
closure.into_concrete_block()
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, R, F> ConcreteBlock<A, R, F> {
|
||||
/// Constructs a `ConcreteBlock` with the given invoke function and closure.
|
||||
/// Unsafe because the caller must ensure the invoke function takes the
|
||||
/// correct arguments.
|
||||
unsafe fn with_invoke(invoke: unsafe extern "C" fn(*mut Self, ...) -> R,
|
||||
closure: F) -> Self {
|
||||
ConcreteBlock {
|
||||
base: BlockBase {
|
||||
isa: &_NSConcreteStackBlock,
|
||||
// 1 << 25 = BLOCK_HAS_COPY_DISPOSE
|
||||
flags: 1 << 25,
|
||||
_reserved: 0,
|
||||
invoke: mem::transmute(invoke),
|
||||
},
|
||||
descriptor: Box::new(BlockDescriptor::new()),
|
||||
closure: closure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, R, F> ConcreteBlock<A, R, F> where F: 'static {
|
||||
/// Copy self onto the heap as an `RcBlock`.
|
||||
pub fn copy(self) -> RcBlock<A, R> {
|
||||
unsafe {
|
||||
let mut block = self;
|
||||
let copied = RcBlock::copy(&mut *block);
|
||||
// At this point, our copy helper has been run so the block will
|
||||
// be moved to the heap and we can forget the original block
|
||||
// because the heap block will drop in our dispose helper.
|
||||
mem::forget(block);
|
||||
copied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, R, F> Clone for ConcreteBlock<A, R, F> where F: Clone {
|
||||
fn clone(&self) -> Self {
|
||||
unsafe {
|
||||
ConcreteBlock::with_invoke(mem::transmute(self.base.invoke),
|
||||
self.closure.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, R, F> Deref for ConcreteBlock<A, R, F> {
|
||||
type Target = Block<A, R>;
|
||||
|
||||
fn deref(&self) -> &Block<A, R> {
|
||||
unsafe { &*(&self.base as *const _ as *const Block<A, R>) }
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, R, F> DerefMut for ConcreteBlock<A, R, F> {
|
||||
fn deref_mut(&mut self) -> &mut Block<A, R> {
|
||||
unsafe { &mut *(&mut self.base as *mut _ as *mut Block<A, R>) }
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn block_context_dispose<B>(block: &mut B) {
|
||||
// Read the block onto the stack and let it drop
|
||||
ptr::read(block);
|
||||
}
|
||||
|
||||
unsafe extern "C" fn block_context_copy<B>(_dst: &mut B, _src: &B) {
|
||||
// The runtime memmoves the src block into the dst block, nothing to do
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct BlockDescriptor<B> {
|
||||
_reserved: c_ulong,
|
||||
block_size: c_ulong,
|
||||
copy_helper: unsafe extern "C" fn(&mut B, &B),
|
||||
dispose_helper: unsafe extern "C" fn(&mut B),
|
||||
}
|
||||
|
||||
impl<B> BlockDescriptor<B> {
|
||||
fn new() -> BlockDescriptor<B> {
|
||||
BlockDescriptor {
|
||||
_reserved: 0,
|
||||
block_size: mem::size_of::<B>() as c_ulong,
|
||||
copy_helper: block_context_copy::<B>,
|
||||
dispose_helper: block_context_dispose::<B>,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use test_utils::*;
|
||||
use super::{ConcreteBlock, RcBlock};
|
||||
|
||||
#[test]
|
||||
fn test_call_block() {
|
||||
let block = get_int_block_with(13);
|
||||
unsafe {
|
||||
assert!(block.call(()) == 13);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_call_block_args() {
|
||||
let block = get_add_block_with(13);
|
||||
unsafe {
|
||||
assert!(block.call((2,)) == 15);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_block() {
|
||||
let block = ConcreteBlock::new(|| 13);
|
||||
let result = invoke_int_block(&block);
|
||||
assert!(result == 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_block_args() {
|
||||
let block = ConcreteBlock::new(|a: i32| a + 5);
|
||||
let result = invoke_add_block(&block, 6);
|
||||
assert!(result == 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_concrete_block_copy() {
|
||||
let s = "Hello!".to_string();
|
||||
let expected_len = s.len() as i32;
|
||||
let block = ConcreteBlock::new(move || s.len() as i32);
|
||||
assert!(invoke_int_block(&block) == expected_len);
|
||||
|
||||
let copied = block.copy();
|
||||
assert!(invoke_int_block(&copied) == expected_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_concrete_block_stack_copy() {
|
||||
fn make_block() -> RcBlock<(), i32> {
|
||||
let x = 7;
|
||||
let block = ConcreteBlock::new(move || x);
|
||||
block.copy()
|
||||
}
|
||||
|
||||
let block = make_block();
|
||||
assert!(invoke_int_block(&block) == 7);
|
||||
}
|
||||
}
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
extern crate objc_test_utils;
|
||||
|
||||
use {Block, RcBlock};
|
||||
|
||||
pub fn get_int_block_with(i: i32) -> RcBlock<(), i32> {
|
||||
unsafe {
|
||||
let ptr = objc_test_utils::get_int_block_with(i);
|
||||
RcBlock::new(ptr as *mut _)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_add_block_with(i: i32) -> RcBlock<(i32,), i32> {
|
||||
unsafe {
|
||||
let ptr = objc_test_utils::get_add_block_with(i);
|
||||
RcBlock::new(ptr as *mut _)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invoke_int_block(block: &Block<(), i32>) -> i32 {
|
||||
let ptr = block as *const _;
|
||||
unsafe {
|
||||
objc_test_utils::invoke_int_block(ptr as *mut _)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invoke_add_block(block: &Block<(i32,), i32>, a: i32) -> i32 {
|
||||
let ptr = block as *const _;
|
||||
unsafe {
|
||||
objc_test_utils::invoke_add_block(ptr as *mut _, a)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user