Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2da81ecb89 | ||
|
|
54ba8b4309 | ||
|
|
ba5a73816e | ||
|
|
5b624443d5 | ||
|
|
ec777f956a | ||
|
|
654d8ad750 | ||
|
|
d13b0c8085 | ||
|
|
e30ad8be36 |
@@ -0,0 +1,104 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_NET_RETRY: 10
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.asset_name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
asset_name: furumi-linux-x86_64
|
||||
archive_ext: tar.gz
|
||||
binary_name: furumi
|
||||
- os: macos-latest
|
||||
asset_name: furumi-macos-aarch64
|
||||
archive_ext: tar.gz
|
||||
binary_name: furumi
|
||||
- os: windows-latest
|
||||
asset_name: furumi-windows-x86_64
|
||||
archive_ext: zip
|
||||
binary_name: furumi.exe
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev pkg-config
|
||||
|
||||
- name: Show Rust version
|
||||
run: rustc --version && cargo --version
|
||||
|
||||
- name: Fetch locked dependencies
|
||||
run: cargo fetch --locked
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --locked --offline
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
archive_name="${{ matrix.asset_name }}-${version}.${{ matrix.archive_ext }}"
|
||||
package_dir="dist/${{ matrix.asset_name }}"
|
||||
mkdir -p "$package_dir"
|
||||
cp "target/release/${{ matrix.binary_name }}" "$package_dir/"
|
||||
cp README.md "$package_dir/"
|
||||
cp LICENSE "$package_dir/"
|
||||
|
||||
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||
(cd dist && 7z a "../${archive_name}" "${{ matrix.asset_name }}")
|
||||
else
|
||||
tar -C dist -czf "${archive_name}" "${{ matrix.asset_name }}"
|
||||
fi
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset_name }}
|
||||
path: ${{ matrix.asset_name }}-*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish release assets
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create release and upload assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh release create "$RELEASE_TAG" artifacts/*/* \
|
||||
--title "furumi ${RELEASE_TAG}" \
|
||||
--notes "Release ${RELEASE_TAG}" \
|
||||
--verify-tag
|
||||
@@ -0,0 +1,241 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file describes how to work safely in the Furumi repository. It applies to
|
||||
the entire project.
|
||||
|
||||
## Project intent
|
||||
|
||||
Furumi is a cross-platform, federated P2P player for personal music libraries.
|
||||
Every node must remain a complete local player without network access. Other
|
||||
nodes add discovery, availability, trusted-device synchronization, and direct
|
||||
track transfer; they are never a prerequisite for using the local library.
|
||||
|
||||
Read `ARCHITECTURE.md` before changing federation, device sync, playback
|
||||
handoff, persistence, or runtime boundaries.
|
||||
|
||||
Preserve these architectural invariants:
|
||||
|
||||
1. Local import, browsing, playback, playlists, likes, and history work
|
||||
offline.
|
||||
2. No central Furumi service becomes required for discovery, playback, or
|
||||
synchronization.
|
||||
3. Federation membership does not imply trusted-device membership.
|
||||
4. Remote state is merged, replicated, or cached locally; it is not treated as
|
||||
an always-available database.
|
||||
5. Network, database, filesystem, image, and audio preparation work stays out
|
||||
of the interactive UI path.
|
||||
6. Losing peers may reduce remote availability but must not invalidate local
|
||||
state.
|
||||
|
||||
## Toolchain and checks
|
||||
|
||||
The crate uses Rust edition 2024 and Rust 1.97 or newer.
|
||||
|
||||
Run the checks relevant to every code change:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo check --all-targets
|
||||
cargo test --all-targets
|
||||
cargo clippy --all-targets
|
||||
```
|
||||
|
||||
`cargo clippy --all-targets` currently reports known warnings. Do not hide
|
||||
them with broad `allow` attributes. Do not claim `-D warnings` passes unless
|
||||
the existing warning set has actually been resolved.
|
||||
|
||||
Use `cargo fmt --all` after editing Rust. Keep `Cargo.lock` committed and
|
||||
update it only when dependencies change.
|
||||
|
||||
## Source boundaries
|
||||
|
||||
- `src/main.rs` owns process startup, terminal restoration, Tokio setup, and
|
||||
platform media-loop integration.
|
||||
- `src/app/state.rs` owns UI/navigation state.
|
||||
- `src/app/action.rs` and `src/app/event.rs` define input intent and runtime
|
||||
results.
|
||||
- `src/app/update.rs` performs state transitions and requests effects. It must
|
||||
not perform blocking I/O.
|
||||
- `src/app/mod.rs` owns runtime orchestration and effect execution.
|
||||
- `src/library/` owns SQLite library queries, models, imports, and migrations.
|
||||
- `src/player/` owns rodio playback and audio analysis.
|
||||
- `src/federation/` owns DHT discovery, peer catalogs, audio exchange, and
|
||||
federation caches.
|
||||
- `src/devices.rs` owns trusted-device replication, membership, and playback
|
||||
coordination.
|
||||
- `src/ui/` renders `AppState`; rendering code must not start background work
|
||||
or mutate persistence.
|
||||
- `src/media.rs` owns OS media controls.
|
||||
- `src/visualizer.rs` and `src/visualizations/` own the Rhai visualization
|
||||
host and bundled scripts.
|
||||
|
||||
Prefer adding a focused module when a responsibility has a clear boundary.
|
||||
Do not split files solely to reduce line count if doing so introduces leaky
|
||||
APIs or circular ownership.
|
||||
|
||||
Keep large test suites in their existing adjacent files:
|
||||
|
||||
- `src/app/update_tests.rs`
|
||||
- `src/devices/tests.rs`
|
||||
- `src/federation/tests.rs`
|
||||
- `src/library/tests.rs`
|
||||
|
||||
## Application state and concurrency
|
||||
|
||||
`AppState` is the single source of truth for the TUI. The normal flow is:
|
||||
|
||||
```text
|
||||
external event -> AppEvent -> Action/update -> Effect -> runtime -> AppEvent
|
||||
```
|
||||
|
||||
Follow these rules:
|
||||
|
||||
- Keep update logic deterministic wherever practical.
|
||||
- Return an `Effect` for work that requires runtime services.
|
||||
- Report asynchronous results through `AppEvent`.
|
||||
- Use `tokio::task::spawn_blocking` for blocking SQLite, hashing, tag parsing,
|
||||
or filesystem-heavy work invoked from async code.
|
||||
- Do not hold a mutex guard across `.await`.
|
||||
- Do not call terminal rendering APIs from background tasks.
|
||||
- Preserve event sequence or request identifiers where they prevent stale
|
||||
search, download, or synchronization results from overwriting newer state.
|
||||
|
||||
## Library and SQLite
|
||||
|
||||
`library::Library` is the authority for local catalog persistence. Keep SQL in
|
||||
the library/device persistence layers rather than spreading it through the UI
|
||||
or runtime.
|
||||
|
||||
When changing schema or stored data:
|
||||
|
||||
- provide an upgrade path for existing databases;
|
||||
- make migrations repeatable and safe on partially upgraded databases;
|
||||
- preserve foreign-key and uniqueness invariants;
|
||||
- distinguish durable user data from replaceable federation/cache data;
|
||||
- test both the new behavior and migration-sensitive queries;
|
||||
- never solve a schema problem by deleting or recreating a user's database.
|
||||
|
||||
Content identifiers are the bridge between independent libraries. Avoid
|
||||
assuming that local numeric row IDs identify the same track on another node.
|
||||
|
||||
## Federation and wire protocols
|
||||
|
||||
Discovery and transfer are separate concerns:
|
||||
|
||||
- the DHT provides distributed discovery;
|
||||
- peer catalog streams provide richer metadata;
|
||||
- audio streams transfer content;
|
||||
- the device-sync protocol replicates trusted personal state.
|
||||
|
||||
Treat protocol and serialized-data changes as compatibility changes:
|
||||
|
||||
- preserve existing ALPN values unless intentionally introducing a new
|
||||
protocol version;
|
||||
- retain backward-compatible `serde` defaults for fields added to wire types;
|
||||
- tolerate peers with older or partial metadata;
|
||||
- bound incoming messages and validate untrusted lengths/identifiers;
|
||||
- keep catalog/federation authority separate from trusted-device authority;
|
||||
- assume peers can disappear between discovery and transfer;
|
||||
- keep retries and fallback sources idempotent.
|
||||
|
||||
Do not introduce a required coordinator, registry, account server, or canonical
|
||||
network database.
|
||||
|
||||
## Trusted-device synchronization
|
||||
|
||||
Device sync is offline-first. Operations may arrive late, more than once, or
|
||||
in a different order.
|
||||
|
||||
When changing it:
|
||||
|
||||
- preserve operation-ID deduplication;
|
||||
- use the existing hybrid logical time ordering rules consistently;
|
||||
- ensure deletes survive offline replicas through tombstones;
|
||||
- compact tombstones only after the required acknowledgements;
|
||||
- keep snapshots able to repair peers that missed older operations;
|
||||
- treat membership and revocation as replicated durable state;
|
||||
- keep playback commands targeted and deduplicated;
|
||||
- test merge behavior from at least two operation orders.
|
||||
|
||||
Do not replace eventual reconciliation with assumptions about a continuously
|
||||
connected leader.
|
||||
|
||||
## Playback and remote resolution
|
||||
|
||||
The application owns the logical queue; `player::Controller` owns physical
|
||||
audio playback.
|
||||
|
||||
Remote and local tracks should continue through the same queue and UI model.
|
||||
When a track is unavailable locally, resolve it asynchronously and replace or
|
||||
materialize the pending entry without blocking the event loop.
|
||||
|
||||
Preserve:
|
||||
|
||||
- queue position and prefetch indexes when inserting or removing tracks;
|
||||
- shuffle/repeat behavior;
|
||||
- pause and seek state during device handoff;
|
||||
- fallback to another advertised source when a peer disappears;
|
||||
- the distinction between cached audio and content imported into the durable
|
||||
library.
|
||||
|
||||
## UI and key bindings
|
||||
|
||||
The TUI is keyboard-first and cross-platform.
|
||||
|
||||
- Keep rendering pure over `&AppState`.
|
||||
- Use semantic `Action` values rather than checking raw keys inside views.
|
||||
- Add default bindings in `src/config/default_keymap.toml`.
|
||||
- Preserve user overrides and context-specific bindings.
|
||||
- Account for narrow terminals, Unicode display width, and empty/loading/error
|
||||
states.
|
||||
- Restore raw mode, alternate screen, bracketed paste, and keyboard
|
||||
enhancements on exit and panic.
|
||||
|
||||
Do not print to stdout/stderr while the alternate-screen UI is active; use
|
||||
`tracing` and visible application status instead.
|
||||
|
||||
## Cross-platform work
|
||||
|
||||
Furumi supports Linux, macOS, and Windows.
|
||||
|
||||
- Keep OS-specific code behind narrow `cfg` boundaries.
|
||||
- Do not introduce shell-only behavior into portable paths.
|
||||
- Use platform data/config/cache directories through the existing config
|
||||
helpers.
|
||||
- Consider path encoding, separators, and non-UTF-8 filesystem values.
|
||||
- Changes to media controls, terminal setup, clipboard behavior, or audio
|
||||
devices require explicit review of all three platforms.
|
||||
|
||||
If only one target can be exercised locally, state which platform-specific
|
||||
paths remain unverified.
|
||||
|
||||
## Tests
|
||||
|
||||
Add tests at the closest stable boundary:
|
||||
|
||||
- pure state transitions in `app/update_tests.rs`;
|
||||
- SQLite behavior and migrations in `library/tests.rs`;
|
||||
- operation merge, tombstone, membership, and playback sync behavior in
|
||||
`devices/tests.rs`;
|
||||
- search/ranking/federation conversion in `federation/tests.rs`;
|
||||
- protocol-specific tests beside `federation/audio.rs` or
|
||||
`federation/catalog.rs` when appropriate.
|
||||
|
||||
Prefer in-memory SQLite databases and deterministic fixtures. Temporary files
|
||||
must use unique names and must not depend on a developer's music library,
|
||||
configuration, home directory, network peers, or audio hardware.
|
||||
|
||||
Do not remove a test merely because a refactor makes it inconvenient. Update
|
||||
it to assert the preserved behavior.
|
||||
|
||||
## Documentation and releases
|
||||
|
||||
Keep public documentation aligned with the decentralized product model.
|
||||
README content should explain user value and setup; `ARCHITECTURE.md` should
|
||||
explain architectural decisions and invariants rather than restating source
|
||||
code.
|
||||
|
||||
Release archives are produced by `.github/workflows/release.yml` and must
|
||||
include the binary, `README.md`, and `LICENSE`.
|
||||
|
||||
The project is licensed under WTFPL version 2.
|
||||
+302
-208
@@ -1,240 +1,334 @@
|
||||
# furumi_cli — Architecture
|
||||
# Furumi architecture
|
||||
|
||||
Cross-platform terminal client (cmus-style TUI) for the furumusic backend.
|
||||
Targets: macOS, Linux (ALSA/Pulse/PipeWire), Windows (WASAPI, Windows Terminal).
|
||||
Furumi is an autonomous music player that can cooperate with other Furumi
|
||||
players. The architecture starts from one constraint: **a node must remain a
|
||||
complete and useful player when every other node is unavailable**.
|
||||
|
||||
## 1. Technology choices
|
||||
Networking therefore extends a local player instead of becoming a prerequisite
|
||||
for it. There is no control plane, account service, canonical catalog, or
|
||||
server-owned source of truth.
|
||||
|
||||
### TUI: ratatui 0.30 + crossterm 0.29
|
||||
## Architectural goals
|
||||
|
||||
Evaluated: **ratatui**, cursive, tui-realm, iocraft.
|
||||
The design optimizes for five properties:
|
||||
|
||||
- **ratatui 0.30.x** — the de-facto standard (gitui, yazi, spotify-player all use it).
|
||||
0.30 split the project into workspace crates (`ratatui-core`, `ratatui-widgets`,
|
||||
`ratatui-crossterm`) with a stable core API. Stock widgets cover everything we
|
||||
need: `Tabs`, `List`, `Table`, nested `Layout` for tile grids.
|
||||
- cursive — maintenance mode since 2024, rejected.
|
||||
- tui-realm — viable framework on top of ratatui (termusic uses it), but a
|
||||
single-maintainer abstraction layer; we prefer plain ratatui with our own
|
||||
thin component layer.
|
||||
- iocraft — too young, optimized for inline CLI output rather than fullscreen apps.
|
||||
- termion — Unix-only, eliminated (we need Windows).
|
||||
1. **Local autonomy** — importing, browsing, playback, playlists, likes, and
|
||||
history work entirely on one device.
|
||||
2. **No central failure domain** — discovery, catalog exchange, streaming, and
|
||||
synchronization do not depend on a Furumi-operated service.
|
||||
3. **Offline tolerance** — trusted devices may change state independently and
|
||||
reconcile after reconnecting.
|
||||
4. **Incremental federation** — one node is complete; every additional node
|
||||
increases availability and the amount of discoverable music.
|
||||
5. **Explicit trust boundaries** — personal-device replication and wider
|
||||
music federation are different protocols with different authority.
|
||||
|
||||
crossterm is the only backend that covers Windows. Caveats to handle:
|
||||
- Enable kitty keyboard enhancement flags only when
|
||||
`supports_keyboard_enhancement()` returns true; always pop flags on exit.
|
||||
- Filter key events to `KeyEventKind::Press` (Windows and kitty-enhanced
|
||||
terminals also deliver Repeat/Release — otherwise bindings double-fire).
|
||||
- Restore the terminal on panic (panic hook) — a TUI that corrupts the shell
|
||||
is the #1 reliability complaint.
|
||||
These goals are more important than maintaining a globally identical view of
|
||||
the network. Furumi prefers useful local progress and eventual reconciliation
|
||||
over distributed consensus.
|
||||
|
||||
### Keybindings: crokey + TOML keymap
|
||||
## The node model
|
||||
|
||||
- **crokey 1.4** — parses/formats key combos (`ctrl-a`, `g`), serde support, used
|
||||
for the config file format.
|
||||
- Keymap model copied from spotify-player: a `[[keymaps]]` TOML table mapping a
|
||||
*key sequence* (space-separated chords, e.g. `"g g"`, `"C-c x"`) to a
|
||||
`Command` enum, optionally parameterized (`{ SeekForward = { seconds = 10 } }`).
|
||||
- A small chord state machine resolves sequences; bindings are layered:
|
||||
built-in defaults ← user config (`~/.config/furumi/keymap.toml`).
|
||||
- Bindings resolve per *input context* (Global, LibraryGrid, TrackList,
|
||||
TextInput, Popup) so the same key can mean different things per view.
|
||||
Every running Furumi instance contains the same four capabilities:
|
||||
|
||||
### Audio: rodio 0.22 + stream-download, behind a backend trait
|
||||
|
||||
Evaluated: **rodio**, kira, raw cpal+symphonia, gstreamer-rs, libmpv.
|
||||
|
||||
- **rodio 0.22** (`Player` / `DeviceSinkBuilder` API — note the 0.21/0.22 renames;
|
||||
most older tutorials are outdated). Symphonia is the default decoder; enable
|
||||
the `aac`, `isomp4`, `alac` features for m4a support. Pure Rust → trivial
|
||||
cross-compilation; cpal covers CoreAudio / ALSA / WASAPI.
|
||||
- **stream-download 0.24** bridges HTTP to rodio: background download exposing
|
||||
blocking `Read + Seek`, built on reqwest (shares our authenticated client,
|
||||
auth headers included), seek into undownloaded regions via HTTP Range
|
||||
(the backend's `/stream/{id}` supports Range), temp-file storage, retries.
|
||||
- kira — game-audio oriented, no network story, rejected.
|
||||
- gstreamer / libmpv — best playback quality but heavy system dependencies;
|
||||
not acceptable as the only backend for a portable CLI.
|
||||
|
||||
Playback lives behind a trait so backends can be added later (termusic ships
|
||||
rodio + mpv + gstreamer this way):
|
||||
|
||||
```rust
|
||||
trait AudioBackend {
|
||||
fn play(&mut self, source: TrackSource) -> Result<()>;
|
||||
fn pause(&mut self); fn resume(&mut self);
|
||||
fn seek(&mut self, pos: Duration) -> Result<()>;
|
||||
fn set_volume(&mut self, v: f32);
|
||||
fn position(&self) -> Duration;
|
||||
fn events(&self) -> Receiver<PlayerEvent>; // TrackEnded, Failed, ...
|
||||
}
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Furumi node │
|
||||
│ │
|
||||
│ Local library ── Playback engine ── TUI / media keys │
|
||||
│ │ │ │
|
||||
│ ├── Trusted-device replication │
|
||||
│ │ │
|
||||
│ └── Federation: discovery, catalog, audio │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Gapless-ish playback: pre-open the `stream-download` source and decoder for the
|
||||
next queue item and append it to the rodio `Player` before the current track
|
||||
ends. (True gapless is impossible for AAC/M4A anyway — symphonia has no AAC
|
||||
gapless trim.)
|
||||
The local SQLite library is authoritative for that node. Network data is
|
||||
merged into local views or materialized as pending remote content; it does not
|
||||
replace the local database with a remote database abstraction.
|
||||
|
||||
### Async runtime: tokio
|
||||
This keeps the core behavior predictable:
|
||||
|
||||
Needed for: crossterm `EventStream`, reqwest, stream-download, device-sync
|
||||
polling, debounced search. The audio decode thread is rodio's own; everything
|
||||
else is async tasks talking over channels.
|
||||
- disconnecting never makes the local collection unavailable;
|
||||
- downloaded content can become ordinary local content;
|
||||
- a peer disappearing reduces availability but does not invalidate local
|
||||
state;
|
||||
- nodes may join and leave without electing a leader.
|
||||
|
||||
## 2. Application architecture
|
||||
## Two network layers
|
||||
|
||||
Elm-style (TEA) core with a component-per-view UI layer — the pattern from the
|
||||
official ratatui component template and spotify-player.
|
||||
Furumi deliberately separates **trusted-device synchronization** from
|
||||
**federated music exchange**.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ main loop │
|
||||
│ recv Event -> keymap -> Action -> update() │
|
||||
│ tick -> draw(&state) │
|
||||
└───────▲──────────────────────────┬──────────┘
|
||||
Event (mpsc) │ │ Command (spawn task / send msg)
|
||||
┌─────────────────────┼──────────────┐ │
|
||||
│ terminal input (crossterm stream) │ ┌───────▼────────┐
|
||||
│ api task results │ │ side effects │
|
||||
│ player events (TrackEnded, ...) │ │ api::Client │
|
||||
│ device-sync poll results/commands │ │ player::Engine │
|
||||
│ tick (render + position updates) │ │ sync::Poller │
|
||||
└────────────────────────────────────┘ └────────────────┘
|
||||
### Trusted-device synchronization
|
||||
|
||||
This layer connects devices owned or trusted by the same user. It carries
|
||||
personal state such as:
|
||||
|
||||
- likes and playlist operations;
|
||||
- device membership and revocation;
|
||||
- acknowledgements and synchronization progress;
|
||||
- playback state, commands, and handoff information.
|
||||
|
||||
Pairing establishes the trust relationship. After that, changes are replicated
|
||||
through an append-only operation log and applied to materialized local tables.
|
||||
|
||||
### Music federation
|
||||
|
||||
Federation connects independent libraries. It provides:
|
||||
|
||||
- decentralized discovery through the DHT;
|
||||
- automatic publication of searchable local catalog metadata;
|
||||
- richer artist and release catalogs fetched from peers;
|
||||
- direct audio transfer when a selected track is not available locally.
|
||||
|
||||
Federation does not grant another peer authority over personal playlists,
|
||||
likes, or device membership. A node may participate in federation without
|
||||
joining another user's trusted-device group.
|
||||
|
||||
Keeping these layers separate prevents discovery convenience from silently
|
||||
becoming a synchronization trust decision.
|
||||
|
||||
## Discovery and direct communication
|
||||
|
||||
Furumi separates finding content from transferring it.
|
||||
|
||||
```text
|
||||
discovery plane
|
||||
Local catalog ───────> DHT <─────── Other catalogs
|
||||
│
|
||||
│ peer + content identity
|
||||
v
|
||||
direct P2P connection
|
||||
├── catalog protocol
|
||||
├── audio protocol
|
||||
└── device-sync protocol
|
||||
```
|
||||
|
||||
Key rules:
|
||||
The DHT is the distributed index. Nodes publish compact searchable
|
||||
descriptions of their local library and query the network without contacting a
|
||||
central search service.
|
||||
|
||||
- **Single source of truth**: one `AppState` struct, mutated only in `update()`.
|
||||
Views are pure render functions over `&AppState`.
|
||||
- **No blocking in the UI loop.** All I/O (HTTP, audio open) happens in spawned
|
||||
tasks that report back via the event channel. Every remote list is a
|
||||
`Loadable<T> { NotAsked, Loading, Loaded(T), Failed(Error) }` so views can
|
||||
render spinners and errors honestly.
|
||||
- **Input → Action indirection**: raw key events are translated by the keymap
|
||||
into semantic `Action`s (`PlayPause`, `FocusNextTab`, `Select`, `Back`,
|
||||
`SeekForward(10)`). Views never see raw keys; this is what makes bindings
|
||||
configurable and the app testable.
|
||||
Once a peer is known, communication moves to direct P2P streams provided by
|
||||
iroh through `music-dht`. Furumi defines separate application protocols for
|
||||
catalog requests, audio transfer, and trusted-device synchronization. This
|
||||
keeps discovery traffic small and lets large or private exchanges happen only
|
||||
between the participating peers.
|
||||
|
||||
### Module layout (single crate now, splittable later)
|
||||
Relay-assisted connectivity may help peers establish a route, but relays do
|
||||
not become catalog authorities or application-state owners.
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs // setup: terminal guard, tokio, channels, run loop
|
||||
config/ // Config + keymap loading (figment or manual TOML merge)
|
||||
api/ // typed client for /api/player/*
|
||||
client.rs // reqwest wrapper: base_url, bearer auth, retries
|
||||
auth.rs // password login, token store, auto-refresh (15min TTL)
|
||||
models.rs // ArtistCard, Release, TrackItem, PlaylistCard, ...
|
||||
player/ // playback engine
|
||||
backend.rs // AudioBackend trait
|
||||
rodio_backend.rs // rodio Player + stream-download sources
|
||||
queue.rs // queue, shuffle, repeat_mode, next-track prefetch
|
||||
sync/ // connected devices: heartbeat/poll loop, command handling
|
||||
app/ // AppState, Action, Event, update()
|
||||
ui/ // ratatui rendering
|
||||
views/ // library_grid, artist, release, playlists, search,
|
||||
// queue, devices, now_playing bar, popups
|
||||
theme.rs
|
||||
## Identity and content resolution
|
||||
|
||||
Network operations refer to content independently of any one library row.
|
||||
Content identifiers allow a node to ask:
|
||||
|
||||
1. Is this track already present in my local library?
|
||||
2. Does one of my trusted devices have it?
|
||||
3. Which federation peers currently advertise it?
|
||||
|
||||
Resolution follows that order conceptually: prefer a ready local source, reuse
|
||||
known content where possible, and fetch from a peer only when necessary.
|
||||
|
||||
A federated track can initially exist as a lightweight pending item in a queue
|
||||
or playlist. When playback reaches it, Furumi resolves an available peer,
|
||||
starts the transfer, and either uses the cache or imports the result into the
|
||||
local library. The rest of the player continues to work with the same
|
||||
`TrackItem` model, so local and remote availability do not require separate
|
||||
playback systems.
|
||||
|
||||
## Offline-first synchronization
|
||||
|
||||
Trusted devices do not share a live database connection. Each device records
|
||||
operations locally and exchanges them when connectivity returns.
|
||||
|
||||
The synchronization model combines:
|
||||
|
||||
- immutable operation identifiers for deduplication;
|
||||
- hybrid logical timestamps for deterministic last-writer decisions;
|
||||
- materialized tables for fast UI queries;
|
||||
- tombstones so deletion survives offline replicas;
|
||||
- per-peer acknowledgements to determine when old tombstones can be compacted;
|
||||
- snapshots to repair a peer that missed older operations.
|
||||
|
||||
This is eventual consistency scoped to a trusted device group. A temporarily
|
||||
offline laptop can modify playlists, another device can continue playback, and
|
||||
both can later converge without a permanently available coordinator.
|
||||
|
||||
Membership changes use the same replicated model. Revocation is state that
|
||||
must propagate and converge, not an ephemeral server-side session flag.
|
||||
|
||||
## Playback across devices
|
||||
|
||||
Playback has one logical state but remains physically local to the device
|
||||
producing audio.
|
||||
|
||||
The synchronized state describes the queue, current item, position, pause
|
||||
state, volume, shuffle/repeat mode, and the active playback owner. Commands are
|
||||
targeted and deduplicated. Handoff transfers intent and position; the receiving
|
||||
device resolves the track against its own library or federation sources before
|
||||
starting its audio engine.
|
||||
|
||||
Active-device leases and idle timing prevent stale snapshots from immediately
|
||||
taking control after a device reconnects. This provides practical coordination
|
||||
without introducing a central playback arbiter.
|
||||
|
||||
## Local application architecture
|
||||
|
||||
Inside one node, Furumi uses an event-driven state machine:
|
||||
|
||||
```text
|
||||
terminal / player / database / network / OS media events
|
||||
│
|
||||
v
|
||||
AppEvent
|
||||
│
|
||||
input → Action
|
||||
│
|
||||
v
|
||||
update(AppState)
|
||||
│
|
||||
optional Effect
|
||||
│
|
||||
v
|
||||
asynchronous runtime work
|
||||
│
|
||||
└──────────> AppEvent
|
||||
```
|
||||
|
||||
The `api`, `player`, and `app` layers do not import `ui` or ratatui. If a
|
||||
shared core for furumi_macos/android ever makes sense, those modules extract
|
||||
into workspace crates without surgery.
|
||||
`AppState` is the single source of truth for the interface. The update layer
|
||||
performs deterministic state transitions and requests effects; it does not
|
||||
perform blocking I/O. SQLite access, imports, artwork decoding, DHT queries,
|
||||
peer transfers, and audio preparation run through runtime services and report
|
||||
their results as events.
|
||||
|
||||
## 3. UI model
|
||||
This structure gives the TUI three important properties:
|
||||
|
||||
Persistent layout: a tab bar on top, the active view in the middle, a
|
||||
now-playing/status bar at the bottom (track, position gauge, volume, shuffle/
|
||||
repeat, active device indicator).
|
||||
- rendering is a pure projection of current state;
|
||||
- input behavior can be tested without starting audio or networking;
|
||||
- slow peers and large imports cannot block terminal interaction.
|
||||
|
||||
Tabs (each owns a navigation stack, like a browser per tab):
|
||||
The audio engine is similarly isolated. The application owns the logical
|
||||
queue, while `player::Controller` owns rodio playback and receives explicit
|
||||
commands. Prefetching prepares the next source before the current item ends.
|
||||
|
||||
1. **Library** — paginated grid of artist tiles (`GET /artists`).
|
||||
`Enter` on a tile pushes **Artist view** (`GET /artists/{id}`: metadata,
|
||||
top tracks, releases list). Selecting a release pushes **Release view**
|
||||
(`GET /releases/{id}`: metadata + track list). `Esc`/`Backspace` pops.
|
||||
2. **Search** — debounced `GET /search?q=` with artists/releases/tracks sections.
|
||||
3. **Playlists** — own + saved playlists, likes ("Liked tracks" virtual playlist).
|
||||
4. **Queue** — current play queue, reorder/remove.
|
||||
5. **Devices** — connected devices list, pick active device, transfer playback.
|
||||
## Scripted visualizations
|
||||
|
||||
Navigation state is `Vec<Route>` per tab; a `Route` is an enum
|
||||
(`ArtistGrid { page }`, `Artist { id }`, `Release { id }`, ...). Views cache
|
||||
their loaded data in `AppState` keyed by route so Back is instant.
|
||||
Visualizations are an extension boundary rather than hard-coded rendering
|
||||
paths. Rust owns audio sampling, script execution, validation, and terminal
|
||||
drawing; Rhai scripts own the visual composition.
|
||||
|
||||
Tile grid: computed from terminal width (`Layout` columns × rows), each tile a
|
||||
bordered block with artist name (cover art rendering in-terminal is a later,
|
||||
optional feature — e.g. ratatui-image with kitty/sixel detection, never a
|
||||
hard dependency).
|
||||
|
||||
## 4. Backend integration notes
|
||||
|
||||
(Verified against the furumusic source; base path `/api/player`.)
|
||||
|
||||
- **Auth**: `POST /api/auth/password` → access token (15 min) + refresh token
|
||||
(60 days). Client stores tokens at `~/.config/furumi/credentials.json`
|
||||
(0600) and refreshes proactively via `POST /api/auth/refresh`. All API calls
|
||||
go through one client that retries once on 401 after refreshing.
|
||||
- **Streaming**: `GET /api/player/stream/{track_id}` with `Accept-Ranges:
|
||||
bytes` — exactly what stream-download needs for seek. Original files are
|
||||
served untranscoded (mp3/flac/ogg/m4a/...), hence the symphonia feature set.
|
||||
- **Playback state**: persisted server-side via `PUT /api/player/state`
|
||||
(queue, position, shuffle, repeat, volume). We push throttled updates
|
||||
(on track change + every ~10s while playing) and restore on startup.
|
||||
- **History/scrobbling**: `POST /history` on track completion;
|
||||
`POST /lastfm/now-playing` and `/lastfm/scrobble` if last.fm is connected.
|
||||
- **Connected devices**: *polling, not websockets.* The sync task:
|
||||
- sends `POST /devices/poll` every ~5s while the app runs (device TTL is
|
||||
30s; commands TTL 20s) with our stable `device_id` (generated once,
|
||||
persisted) and current `playback_state`;
|
||||
- applies returned commands (`transfer_state` → load queue/position and
|
||||
start/stop locally; play/pause/seek commands when we are the active
|
||||
device but controlled remotely);
|
||||
- feeds the device list into the Devices tab. Activating another device =
|
||||
`POST /devices/active`; we then stop local audio and become a remote
|
||||
control (UI keeps working, actions are sent via `POST /devices/command`).
|
||||
- **Jams** (collaborative sessions) exist in the API — out of scope for v1,
|
||||
but the sync task's command-handling design must not preclude them.
|
||||
|
||||
## 5. Reliability checklist
|
||||
|
||||
- Terminal guard type + panic hook: raw mode/alternate screen/keyboard flags
|
||||
always restored, even on panic.
|
||||
- Every spawned task's failure becomes an `Event::TaskFailed` rendered as a
|
||||
status-bar error — no silent hangs, no `unwrap` on I/O.
|
||||
- Token refresh races guarded by a single-flight lock.
|
||||
- Audio device disappearance (headphones unplugged) → backend emits
|
||||
`PlayerEvent::Failed`, engine retries on default device, pauses on repeated
|
||||
failure.
|
||||
- Config/keymap parse errors are reported with line context and fall back to
|
||||
defaults — a typo in keymap.toml must not brick the app.
|
||||
|
||||
## 6. Suggested initial dependencies
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
crokey = "1.4"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
rodio = { version = "0.22", features = ["symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } # check exact feature names
|
||||
stream-download = { version = "0.24", features = ["reqwest"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
directories = "6" # config/cache paths per-OS
|
||||
tracing = "0.1" # file-based logging (never stdout — it's the UI)
|
||||
tracing-subscriber = "0.3"
|
||||
```text
|
||||
rodio source
|
||||
│
|
||||
v
|
||||
audio analyzer ──> normalized features + scope samples
|
||||
│
|
||||
v
|
||||
Rhai render(input)
|
||||
│
|
||||
v
|
||||
validated draw commands
|
||||
│
|
||||
v
|
||||
ratatui frame buffer
|
||||
```
|
||||
|
||||
## 7. Build order (milestones)
|
||||
The player analyzer derives a bounded, renderer-independent input model:
|
||||
energy, bass, mid, treble, beat strength, waveform samples, playback progress,
|
||||
volume, pause state, track metadata, time, and terminal dimensions. Each
|
||||
script implements `render(input)` and returns declarative commands such as
|
||||
clear, cell, line, rectangle, trace, and text. Scripts never receive the
|
||||
ratatui frame or audio engine directly.
|
||||
|
||||
1. Skeleton: terminal guard, event loop, tab bar, status bar, keymap with defaults.
|
||||
2. `api` crate-module: auth + artists/releases/tracks; Library grid → Artist → Release navigation.
|
||||
3. Playback: rodio backend + stream-download, queue, now-playing bar, seek/volume.
|
||||
4. Likes, playlists, search, history reporting.
|
||||
5. Device sync: heartbeat/poll, transfer playback, remote-control mode.
|
||||
6. Polish: server-side state restore, last.fm, config file, themes, optional cover art.
|
||||
This command boundary is intentional:
|
||||
|
||||
- scripts remain independent of Rust UI internals;
|
||||
- the host validates command shapes, colors, coordinates, and arrays;
|
||||
- drawing is clipped to the current terminal area;
|
||||
- script failures become an in-UI visualizer error instead of corrupting the
|
||||
terminal or stopping playback.
|
||||
|
||||
Rhai files live in the user's visualization directory. The runtime discovers
|
||||
them dynamically, compiles the selected script, caches its AST, and recompiles
|
||||
it when the file modification time changes. A visualization can therefore be
|
||||
created or edited while Furumi is running without rebuilding or restarting the
|
||||
application. Bundled scripts use the same path and contract as user scripts,
|
||||
so built-in and custom visualizations exercise the same runtime.
|
||||
|
||||
The Rhai engine is configured as a sandboxed computation environment. Module
|
||||
loading through `import` and `export` is disabled, no filesystem or network API
|
||||
is exposed to scripts, and execution is bounded by limits on operations, call
|
||||
depth, variables, functions, expression depth, and collection/string sizes.
|
||||
Only the input map, Rhai language primitives, and a small set of mathematical
|
||||
helpers are available.
|
||||
|
||||
The sandbox protects responsiveness and keeps visualization code in its
|
||||
intended role: transforming current audio features into drawing commands. It
|
||||
is not a plugin mechanism for accessing the library, network, or player
|
||||
controls.
|
||||
|
||||
## Persistence boundaries
|
||||
|
||||
Furumi stores different kinds of state according to their lifetime:
|
||||
|
||||
| State | Storage | Role |
|
||||
| --- | --- | --- |
|
||||
| Library, playlists, likes, history | SQLite | Durable local source of truth |
|
||||
| Device operation log and replicas | SQLite | Offline synchronization |
|
||||
| Federation catalog cache | SQLite/cache | Faster network browsing |
|
||||
| Audio and artwork cache | Filesystem cache | Reusable fetched data |
|
||||
| Settings, keymap, identity | Platform config/data dirs | Node configuration |
|
||||
| Queue and playback snapshots | Application/device sync state | Continuity and handoff |
|
||||
|
||||
Caches are replaceable. The local library and device operation log are durable.
|
||||
This distinction lets maintenance and recovery code discard derived network
|
||||
data without risking the user's collection.
|
||||
|
||||
## Failure model
|
||||
|
||||
Expected failures are treated as ordinary state:
|
||||
|
||||
- a DHT query may return partial results;
|
||||
- an advertised peer may be offline by the time a track is requested;
|
||||
- a transfer may stop and be retried through another source;
|
||||
- trusted devices may reconnect with overlapping changes;
|
||||
- cached metadata may be stale;
|
||||
- an audio device may disappear during playback.
|
||||
|
||||
The architecture avoids converting these cases into global failure. Search can
|
||||
show partial data, synchronization can resume, and playback resolution can try
|
||||
another source. Errors return to the application as events so the UI can expose
|
||||
them without terminating the node.
|
||||
|
||||
## Main implementation boundaries
|
||||
|
||||
The source tree follows the architectural responsibilities:
|
||||
|
||||
- `library/` owns the local catalog and import pipeline;
|
||||
- `player/` owns audio playback and analysis;
|
||||
- `federation/` owns DHT-facing search, peer catalogs, and audio exchange;
|
||||
- `devices.rs` owns trusted-device replication and playback coordination;
|
||||
- `app/` owns state transitions and runtime orchestration;
|
||||
- `ui/` renders the TUI;
|
||||
- `media.rs` integrates platform media controls;
|
||||
- `visualizer.rs` hosts programmable Rhai visualizations.
|
||||
|
||||
Dependencies should continue to point inward toward typed models and explicit
|
||||
events. UI code should not own network tasks, network protocols should not
|
||||
mutate UI state directly, and the local library should not depend on the
|
||||
presence of federation.
|
||||
|
||||
## Architectural invariants
|
||||
|
||||
Future changes should preserve these rules:
|
||||
|
||||
1. A node must start and play its local library without network access.
|
||||
2. No central Furumi service may become required for discovery, playback, or
|
||||
trusted-device synchronization.
|
||||
3. Federation membership must not imply personal-device trust.
|
||||
4. Remote state must be merged or cached locally, never treated as an always
|
||||
available database.
|
||||
5. Network and storage work must remain outside the interactive UI path.
|
||||
6. More peers should improve availability; losing peers should only reduce
|
||||
remote capabilities.
|
||||
|
||||
Generated
+2255
-924
File diff suppressed because it is too large
Load Diff
+13
-7
@@ -1,7 +1,10 @@
|
||||
[package]
|
||||
name = "furumi_tui"
|
||||
version = "0.1.1"
|
||||
version = "0.1.8"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
description = "A federated P2P player for personal music libraries"
|
||||
license = "WTFPL"
|
||||
|
||||
[[bin]]
|
||||
name = "furumi"
|
||||
@@ -9,22 +12,25 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
blake3 = "1"
|
||||
crokey = "1.4.0"
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||
directories = "6.0.0"
|
||||
futures-util = "0.3.32"
|
||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
||||
open = "5.3.5"
|
||||
lofty = "0.22"
|
||||
# P2P federation: library index in a shared DHT + audio streaming between
|
||||
# peers (same protocol as furumi-fd).
|
||||
music-dht = { git = "https://gt.hexor.cy/ab/frid.git", rev = "a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" }
|
||||
ratatui = "0.30.1"
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
|
||||
rhai = { version = "1", features = ["sync"] }
|
||||
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
||||
stream-download = { version = "0.24.1", default-features = false, features = ["reqwest-rustls", "temp-storage"] }
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
|
||||
toml = "1.1.2"
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
@@ -34,7 +40,7 @@ unicode-width = "0.2.2"
|
||||
core-foundation = "0.10.1"
|
||||
|
||||
[target."cfg(windows)".dependencies]
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader", "Win32_Graphics_Gdi"] }
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader", "Win32_Graphics_Gdi", "Win32_System_Console", "Win32_System_Pipes"] }
|
||||
|
||||
[target."cfg(unix)".dependencies]
|
||||
libc = "0.2.186"
|
||||
|
||||
@@ -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,44 +1,145 @@
|
||||
# furumi
|
||||
|
||||
Terminal client (TUI) for the furumusic server. Cross-platform: Linux,
|
||||
macOS, Windows.
|
||||

|
||||
|
||||
## Building
|
||||
**Your music. Your devices. Your network.**
|
||||
|
||||
Rust 1.88+ (edition 2024).
|
||||
Furumi is a federated P2P player for your personal music library. Every
|
||||
running player is a complete, self-sufficient music library: it can import,
|
||||
organize, search, and play your collection without an account, a cloud
|
||||
backend, or a central service.
|
||||
|
||||
Connect Furumi on your desktop, laptop, or another device and they become one
|
||||
personal music network. Playback, likes, playlists, and library state can move
|
||||
between your devices, while missing tracks can be requested directly from
|
||||
another player.
|
||||
|
||||
Furumi runs on **Linux, macOS, and Windows**.
|
||||
|
||||
## Why Furumi?
|
||||
|
||||
Music you own should not disappear because a subscription ended, a catalog
|
||||
changed, a service was censored, or a server went offline.
|
||||
|
||||
Furumi is built around a different model:
|
||||
|
||||
- your library remains under your control;
|
||||
- every player works independently;
|
||||
- there is no central account or single point of failure;
|
||||
- connecting more players improves availability instead of creating a new
|
||||
dependency;
|
||||
- federation is optional and simple to configure.
|
||||
|
||||
A single Furumi instance is already useful. A group of instances becomes a
|
||||
resilient network for your music.
|
||||
|
||||
## How it works
|
||||
|
||||
Each player maintains its own local library and publishes a searchable view of
|
||||
it into Furumi's DHT network. Discovery does not depend on a central index.
|
||||
|
||||
Clients connect directly over P2P transport built on
|
||||
[iroh](https://www.iroh.computer/). Furumi adds a synchronization protocol on
|
||||
top of that transport to keep trusted devices consistent even when they are
|
||||
not always online.
|
||||
|
||||
In practice:
|
||||
|
||||
1. Import music into any Furumi player.
|
||||
2. Pair your other players or join a federation network.
|
||||
3. Devices discover available libraries through the DHT.
|
||||
4. Likes, playlists, playback state, and library metadata synchronize between
|
||||
trusted clients.
|
||||
5. If a track is missing locally, Furumi can fetch it directly from another
|
||||
client.
|
||||
|
||||
There is no coordination server in the middle. Local players remain usable
|
||||
when peers are offline, and the network becomes more capable as peers appear.
|
||||
|
||||
## The player
|
||||
|
||||
Furumi includes a full-featured terminal interface for browsing artists and
|
||||
releases, searching, managing playlists and the queue, controlling playback,
|
||||
inspecting connected devices, and configuring federation.
|
||||
|
||||
The interface supports keyboard-driven navigation, multi-key combinations,
|
||||
context-aware bindings, and user-defined rebinding through TOML. Built-in
|
||||
audio visualizations, OS media controls, gapless queue playback, and local
|
||||
library management are included. Visualizations are runtime-loadable Rhai
|
||||
scripts executed in a resource-limited sandbox, so they can be added or edited
|
||||
without rebuilding the player.
|
||||
|
||||
## Install
|
||||
|
||||
### macOS
|
||||
|
||||
On Apple Silicon Macs, install Furumi from the Homebrew tap:
|
||||
|
||||
```bash
|
||||
cargo build --release # binary: target/release/furumi
|
||||
brew install house-of-vanity/tap/furumi
|
||||
```
|
||||
|
||||
### Linux
|
||||
Run it with:
|
||||
|
||||
Sound output needs the system ALSA library — the one and only system
|
||||
build dependency (PipeWire/PulseAudio are reached through the ALSA
|
||||
compatibility layer at runtime):
|
||||
```bash
|
||||
furumi
|
||||
```
|
||||
|
||||
### Linux, Windows, and other platforms
|
||||
|
||||
Download a prebuilt archive from the
|
||||
[GitHub releases](https://github.com/house-of-vanity/furumi_tui/releases), or
|
||||
build Furumi from source with Rust 1.97 or newer:
|
||||
|
||||
```bash
|
||||
cargo build --release --locked
|
||||
./target/release/furumi
|
||||
```
|
||||
|
||||
On Debian or Ubuntu, install the Linux audio build dependencies first:
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt install libasound2-dev pkg-config
|
||||
# Fedora
|
||||
sudo dnf install alsa-lib-devel pkgconf-pkg-config
|
||||
# Arch
|
||||
sudo pacman -S alsa-lib pkgconf
|
||||
```
|
||||
|
||||
Everything else is pure Rust: TLS is rustls, MPRIS media keys go through
|
||||
zbus (no libdbus), images and audio decoding are Rust crates.
|
||||
Equivalent ALSA development packages are required on other Linux
|
||||
distributions. macOS and Windows require no additional system packages.
|
||||
|
||||
### macOS / Windows
|
||||
Import a music directory from Furumi's command line:
|
||||
|
||||
No system packages required.
|
||||
```text
|
||||
:import /path/to/music
|
||||
```
|
||||
|
||||
## Configuration
|
||||
Federation, trusted-device pairing, and key bindings are configured directly
|
||||
inside the player.
|
||||
|
||||
- `keymap.toml` in the config dir — keybinding overrides, see
|
||||
`src/config/default_keymap.toml` for the format and defaults.
|
||||
Config dir: `~/.config/furumi` on Linux,
|
||||
`~/Library/Application Support/furumi` on macOS.
|
||||
- `credentials.json` in the same dir — created on login (0600).
|
||||
- Logs: in-app on the Logs tab (`5`), and in the cache dir
|
||||
(`furumi-cli.log`), filtered by `RUST_LOG`.
|
||||
## Architecture
|
||||
|
||||
Furumi is a Rust application built with:
|
||||
|
||||
- `ratatui` and `crossterm` for the cross-platform TUI;
|
||||
- `rodio` for local audio playback;
|
||||
- SQLite for the personal library and synchronization state;
|
||||
- a dedicated DHT for decentralized discovery;
|
||||
- iroh-based P2P streams for client-to-client communication;
|
||||
- an offline-tolerant operation log for trusted-device synchronization;
|
||||
- Rhai for programmable audio visualizations.
|
||||
|
||||
More detail is available in [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, design discussions, and patches are welcome. Before submitting a
|
||||
change, run:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo check --all-targets
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Furumi is released under the
|
||||
[Do What The Fuck You Want To Public License, Version 2](LICENSE).
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
-226
@@ -1,226 +0,0 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::models::{TokensResponse, User};
|
||||
|
||||
/// Margin before access-token expiry at which we refresh proactively,
|
||||
/// mirroring the Android/macOS clients.
|
||||
pub const EXPIRY_SKEW_SECONDS: i64 = 60;
|
||||
|
||||
/// Persisted session, same shape as the macOS client's AuthSession.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthSession {
|
||||
pub server_base_url: String,
|
||||
pub user: User,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_at_epoch_seconds: i64,
|
||||
}
|
||||
|
||||
impl AuthSession {
|
||||
pub fn new(server_base_url: String, user: User, tokens: TokensResponse) -> Self {
|
||||
Self {
|
||||
server_base_url,
|
||||
user,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_type: tokens.token_type,
|
||||
expires_at_epoch_seconds: now_epoch_seconds() + tokens.expires_in_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_tokens(&mut self, tokens: TokensResponse) {
|
||||
self.access_token = tokens.access_token;
|
||||
self.refresh_token = tokens.refresh_token;
|
||||
self.token_type = tokens.token_type;
|
||||
self.expires_at_epoch_seconds = now_epoch_seconds() + tokens.expires_in_seconds;
|
||||
}
|
||||
|
||||
pub fn access_token_expired(&self) -> bool {
|
||||
now_epoch_seconds() + EXPIRY_SKEW_SECONDS >= self.expires_at_epoch_seconds
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now_epoch_seconds() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn session_path() -> Option<PathBuf> {
|
||||
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("credentials.json"))
|
||||
}
|
||||
|
||||
pub fn load_session() -> Option<AuthSession> {
|
||||
let path = session_path()?;
|
||||
let text = fs::read_to_string(&path).ok()?;
|
||||
match serde_json::from_str(&text) {
|
||||
Ok(session) => Some(session),
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %path.display(), %err, "ignoring unreadable credentials file");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_session(session: &AuthSession) -> Result<()> {
|
||||
let path = session_path().context("cannot determine config directory")?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(session)?;
|
||||
write_private(&path, &text).with_context(|| format!("writing {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn delete_session() {
|
||||
if let Some(path) = session_path() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
file.write_all(text.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> {
|
||||
fs::write(path, text)
|
||||
}
|
||||
|
||||
/// Same normalization rules as the Android client's ServerConfig:
|
||||
/// add https:// when no scheme, require http(s) with a host, reject
|
||||
/// credentials/query/fragment, lowercase the host, trim trailing slashes.
|
||||
pub fn normalize_base_url(raw: &str) -> Result<String> {
|
||||
let trimmed = raw.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
bail!("server URL is empty");
|
||||
}
|
||||
let with_scheme = if trimmed.contains("://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("https://{trimmed}")
|
||||
};
|
||||
let url = reqwest::Url::parse(&with_scheme).context("invalid server URL")?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
bail!("server URL must use http or https");
|
||||
}
|
||||
let host = url.host_str().filter(|h| !h.is_empty());
|
||||
let Some(host) = host else {
|
||||
bail!("server URL has no host");
|
||||
};
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
bail!("server URL must not contain credentials");
|
||||
}
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
bail!("server URL must not contain a query or fragment");
|
||||
}
|
||||
let mut normalized = format!("{}://{}", url.scheme(), host.to_ascii_lowercase());
|
||||
if let Some(port) = url.port() {
|
||||
normalized.push_str(&format!(":{port}"));
|
||||
}
|
||||
let path = url.path().trim_end_matches('/');
|
||||
normalized.push_str(path);
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Accepts what the user pastes after browser SSO: either the full
|
||||
/// `furumi://auth/callback?code=furu_mx_...` link (copied from the
|
||||
/// "Open Furumi" button) or the bare `furu_mx_...` code.
|
||||
pub fn extract_sso_code(input: &str) -> Result<String> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
bail!("paste the link or code first");
|
||||
}
|
||||
if input.starts_with("furu_mx_") {
|
||||
return Ok(input.to_string());
|
||||
}
|
||||
if let Ok(url) = reqwest::Url::parse(input) {
|
||||
if let Some((_, error)) = url.query_pairs().find(|(k, _)| k == "error") {
|
||||
bail!("SSO failed: {error}");
|
||||
}
|
||||
if let Some((_, code)) = url.query_pairs().find(|(k, _)| k == "code") {
|
||||
return Ok(code.into_owned());
|
||||
}
|
||||
}
|
||||
bail!("no furu_mx_ code found in the pasted text");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_adds_https_and_strips_slash() {
|
||||
assert_eq!(
|
||||
normalize_base_url(" Music.Hexor.cy/ ").unwrap(),
|
||||
"https://music.hexor.cy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_keeps_port_and_path() {
|
||||
assert_eq!(
|
||||
normalize_base_url("http://localhost:8000/furumi/").unwrap(),
|
||||
"http://localhost:8000/furumi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_bad_urls() {
|
||||
assert!(normalize_base_url("").is_err());
|
||||
assert!(normalize_base_url("ftp://x").is_err());
|
||||
assert!(normalize_base_url("https://user:pw@host").is_err());
|
||||
assert!(normalize_base_url("https://host?x=1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sso_code_from_deep_link() {
|
||||
let code = extract_sso_code("furumi://auth/callback?code=furu_mx_abc123").unwrap();
|
||||
assert_eq!(code, "furu_mx_abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sso_code_bare() {
|
||||
assert_eq!(extract_sso_code(" furu_mx_x ").unwrap(), "furu_mx_x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sso_error_is_reported() {
|
||||
let err = extract_sso_code("furumi://auth/callback?error=provider_denied")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("provider_denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_uses_skew() {
|
||||
let session = AuthSession {
|
||||
server_base_url: "https://x".into(),
|
||||
user: User {
|
||||
id: 1,
|
||||
name: "n".into(),
|
||||
role: "user".into(),
|
||||
},
|
||||
access_token: "a".into(),
|
||||
refresh_token: "r".into(),
|
||||
token_type: "Bearer".into(),
|
||||
expires_at_epoch_seconds: now_epoch_seconds() + 30,
|
||||
};
|
||||
assert!(session.access_token_expired());
|
||||
}
|
||||
}
|
||||
@@ -1,610 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::auth::{self, AuthSession};
|
||||
use super::models::{
|
||||
ApiErrorBody, ArtistDetail, ArtistsPage, DevicePlaybackState, DevicePollResponse,
|
||||
LikesResponse, LoginResponse, MeResponse, PlaylistCard, PlaylistDetail, ReleaseDetail,
|
||||
SearchResults, TokensResponse, TrackItem,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
#[error("{0}")]
|
||||
Server(String),
|
||||
/// Refresh token rejected or expired — the user must sign in again.
|
||||
#[error("session expired, please sign in again")]
|
||||
SessionExpired,
|
||||
#[error("network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("{0}")]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub fn http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(format!(
|
||||
"furumi-tui/{} ({})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::env::consts::OS
|
||||
))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("reqwest client config is static")
|
||||
}
|
||||
|
||||
pub fn device_name() -> String {
|
||||
format!("furumi-tui ({})", std::env::consts::OS)
|
||||
}
|
||||
|
||||
pub fn device_user_agent() -> String {
|
||||
format!(
|
||||
"FurumiTUI/{} {}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::env::consts::OS
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PasswordLoginRequest<'a> {
|
||||
username: &'a str,
|
||||
password: &'a str,
|
||||
device_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SsoExchangeRequest<'a> {
|
||||
code: &'a str,
|
||||
device_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest<'a> {
|
||||
refresh_token: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LogoutRequest<'a> {
|
||||
refresh_token: &'a str,
|
||||
}
|
||||
|
||||
pub async fn login_password(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<AuthSession, ApiError> {
|
||||
let response = http
|
||||
.post(format!("{base_url}/api/auth/password"))
|
||||
.json(&PasswordLoginRequest {
|
||||
username,
|
||||
password,
|
||||
device_name: device_name(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
let login: LoginResponse = parse_response(response).await?;
|
||||
Ok(AuthSession::new(
|
||||
base_url.to_string(),
|
||||
login.user,
|
||||
login.tokens,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn login_sso_exchange(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
code: &str,
|
||||
) -> Result<AuthSession, ApiError> {
|
||||
let response = http
|
||||
.post(format!("{base_url}/api/auth/sso/exchange"))
|
||||
.json(&SsoExchangeRequest {
|
||||
code,
|
||||
device_name: device_name(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
let login: LoginResponse = parse_response(response).await?;
|
||||
Ok(AuthSession::new(
|
||||
base_url.to_string(),
|
||||
login.user,
|
||||
login.tokens,
|
||||
))
|
||||
}
|
||||
|
||||
/// Browser entry point for SSO. redirect_uri is either our loopback
|
||||
/// listener (`http://127.0.0.1:{port}/callback`) or the `furumi://` deep
|
||||
/// link as a manual-paste fallback.
|
||||
pub fn sso_start_url(base_url: &str, redirect_uri: &str) -> String {
|
||||
let mut url = reqwest::Url::parse(&format!("{base_url}/auth/mobile/oidc/start"))
|
||||
.expect("base_url is pre-validated");
|
||||
url.query_pairs_mut()
|
||||
.append_pair("redirect_uri", redirect_uri);
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
async fn refresh_tokens(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<TokensResponse, ApiError> {
|
||||
let response = http
|
||||
.post(format!("{base_url}/api/auth/refresh"))
|
||||
.json(&RefreshRequest { refresh_token })
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(ApiError::SessionExpired);
|
||||
}
|
||||
parse_response(response).await
|
||||
}
|
||||
|
||||
/// Mirrors the backend's PlaybackStateDto.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PlaybackStateBody {
|
||||
pub current_track_id: Option<i64>,
|
||||
pub position_ms: i32,
|
||||
pub queue: Vec<i64>,
|
||||
pub queue_position: i32,
|
||||
pub shuffle: bool,
|
||||
pub repeat_mode: String,
|
||||
pub volume: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DevicePollRequest<'a> {
|
||||
device_id: &'a str,
|
||||
user_agent: String,
|
||||
current_jam_id: Option<&'a str>,
|
||||
playback_state: Option<DevicePlaybackState>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeviceActiveRequest<'a> {
|
||||
device_id: &'a str,
|
||||
current_device_id: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeviceCommandRequest<'a> {
|
||||
target_device_id: Option<&'a str>,
|
||||
jam_id: Option<&'a str>,
|
||||
command: &'a str,
|
||||
payload: &'a serde_json::Value,
|
||||
}
|
||||
|
||||
/// Percent-encode a query-string value.
|
||||
fn url_encode(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(byte as char);
|
||||
}
|
||||
_ => out.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, ApiError> {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return Ok(response.json().await?);
|
||||
}
|
||||
let message = match response.json::<ApiErrorBody>().await {
|
||||
Ok(body) => body.error,
|
||||
Err(_) => format!("server returned {status}"),
|
||||
};
|
||||
Err(ApiError::Server(message))
|
||||
}
|
||||
|
||||
/// Authenticated API client. Owns the session; refreshes the access token
|
||||
/// proactively (60s skew) and once more on 401, persisting rotated tokens.
|
||||
/// The session mutex makes concurrent refreshes single-flight.
|
||||
pub struct ApiClient {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
session: Mutex<AuthSession>,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(http: reqwest::Client, session: AuthSession) -> Self {
|
||||
Self {
|
||||
http,
|
||||
base_url: session.server_base_url.clone(),
|
||||
session: Mutex::new(session),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
pub async fn me(&self) -> Result<MeResponse, ApiError> {
|
||||
self.get_json("/api/player/me").await
|
||||
}
|
||||
|
||||
pub async fn artists(&self, page: i64, limit: i64) -> Result<ArtistsPage, ApiError> {
|
||||
self.get_json(&format!("/api/player/artists?page={page}&limit={limit}"))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn artist(&self, id: i64) -> Result<ArtistDetail, ApiError> {
|
||||
self.get_json(&format!("/api/player/artists/{id}")).await
|
||||
}
|
||||
|
||||
pub async fn release(&self, id: i64) -> Result<ReleaseDetail, ApiError> {
|
||||
self.get_json(&format!("/api/player/releases/{id}")).await
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str, limit: i64) -> Result<SearchResults, ApiError> {
|
||||
self.get_json(&format!(
|
||||
"/api/player/search?q={}&limit={limit}",
|
||||
url_encode(query)
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Open an audio stream for playback: background download backed by a
|
||||
/// temp file, exposing blocking Read+Seek for the decoder; seeking into
|
||||
/// not-yet-downloaded ranges uses HTTP Range requests.
|
||||
///
|
||||
/// The download client carries the bearer token valid at start; on very
|
||||
/// long tracks a Range request after token expiry (15 min) can fail —
|
||||
/// acceptable for now, a refreshing middleware can replace this later.
|
||||
pub async fn open_stream(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<(crate::player::TrackReader, Option<u64>), ApiError> {
|
||||
use stream_download::Settings;
|
||||
use stream_download::http::HttpStream;
|
||||
use stream_download::source::SourceStream as _;
|
||||
use stream_download::storage::temp::TempStorageProvider;
|
||||
|
||||
let token = self.fresh_access_token().await?;
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
let value = format!("Bearer {token}")
|
||||
.parse()
|
||||
.map_err(|_| ApiError::Server("invalid token header".to_string()))?;
|
||||
headers.insert(reqwest::header::AUTHORIZATION, value);
|
||||
let client = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.map_err(ApiError::Network)?;
|
||||
|
||||
let url = format!("{}{path}", self.base_url)
|
||||
.parse()
|
||||
.map_err(|err| ApiError::Server(format!("bad stream url: {err}")))?;
|
||||
let stream = HttpStream::new(client, url)
|
||||
.await
|
||||
.map_err(|err| ApiError::Server(format!("stream open failed: {err}")))?;
|
||||
let byte_len = stream.content_length();
|
||||
let reader = stream_download::StreamDownload::from_stream(
|
||||
stream,
|
||||
TempStorageProvider::new(),
|
||||
Settings::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ApiError::Server(format!("stream start failed: {err}")))?;
|
||||
Ok((reader, byte_len))
|
||||
}
|
||||
|
||||
/// Raw bytes (cover art, artist images) from a server-relative path.
|
||||
pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>, ApiError> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let response = self
|
||||
.send_authed(&url, |client, url, token| {
|
||||
client.get(url).bearer_auth(token)
|
||||
})
|
||||
.await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(ApiError::Server(format!("server returned {status}")));
|
||||
}
|
||||
Ok(response.bytes().await?.to_vec())
|
||||
}
|
||||
|
||||
pub async fn playlists(&self) -> Result<Vec<PlaylistCard>, ApiError> {
|
||||
self.get_json("/api/player/playlists").await
|
||||
}
|
||||
|
||||
pub async fn playlist(&self, id: i64) -> Result<PlaylistDetail, ApiError> {
|
||||
self.get_json(&format!("/api/player/playlists/{id}")).await
|
||||
}
|
||||
|
||||
pub async fn create_playlist(&self, title: &str) -> Result<PlaylistCard, ApiError> {
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
title: &'a str,
|
||||
}
|
||||
self.post_json("/api/player/playlists", &Body { title })
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn add_tracks_to_playlist(
|
||||
&self,
|
||||
playlist_id: i64,
|
||||
track_ids: &[i64],
|
||||
) -> Result<(), ApiError> {
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
track_ids: &'a [i64],
|
||||
}
|
||||
let _: serde_json::Value = self
|
||||
.post_json(
|
||||
&format!("/api/player/playlists/{playlist_id}/tracks"),
|
||||
&Body { track_ids },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn likes(&self) -> Result<Vec<i64>, ApiError> {
|
||||
let response: LikesResponse = self.get_json("/api/player/likes").await?;
|
||||
Ok(response.track_ids)
|
||||
}
|
||||
|
||||
pub async fn toggle_like(&self, track_id: i64) -> Result<bool, ApiError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Body {
|
||||
liked: bool,
|
||||
}
|
||||
let body: Body = self
|
||||
.post_json(&format!("/api/player/likes/toggle/{track_id}"), &())
|
||||
.await?;
|
||||
Ok(body.liked)
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "device-sync state restore needs id→track resolution"
|
||||
)]
|
||||
pub async fn tracks_by_ids(&self, track_ids: &[i64]) -> Result<Vec<TrackItem>, ApiError> {
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
track_ids: &'a [i64],
|
||||
}
|
||||
self.post_json("/api/player/tracks-by-ids", &Body { track_ids })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tell last.fm (via the server) what is playing right now. Called at
|
||||
/// track start; the completed-play scrobble goes through /history.
|
||||
pub async fn lastfm_now_playing(&self, track_id: i64) -> Result<(), ApiError> {
|
||||
#[derive(Serialize)]
|
||||
struct Body {
|
||||
track_id: i64,
|
||||
}
|
||||
let _: serde_json::Value = self
|
||||
.post_json("/api/player/lastfm/now-playing", &Body { track_id })
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report a finished/aborted listen to the play history.
|
||||
/// Body shape is the backend's HistoryEntry; `completed` marks a full
|
||||
/// play (vs a manual skip).
|
||||
pub async fn report_history(
|
||||
&self,
|
||||
track_id: i64,
|
||||
started_at: Option<i64>,
|
||||
listened_seconds: i32,
|
||||
completed: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
#[derive(Serialize)]
|
||||
struct Body {
|
||||
track_id: i64,
|
||||
started_at: Option<i64>,
|
||||
duration_listened: Option<i32>,
|
||||
completed: bool,
|
||||
}
|
||||
let _: serde_json::Value = self
|
||||
.post_json(
|
||||
"/api/player/history",
|
||||
&Body {
|
||||
track_id,
|
||||
started_at,
|
||||
duration_listened: Some(listened_seconds),
|
||||
completed,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist playback state server-side (used for cross-device restore).
|
||||
pub async fn push_state(&self, state: &PlaybackStateBody) -> Result<(), ApiError> {
|
||||
let _: serde_json::Value = self.put_json("/api/player/state", state).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn poll_device(
|
||||
&self,
|
||||
device_id: &str,
|
||||
playback_state: Option<DevicePlaybackState>,
|
||||
) -> Result<DevicePollResponse, ApiError> {
|
||||
self.post_json(
|
||||
"/api/player/devices/poll",
|
||||
&DevicePollRequest {
|
||||
device_id,
|
||||
user_agent: device_user_agent(),
|
||||
current_jam_id: None,
|
||||
playback_state,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn select_device(
|
||||
&self,
|
||||
target_device_id: &str,
|
||||
current_device_id: &str,
|
||||
) -> Result<DevicePollResponse, ApiError> {
|
||||
self.post_json(
|
||||
"/api/player/devices/active",
|
||||
&DeviceActiveRequest {
|
||||
device_id: target_device_id,
|
||||
current_device_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_device_command(
|
||||
&self,
|
||||
target_device_id: Option<&str>,
|
||||
command: &str,
|
||||
payload: &serde_json::Value,
|
||||
) -> Result<(), ApiError> {
|
||||
let _: serde_json::Value = self
|
||||
.post_json(
|
||||
"/api/player/devices/command",
|
||||
&DeviceCommandRequest {
|
||||
target_device_id,
|
||||
jam_id: None,
|
||||
command,
|
||||
payload,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke this device's session server-side. Best effort: local
|
||||
/// credentials are deleted regardless of the outcome.
|
||||
pub async fn logout(&self) -> Result<bool, ApiError> {
|
||||
let (access_token, refresh_token) = {
|
||||
let session = self.session.lock().await;
|
||||
(session.access_token.clone(), session.refresh_token.clone())
|
||||
};
|
||||
let response = self
|
||||
.http
|
||||
.post(format!("{}/api/auth/logout", self.base_url))
|
||||
.bearer_auth(access_token)
|
||||
.json(&LogoutRequest {
|
||||
refresh_token: &refresh_token,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LogoutResponse {
|
||||
revoked: bool,
|
||||
}
|
||||
let body: LogoutResponse = parse_response(response).await?;
|
||||
Ok(body.revoked)
|
||||
}
|
||||
|
||||
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
|
||||
self.json_request::<(), T>(reqwest::Method::GET, path, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T, ApiError> {
|
||||
self.json_request(reqwest::Method::POST, path, Some(body))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn put_json<B: Serialize, T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T, ApiError> {
|
||||
self.json_request(reqwest::Method::PUT, path, Some(body))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn json_request<B: Serialize, T: DeserializeOwned>(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
body: Option<&B>,
|
||||
) -> Result<T, ApiError> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let response = self
|
||||
.send_authed(&url, |client, url, token| {
|
||||
let mut request = client.request(method.clone(), url).bearer_auth(token);
|
||||
if let Some(body) = body {
|
||||
request = request.json(body);
|
||||
}
|
||||
request
|
||||
})
|
||||
.await;
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, %method, path, "api request failed");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
let result = parse_response(response).await;
|
||||
if let Err(err) = &result {
|
||||
tracing::warn!(%err, %status, %method, path, "api response error");
|
||||
} else {
|
||||
tracing::debug!(%status, %method, path, "api ok");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Send a request with a fresh bearer token; on 401, refresh once and
|
||||
/// retry. `build` is called per attempt because RequestBuilder is not
|
||||
/// reusable after send.
|
||||
async fn send_authed<F>(&self, url: &str, build: F) -> Result<reqwest::Response, ApiError>
|
||||
where
|
||||
F: Fn(&reqwest::Client, &str, &str) -> reqwest::RequestBuilder,
|
||||
{
|
||||
let token = self.fresh_access_token().await?;
|
||||
let response = build(&self.http, url, &token).send().await?;
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let token = self.refresh_after_rejection(&token).await?;
|
||||
return Ok(build(&self.http, url, &token).send().await?);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn fresh_access_token(&self) -> Result<String, ApiError> {
|
||||
let mut session = self.session.lock().await;
|
||||
if session.access_token_expired() {
|
||||
self.refresh_locked(&mut session).await?;
|
||||
}
|
||||
Ok(session.access_token.clone())
|
||||
}
|
||||
|
||||
/// A 401 with a token another task already rotated just retries with the
|
||||
/// current token; otherwise this task performs the refresh itself.
|
||||
async fn refresh_after_rejection(&self, rejected_token: &str) -> Result<String, ApiError> {
|
||||
let mut session = self.session.lock().await;
|
||||
if session.access_token != rejected_token {
|
||||
return Ok(session.access_token.clone());
|
||||
}
|
||||
self.refresh_locked(&mut session).await?;
|
||||
Ok(session.access_token.clone())
|
||||
}
|
||||
|
||||
async fn refresh_locked(&self, session: &mut AuthSession) -> Result<(), ApiError> {
|
||||
let result = refresh_tokens(&self.http, &self.base_url, &session.refresh_token).await;
|
||||
match result {
|
||||
Ok(tokens) => {
|
||||
session.apply_tokens(tokens);
|
||||
if let Err(err) = auth::save_session(session) {
|
||||
tracing::warn!(%err, "failed to persist rotated tokens");
|
||||
}
|
||||
tracing::debug!("access token refreshed");
|
||||
Ok(())
|
||||
}
|
||||
Err(ApiError::SessionExpired) => {
|
||||
auth::delete_session();
|
||||
Err(ApiError::SessionExpired)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
@@ -1,306 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TokensResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub user: User,
|
||||
pub tokens: TokensResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "rendered by the profile view in a later milestone"
|
||||
)]
|
||||
pub struct MeStats {
|
||||
pub liked_tracks: i64,
|
||||
pub playlists: i64,
|
||||
pub plays: i64,
|
||||
pub listened_minutes: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "rendered by the profile view in a later milestone"
|
||||
)]
|
||||
pub struct MeResponse {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub stats: MeStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApiErrorBody {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ArtistCard {
|
||||
#[allow(dead_code, reason = "opens the artist view in the next milestone")]
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// Relative path like `/api/player/cover/{file_id}/medium`.
|
||||
pub image_url: Option<String>,
|
||||
pub release_count: i64,
|
||||
pub track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtistRef {
|
||||
#[allow(dead_code, reason = "navigation to artists from track rows later")]
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Serialize keeps every field the backend sent us, so device-sync payloads
|
||||
/// (play_from_index, queue_add) carry full track objects like the web does.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrackItem {
|
||||
#[allow(dead_code, reason = "playback engine consumes this in milestone 3")]
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
/// Absent in the artist-appearance variant of track payloads.
|
||||
#[serde(default)]
|
||||
pub track_number: Option<i32>,
|
||||
pub duration_seconds: f64,
|
||||
#[serde(default)]
|
||||
pub artists: Vec<ArtistRef>,
|
||||
#[serde(default)]
|
||||
pub featured_artists: Vec<ArtistRef>,
|
||||
#[allow(dead_code, reason = "jump-to-release navigation later")]
|
||||
#[serde(default)]
|
||||
pub release_id: i64,
|
||||
#[serde(default)]
|
||||
pub release_title: String,
|
||||
#[allow(dead_code, reason = "shown in queue/now-playing later")]
|
||||
pub release_year: Option<i32>,
|
||||
/// Server-relative path to `/api/player/stream/{id}`.
|
||||
#[serde(default)]
|
||||
pub stream_url: String,
|
||||
#[allow(dead_code, reason = "now-playing artwork in milestone 3")]
|
||||
pub cover_url: Option<String>,
|
||||
pub audio_format: Option<String>,
|
||||
pub audio_bitrate: Option<i32>,
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
pub file_size_bytes: Option<i64>,
|
||||
#[allow(dead_code, reason = "popularity column later")]
|
||||
pub lastfm_playcount: Option<i64>,
|
||||
}
|
||||
|
||||
impl TrackItem {
|
||||
pub fn artist_line(&self) -> String {
|
||||
let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect();
|
||||
if !self.featured_artists.is_empty() {
|
||||
names.push("feat.");
|
||||
names.extend(self.featured_artists.iter().map(|a| a.name.as_str()));
|
||||
}
|
||||
names.join(", ")
|
||||
}
|
||||
|
||||
pub fn duration_label(&self) -> String {
|
||||
let total = self.duration_seconds.round() as i64;
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
|
||||
/// Full tech line for the status bar, including the sample rate.
|
||||
pub fn tech_label_full(&self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(format) = &self.audio_format {
|
||||
parts.push(format.to_uppercase());
|
||||
}
|
||||
if let Some(bitrate) = self.audio_bitrate {
|
||||
parts.push(format!("{bitrate}kbps"));
|
||||
}
|
||||
if let Some(rate) = self.audio_sample_rate {
|
||||
parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0));
|
||||
}
|
||||
if let Some(bytes) = self.file_size_bytes {
|
||||
parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReleaseCard {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub cover_url: Option<String>,
|
||||
pub track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistDetail {
|
||||
#[allow(dead_code, reason = "cache key is held by the caller")]
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub image_url: Option<String>,
|
||||
pub total_track_count: i64,
|
||||
pub total_play_count: i64,
|
||||
pub top_tracks: Vec<TrackItem>,
|
||||
pub releases: Vec<ReleaseCard>,
|
||||
/// Tracks where this artist is featured (the only content for artists
|
||||
/// without own releases).
|
||||
#[serde(default)]
|
||||
pub featured_tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UploaderSummary {
|
||||
pub name: String,
|
||||
#[allow(dead_code, reason = "per-uploader stats for a later detail popup")]
|
||||
pub track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseDetail {
|
||||
#[allow(dead_code, reason = "cache key is held by the caller")]
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub cover_url: Option<String>,
|
||||
pub artists: Vec<ArtistRef>,
|
||||
pub tracks: Vec<TrackItem>,
|
||||
pub uploaders: Vec<UploaderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PlaylistCard {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub track_count: i64,
|
||||
pub is_own: bool,
|
||||
pub owner_name: Option<String>,
|
||||
pub is_public: bool,
|
||||
#[allow(dead_code, reason = "save/unsave playlists later")]
|
||||
pub is_saved: bool,
|
||||
#[allow(dead_code, reason = "playlist kinds get distinct icons later")]
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PlaylistDetail {
|
||||
#[allow(dead_code, reason = "cache key is held by the caller")]
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
#[allow(dead_code, reason = "shown in a detail header later")]
|
||||
pub description: Option<String>,
|
||||
pub tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LikesResponse {
|
||||
pub track_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DeviceDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
#[allow(dead_code, reason = "server-side flag; we compare ids directly")]
|
||||
pub is_current: bool,
|
||||
pub is_active: bool,
|
||||
#[allow(dead_code, reason = "freshness display later")]
|
||||
pub last_seen_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceCommandDto {
|
||||
#[allow(dead_code, reason = "commands are applied in poll order")]
|
||||
pub id: Option<String>,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Mirrors the backend's PlayerDevicePlaybackStateDto; tracks stay raw JSON
|
||||
/// so unknown fields survive the round trip between clients.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DevicePlaybackState {
|
||||
#[serde(default)]
|
||||
pub track: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub tracks: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub index: i32,
|
||||
#[serde(default)]
|
||||
pub position_seconds: f64,
|
||||
#[serde(default)]
|
||||
pub duration_seconds: f64,
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
#[serde(default)]
|
||||
pub shuffle: bool,
|
||||
#[serde(default = "default_repeat_mode")]
|
||||
pub repeat_mode: String,
|
||||
#[serde(default = "default_volume")]
|
||||
pub volume: f64,
|
||||
#[serde(default)]
|
||||
pub updated_at_ms: i64,
|
||||
}
|
||||
|
||||
fn default_repeat_mode() -> String {
|
||||
"off".to_string()
|
||||
}
|
||||
|
||||
fn default_volume() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DevicePollResponse {
|
||||
#[allow(dead_code, reason = "echo of our own id")]
|
||||
pub device_id: String,
|
||||
pub active_device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub devices: Vec<DeviceDto>,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<DeviceCommandDto>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code, reason = "Jam control is out of scope for the TUI v1")]
|
||||
pub current_jam_id: Option<String>,
|
||||
pub playback_state: Option<DevicePlaybackState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct SearchResults {
|
||||
pub artists: Vec<ArtistCard>,
|
||||
pub releases: Vec<ReleaseCard>,
|
||||
pub tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
impl SearchResults {
|
||||
pub fn len(&self) -> usize {
|
||||
self.artists.len() + self.releases.len() + self.tracks.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistsPage {
|
||||
pub items: Vec<ArtistCard>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
#[allow(dead_code, reason = "part of the server pagination envelope")]
|
||||
pub per_page: i64,
|
||||
pub has_more: bool,
|
||||
}
|
||||
+49
-13
@@ -21,25 +21,39 @@ pub enum Action {
|
||||
PlayPause,
|
||||
NextTrack,
|
||||
PrevTrack,
|
||||
SeekForward { seconds: u32 },
|
||||
SeekBackward { seconds: u32 },
|
||||
SeekForward {
|
||||
seconds: u32,
|
||||
},
|
||||
SeekBackward {
|
||||
seconds: u32,
|
||||
},
|
||||
VolumeUp,
|
||||
VolumeDown,
|
||||
ToggleShuffle,
|
||||
CycleRepeat,
|
||||
ToggleVisualizer,
|
||||
ToggleLike,
|
||||
ToggleTrackSelection,
|
||||
OpenTrackInfo,
|
||||
OpenCurrentTrackInfo,
|
||||
QueueAddNext,
|
||||
QueueAddLast,
|
||||
/// Download the selected federated track(s) into the local library.
|
||||
DownloadSelected,
|
||||
RemoveFromQueue,
|
||||
ClearQueue,
|
||||
OpenConnectedDevices,
|
||||
GoToRelease,
|
||||
AddToPlaylist,
|
||||
NewPlaylist,
|
||||
ToggleHelp,
|
||||
ToggleViewMode,
|
||||
OpenDevices,
|
||||
CycleSourceMode,
|
||||
OpenLibraryFilters,
|
||||
OpenCommandLine,
|
||||
OpenSearch,
|
||||
Logout,
|
||||
EditSelected,
|
||||
DeleteSelected,
|
||||
}
|
||||
|
||||
/// Help-window sections, in display order.
|
||||
@@ -47,15 +61,17 @@ pub enum Action {
|
||||
pub enum Category {
|
||||
Playback,
|
||||
Queue,
|
||||
Library,
|
||||
Navigation,
|
||||
Search,
|
||||
System,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
pub const ALL: [Category; 5] = [
|
||||
pub const ALL: [Category; 6] = [
|
||||
Category::Playback,
|
||||
Category::Queue,
|
||||
Category::Library,
|
||||
Category::Navigation,
|
||||
Category::Search,
|
||||
Category::System,
|
||||
@@ -65,6 +81,7 @@ impl Category {
|
||||
match self {
|
||||
Category::Playback => "Playback",
|
||||
Category::Queue => "Queue & playlists",
|
||||
Category::Library => "Library",
|
||||
Category::Navigation => "Navigation",
|
||||
Category::Search => "Search & commands",
|
||||
Category::System => "System",
|
||||
@@ -83,13 +100,20 @@ impl Action {
|
||||
| Action::VolumeUp
|
||||
| Action::VolumeDown
|
||||
| Action::ToggleShuffle
|
||||
| Action::CycleRepeat => Category::Playback,
|
||||
| Action::CycleRepeat
|
||||
| Action::ToggleVisualizer
|
||||
| Action::OpenConnectedDevices => Category::Playback,
|
||||
Action::QueueAddNext
|
||||
| Action::QueueAddLast
|
||||
| Action::DownloadSelected
|
||||
| Action::RemoveFromQueue
|
||||
| Action::ClearQueue
|
||||
| Action::AddToPlaylist
|
||||
| Action::NewPlaylist
|
||||
| Action::ToggleLike => Category::Queue,
|
||||
| Action::ToggleLike
|
||||
| Action::ToggleTrackSelection
|
||||
| Action::OpenTrackInfo
|
||||
| Action::OpenCurrentTrackInfo => Category::Queue,
|
||||
Action::MoveUp
|
||||
| Action::MoveDown
|
||||
| Action::MoveLeft
|
||||
@@ -105,9 +129,12 @@ impl Action {
|
||||
| Action::GoToTab(_)
|
||||
| Action::GoToRelease
|
||||
| Action::ToggleViewMode => Category::Navigation,
|
||||
Action::EditSelected
|
||||
| Action::DeleteSelected
|
||||
| Action::CycleSourceMode
|
||||
| Action::OpenLibraryFilters => Category::Library,
|
||||
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
|
||||
Action::OpenDevices => Category::System,
|
||||
Action::ToggleHelp | Action::Logout | Action::Quit => Category::System,
|
||||
Action::ToggleHelp | Action::Quit => Category::System,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +142,7 @@ impl Action {
|
||||
pub fn command_hint(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Action::Quit => Some(":q"),
|
||||
Action::Logout => Some(":logout"),
|
||||
Action::EditSelected => Some(":import <path> adds files"),
|
||||
Action::PlayPause => Some(":play"),
|
||||
Action::NextTrack => Some(":next"),
|
||||
Action::PrevTrack => Some(":prev"),
|
||||
@@ -124,7 +151,7 @@ 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::ToggleHelp => Some(":help"),
|
||||
Action::OpenSearch => Some("/text"),
|
||||
_ => None,
|
||||
@@ -156,19 +183,28 @@ impl Action {
|
||||
Action::VolumeDown => "Volume down".into(),
|
||||
Action::ToggleShuffle => "Toggle shuffle".into(),
|
||||
Action::CycleRepeat => "Cycle repeat mode".into(),
|
||||
Action::ToggleVisualizer => "Toggle fullscreen visualizer".into(),
|
||||
Action::ToggleLike => "Like / unlike".into(),
|
||||
Action::ToggleTrackSelection => "Track line selection".into(),
|
||||
Action::OpenTrackInfo => "Track info".into(),
|
||||
Action::OpenCurrentTrackInfo => "Current track info".into(),
|
||||
Action::QueueAddNext => "Queue: add next".into(),
|
||||
Action::QueueAddLast => "Queue: add to end".into(),
|
||||
Action::DownloadSelected => "Federation: download to library".into(),
|
||||
Action::RemoveFromQueue => "Queue: remove selected".into(),
|
||||
Action::ClearQueue => "Queue: clear".into(),
|
||||
Action::OpenConnectedDevices => "Connected devices…".into(),
|
||||
Action::GoToRelease => "Open the track's release".into(),
|
||||
Action::AddToPlaylist => "Add track to a playlist…".into(),
|
||||
Action::NewPlaylist => "Create a playlist".into(),
|
||||
Action::ToggleHelp => "Show / hide keybindings".into(),
|
||||
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
||||
Action::OpenDevices => "Connected devices".into(),
|
||||
Action::CycleSourceMode => "Cycle source mode: Local / My / Global".into(),
|
||||
Action::OpenLibraryFilters => "Library filters…".into(),
|
||||
Action::OpenCommandLine => "Command line (:help for commands)".into(),
|
||||
Action::OpenSearch => "Search artists, releases, tracks".into(),
|
||||
Action::Logout => "Sign out".into(),
|
||||
Action::EditSelected => "Edit the selected item".into(),
|
||||
Action::DeleteSelected => "Delete the selected item".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+94
-38
@@ -2,9 +2,8 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
use crate::api::client::ApiError;
|
||||
use crate::app::Runtime;
|
||||
use crate::app::command::{self, Command, Parsed};
|
||||
use crate::app::event::AppEvent;
|
||||
@@ -18,32 +17,22 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => cancel(state),
|
||||
KeyCode::Enter => commit(state, runtime),
|
||||
KeyCode::Backspace => {
|
||||
if state.cmdline.input.pop().is_none() {
|
||||
// Backspace on an empty line closes it, like vim.
|
||||
cancel(state);
|
||||
return;
|
||||
// Backspace on an empty line closes it, like vim.
|
||||
KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state),
|
||||
_ => {
|
||||
if state.cmdline.input.handle_key(key) {
|
||||
after_change(state, runtime);
|
||||
}
|
||||
after_change(state, runtime);
|
||||
}
|
||||
KeyCode::Char(c) if is_typing(key) => {
|
||||
state.cmdline.input.push(c);
|
||||
after_change(state, runtime);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_paste(state: &mut AppState, runtime: &Runtime, pasted: &str) {
|
||||
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
|
||||
state.cmdline.input.push_str(&cleaned);
|
||||
state.cmdline.input.insert_str(&cleaned);
|
||||
after_change(state, runtime);
|
||||
}
|
||||
|
||||
fn is_typing(key: KeyEvent) -> bool {
|
||||
key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
|
||||
}
|
||||
|
||||
/// Re-evaluate the input after every edit; live commands (search) take
|
||||
/// effect immediately, while typing.
|
||||
fn after_change(state: &mut AppState, runtime: &Runtime) {
|
||||
@@ -59,7 +48,9 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
||||
match command {
|
||||
// One-shot commands have no live effect.
|
||||
Command::Quit
|
||||
| Command::Logout
|
||||
| Command::Import(_)
|
||||
| Command::Open(_)
|
||||
| Command::ConnectInvite(_)
|
||||
| Command::Volume(_)
|
||||
| Command::Seek(_)
|
||||
| Command::SeekTo(_)
|
||||
@@ -70,7 +61,6 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
||||
| Command::Prev
|
||||
| Command::PlayPause
|
||||
| Command::Help
|
||||
| Command::Devices
|
||||
| Command::Logs(_) => {}
|
||||
Command::Search(query) => {
|
||||
state.active_tab = Tab::Global;
|
||||
@@ -96,17 +86,18 @@ fn set_view_cursor_zero(state: &mut AppState) {
|
||||
/// Debounced, race-free search: every edit bumps the global sequence; the
|
||||
/// spawned task only queries if it is still the latest after the debounce,
|
||||
/// and the receiver drops responses that arrive out of date.
|
||||
fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let query = state.search.query.clone();
|
||||
if query.is_empty() {
|
||||
state.search.loading = false;
|
||||
state.search.results = None;
|
||||
state.search.fed_tracks.clear();
|
||||
state.search.fed_artists.clear();
|
||||
state.search.fed_loading = false;
|
||||
return;
|
||||
}
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
let library = Arc::clone(&runtime.library);
|
||||
state.search.loading = true;
|
||||
let tx = runtime.event_tx.clone();
|
||||
let latest = Arc::clone(&runtime.search_seq);
|
||||
@@ -115,18 +106,56 @@ fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
if latest.load(Ordering::SeqCst) != seq {
|
||||
return;
|
||||
}
|
||||
let event = match api.search(&query, SEARCH_LIMIT).await {
|
||||
Ok(results) => AppEvent::SearchLoaded {
|
||||
seq,
|
||||
result: Ok(results),
|
||||
},
|
||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||
Err(err) => AppEvent::SearchLoaded {
|
||||
seq,
|
||||
result: Err(err.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
let result = tokio::task::spawn_blocking(move || library.search(&query, SEARCH_LIMIT))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|result| result.map_err(|err| format!("{err:#}")));
|
||||
let _ = tx.send(AppEvent::SearchLoaded { seq, result });
|
||||
});
|
||||
|
||||
// The same query also runs against the federated network (when the
|
||||
// node is up); its results render as a separate, marked section.
|
||||
state.search.fed_tracks.clear();
|
||||
state.search.fed_artists.clear();
|
||||
state.search.fed_loading = false;
|
||||
if runtime.federation.settings().enabled {
|
||||
state.search.fed_loading = true;
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let query = state.search.query.clone();
|
||||
let tx = runtime.event_tx.clone();
|
||||
let latest = Arc::clone(&runtime.search_seq);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(SEARCH_DEBOUNCE).await;
|
||||
if latest.load(Ordering::SeqCst) != seq {
|
||||
return;
|
||||
}
|
||||
let result = fed.search(&query).await.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::FedSearchLoaded { seq, result });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh only the local-library half of an already open search.
|
||||
///
|
||||
/// Library/device sync notifications can arrive while federated search
|
||||
/// results are visible. Reusing `schedule_search` here would clear the
|
||||
/// federation rows and bump the shared sequence, causing valid network
|
||||
/// responses to be dropped or flicker away.
|
||||
pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
|
||||
let query = state.search.query.clone();
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
let seq = runtime.search_seq.load(Ordering::SeqCst);
|
||||
let library = Arc::clone(&runtime.library);
|
||||
state.search.loading = true;
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(move || library.search(&query, SEARCH_LIMIT))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|result| result.map_err(|err| format!("{err:#}")));
|
||||
let _ = tx.send(AppEvent::SearchLoaded { seq, result });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +190,9 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
||||
match command {
|
||||
Command::Search(_) => {}
|
||||
Command::Quit => state.should_quit = true,
|
||||
Command::Logout => super::perform_logout(state, runtime),
|
||||
Command::Import(path) => super::spawn_import(state, runtime, &path),
|
||||
Command::Open(link) => open_frid_link(state, runtime, link),
|
||||
Command::ConnectInvite(invite) => super::device_connect(runtime, invite),
|
||||
Command::Volume(value) => {
|
||||
state.player.volume = value;
|
||||
super::perform_effect(state, runtime, Effect::SetVolume(value));
|
||||
@@ -194,7 +225,6 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
||||
Command::Prev => run_action(state, runtime, Action::PrevTrack),
|
||||
Command::PlayPause => run_action(state, runtime, Action::PlayPause),
|
||||
Command::Help => state.help_visible = true,
|
||||
Command::Devices => run_action(state, runtime, Action::OpenDevices),
|
||||
Command::Logs(level) => {
|
||||
if let Some(index) = level {
|
||||
state.logs.level_index = index.min(LOG_LEVELS.len() - 1);
|
||||
@@ -206,6 +236,32 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_frid_link(state: &mut AppState, runtime: &Runtime, link: String) {
|
||||
let Some(link) = crate::share::parse_frid_link(&link) else {
|
||||
state.status_message = Some("usage: :open frid://<content_id>".into());
|
||||
return;
|
||||
};
|
||||
state.status_message = Some(match link.label.as_deref() {
|
||||
Some(label) => format!("federation: opening \"{label}\"…"),
|
||||
None => "federation: opening shared track…".into(),
|
||||
});
|
||||
let federation = Arc::clone(&runtime.federation);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let event = match federation
|
||||
.track_by_content_id(&link.content_id, link.label.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(track) => AppEvent::EnqueueTracks {
|
||||
tracks: vec![crate::federation::pending_track(&track)],
|
||||
next: false,
|
||||
},
|
||||
Err(err) => AppEvent::StatusMessage(format!("open failed: {err:#}")),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
}
|
||||
|
||||
/// Esc: close the line and undo any live effect it had.
|
||||
fn cancel(state: &mut AppState) {
|
||||
retract_live(state);
|
||||
|
||||
+51
-8
@@ -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))));
|
||||
}
|
||||
|
||||
|
||||
+114
-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,7 @@ pub enum AppEvent {
|
||||
seq: u64,
|
||||
result: Result<SearchResults, String>,
|
||||
},
|
||||
/// Artwork fetched and decoded for the shared art cache.
|
||||
/// Artwork loaded and decoded for the shared art cache.
|
||||
ArtLoaded {
|
||||
key: String,
|
||||
art: Option<Arc<ArtImage>>,
|
||||
@@ -41,7 +40,7 @@ pub enum AppEvent {
|
||||
Player(crate::player::PlayerEvent),
|
||||
/// A command from the OS media keys.
|
||||
Media(crate::media::MediaCommand),
|
||||
/// Gapless prefetch could not open the stream; the normal track-switch
|
||||
/// Gapless prefetch could not open the file; the normal track-switch
|
||||
/// path takes over when the current track ends.
|
||||
PrefetchFailed {
|
||||
pos: usize,
|
||||
@@ -51,17 +50,27 @@ 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>),
|
||||
/// 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 +78,91 @@ pub enum AppEvent {
|
||||
},
|
||||
PlaylistCreated {
|
||||
result: Result<PlaylistCard, String>,
|
||||
/// Add this track to the new playlist right away (Shift-P flow).
|
||||
add_track: Option<TrackItem>,
|
||||
/// Add this target to the new playlist right away (Shift-P flow).
|
||||
add_target: Option<crate::app::state::PlaylistAddTarget>,
|
||||
},
|
||||
PlaylistTracksAdded {
|
||||
playlist_id: i64,
|
||||
playlist_title: String,
|
||||
result: Result<(), String>,
|
||||
},
|
||||
/// The library was mutated (import, edit, delete): cached views must be
|
||||
/// dropped and reloaded lazily.
|
||||
LibraryChanged {
|
||||
message: Option<String>,
|
||||
},
|
||||
/// Progress of a running import, shown in the status bar.
|
||||
ImportProgress {
|
||||
done: usize,
|
||||
total: usize,
|
||||
current: String,
|
||||
},
|
||||
/// Fresh copies of the queued tracks after a library change. Tracks
|
||||
/// missing from the result were deleted and leave the queue.
|
||||
QueueTracksRefreshed {
|
||||
tracks: Vec<TrackItem>,
|
||||
},
|
||||
/// A status snapshot for the Federation tab.
|
||||
FederationStatus(crate::federation::FedStatus),
|
||||
/// Federated live-search results (artists a card can be opened for,
|
||||
/// plus matching tracks).
|
||||
FedSearchLoaded {
|
||||
seq: u64,
|
||||
result: Result<crate::federation::FedSearchResults, String>,
|
||||
},
|
||||
/// A federated artist card finished assembling.
|
||||
FedArtistLoaded {
|
||||
name: String,
|
||||
result: Result<crate::federation::FedArtistCard, String>,
|
||||
},
|
||||
/// Federated enrichment for an already-open local artist view.
|
||||
ArtistFederationLoaded {
|
||||
id: i64,
|
||||
name: String,
|
||||
result: Result<crate::federation::FedArtistCard, String>,
|
||||
},
|
||||
/// A network-library source refreshed its cached top-artist slice.
|
||||
NetworkArtistCacheUpdated {
|
||||
source_id: String,
|
||||
count: usize,
|
||||
},
|
||||
/// A streamed image for the open card arrived (artist image when
|
||||
/// `release` is None, a release cover otherwise).
|
||||
FedCardArt {
|
||||
name: String,
|
||||
release: Option<String>,
|
||||
path: String,
|
||||
},
|
||||
/// A pending federated track finished downloading (or failed); the
|
||||
/// queue swaps the placeholder for the resolved track.
|
||||
FedTrackResolved {
|
||||
placeholder_id: i64,
|
||||
resolve_key: String,
|
||||
result: Result<Box<crate::federation::FedPlayable>, String>,
|
||||
},
|
||||
/// Rich metadata for a federated track-info preview arrived without
|
||||
/// downloading the audio file.
|
||||
FedTrackInfoLoaded {
|
||||
placeholder_id: i64,
|
||||
item_id: String,
|
||||
result: Result<TrackItem, String>,
|
||||
},
|
||||
/// This peer's connection ticket, requested from the Federation tab.
|
||||
FedTicket(Result<String, String>),
|
||||
/// Immediate library publish finished.
|
||||
FedSyncFinished(String),
|
||||
/// Fresh personal-device sync status snapshot for Settings.
|
||||
DeviceSyncStatus(crate::devices::DeviceSyncStatus),
|
||||
/// Invite link for pairing another device.
|
||||
DeviceInvite(Result<String, String>),
|
||||
/// Result of `:connect frid://i/...`.
|
||||
DeviceConnectResult(Result<String, String>),
|
||||
/// Manual trusted-device sync finished.
|
||||
DeviceSyncFinished(String),
|
||||
/// Incoming pairing request that passed the invite-secret check.
|
||||
DevicePairingRequest(crate::devices::PendingPairing),
|
||||
/// Trusted device playback state, delivered by personal-device sync.
|
||||
DevicePlayback(crate::devices::PlaybackSnapshot),
|
||||
/// Playback command addressed to this device.
|
||||
PlaybackCommand(crate::devices::PlaybackCommand),
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+2913
-892
File diff suppressed because it is too large
Load Diff
+1069
-112
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);
|
||||
}
|
||||
}
|
||||
+1309
-156
File diff suppressed because it is too large
Load Diff
+1899
-546
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,909 @@
|
||||
use super::*;
|
||||
use crate::library::models::{ArtistCard, ArtistDetail, TrackItem};
|
||||
|
||||
fn with_artists(n: usize) -> AppState {
|
||||
let mut state = AppState::default();
|
||||
state.global.artists = (0..n)
|
||||
.map(|i| ArtistCard {
|
||||
id: i as i64,
|
||||
name: format!("artist {i}"),
|
||||
image_path: None,
|
||||
release_count: 1,
|
||||
track_count: 2,
|
||||
availability: crate::library::models::Availability::Local,
|
||||
})
|
||||
.collect();
|
||||
state
|
||||
}
|
||||
|
||||
fn test_track(id: i64) -> TrackItem {
|
||||
TrackItem {
|
||||
id,
|
||||
title: format!("t{id}"),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 1.0,
|
||||
artists: vec![],
|
||||
featured_artists: vec![],
|
||||
release_id: 1,
|
||||
release_title: "r".into(),
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: Some(format!("b3:{id:064x}")),
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_fed_track(id: i64) -> TrackItem {
|
||||
crate::federation::pending_track(&crate::federation::FedTrack {
|
||||
item_id: format!("fed-{id}"),
|
||||
owner: "peer".into(),
|
||||
own: false,
|
||||
title: format!("remote-{id}"),
|
||||
artist_names: vec!["remote artist".into()],
|
||||
featured_artist_names: vec![],
|
||||
year: None,
|
||||
duration_seconds: Some(1),
|
||||
content_id: Some(format!("b3:{id:064x}")),
|
||||
release_title: Some("remote release".into()),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_needs_double_press() {
|
||||
let mut state = AppState::default();
|
||||
update(&mut state, Action::Quit);
|
||||
assert!(!state.should_quit);
|
||||
assert_eq!(state.status_message.as_deref(), Some(QUIT_CONFIRM_HINT));
|
||||
update(&mut state, Action::Quit);
|
||||
assert!(state.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_action_disarms_quit() {
|
||||
let mut state = AppState::default();
|
||||
update(&mut state, Action::Quit);
|
||||
update(&mut state, Action::NextTab);
|
||||
update(&mut state, Action::Quit);
|
||||
assert!(!state.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_quit_confirmation_rearms() {
|
||||
let mut state = AppState::default();
|
||||
update(&mut state, Action::Quit);
|
||||
state.quit_armed_until = Some(Instant::now() - Duration::from_secs(1));
|
||||
update(&mut state, Action::Quit);
|
||||
assert!(!state.should_quit);
|
||||
assert_eq!(state.status_message.as_deref(), Some(QUIT_CONFIRM_HINT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_cycling_wraps() {
|
||||
let mut state = AppState::default();
|
||||
update(&mut state, Action::PrevTab);
|
||||
assert_eq!(state.active_tab, Tab::Logs);
|
||||
update(&mut state, Action::NextTab);
|
||||
assert_eq!(state.active_tab, Tab::Global);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_clamps() {
|
||||
let mut state = AppState::default();
|
||||
for _ in 0..30 {
|
||||
update(&mut state, Action::VolumeUp);
|
||||
}
|
||||
assert_eq!(state.player.volume, 100);
|
||||
for _ in 0..30 {
|
||||
update(&mut state, Action::VolumeDown);
|
||||
}
|
||||
assert_eq!(state.player.volume, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_filters_popup_opens_on_library_screens() {
|
||||
let mut state = AppState::default();
|
||||
update(&mut state, Action::OpenLibraryFilters);
|
||||
assert!(matches!(
|
||||
state.popup,
|
||||
Some(crate::app::state::Popup::LibraryFilters { .. })
|
||||
));
|
||||
|
||||
state.popup = None;
|
||||
state.global.stack.push(GlobalView::Search { cursor: 0 });
|
||||
update(&mut state, Action::OpenLibraryFilters);
|
||||
assert!(matches!(
|
||||
state.popup,
|
||||
Some(crate::app::state::Popup::LibraryFilters { .. })
|
||||
));
|
||||
|
||||
state.popup = None;
|
||||
state.active_tab = Tab::Queue;
|
||||
update(&mut state, Action::OpenLibraryFilters);
|
||||
assert!(state.popup.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_mode_cycles_on_library_playlists_and_queue_tabs() {
|
||||
use crate::config::settings::LibrarySourceMode;
|
||||
|
||||
let mut state = AppState::default();
|
||||
assert_eq!(
|
||||
update(&mut state, Action::CycleSourceMode),
|
||||
Some(Effect::SourceModeChanged)
|
||||
);
|
||||
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::My);
|
||||
|
||||
state.active_tab = Tab::Playlists;
|
||||
assert_eq!(
|
||||
update(&mut state, Action::CycleSourceMode),
|
||||
Some(Effect::SourceModeChanged)
|
||||
);
|
||||
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global);
|
||||
|
||||
state.active_tab = Tab::Queue;
|
||||
assert_eq!(
|
||||
update(&mut state, Action::CycleSourceMode),
|
||||
Some(Effect::SourceModeChanged)
|
||||
);
|
||||
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
|
||||
|
||||
state.active_tab = Tab::Federation;
|
||||
assert_eq!(update(&mut state, Action::CycleSourceMode), None);
|
||||
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_closes_help_first() {
|
||||
let mut state = AppState::default();
|
||||
update(&mut state, Action::ToggleHelp);
|
||||
assert!(state.help_visible);
|
||||
update(&mut state, Action::Back);
|
||||
assert!(!state.help_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_movement_clamps_and_wraps_rows() {
|
||||
let mut state = with_artists(10);
|
||||
let cols = grid_columns();
|
||||
update(&mut state, Action::MoveDown);
|
||||
assert_eq!(state.global.selected, cols.min(9));
|
||||
update(&mut state, Action::MoveUp);
|
||||
assert_eq!(state.global.selected, 0);
|
||||
update(&mut state, Action::MoveLeft);
|
||||
assert_eq!(state.global.selected, 0);
|
||||
update(&mut state, Action::MoveRight);
|
||||
assert_eq!(state.global.selected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_mode_moves_one_row() {
|
||||
let mut state = with_artists(10);
|
||||
state.global.view = ViewMode::Table;
|
||||
update(&mut state, Action::MoveDown);
|
||||
assert_eq!(state.global.selected, 1);
|
||||
// Left/right are meaningless in the table.
|
||||
update(&mut state, Action::MoveRight);
|
||||
assert_eq!(state.global.selected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_down_moves_a_page_and_clamps() {
|
||||
let mut state = with_artists(200);
|
||||
let cols = grid_columns() as isize;
|
||||
let expected = (page_step(&state) * cols).min(199) as usize;
|
||||
update(&mut state, Action::PageDown);
|
||||
assert_eq!(state.global.selected, expected);
|
||||
update(&mut state, Action::PageUp);
|
||||
assert_eq!(state.global.selected, 0);
|
||||
|
||||
state.global.view = ViewMode::Table;
|
||||
update(&mut state, Action::PageDown);
|
||||
assert_eq!(state.global.selected, page_step(&state).min(199) as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_first_last() {
|
||||
let mut state = with_artists(10);
|
||||
update(&mut state, Action::SelectLast);
|
||||
assert_eq!(state.global.selected, 9);
|
||||
update(&mut state, Action::SelectFirst);
|
||||
assert_eq!(state.global.selected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_tiles_move_by_visual_rows_across_groups() {
|
||||
use crate::library::models::{ArtistDetail, ReleaseCard};
|
||||
|
||||
let release = |id: i64, kind: &str| ReleaseCard {
|
||||
id,
|
||||
title: format!("r{id}"),
|
||||
release_type: kind.to_string(),
|
||||
year: None,
|
||||
cover_path: None,
|
||||
track_count: 1,
|
||||
availability: crate::library::models::Availability::Local,
|
||||
};
|
||||
let columns = grid_columns();
|
||||
// The terminal size can be visible to tests. Build enough albums to
|
||||
// force a short second album row for whichever width this run has.
|
||||
let detail = ArtistDetail {
|
||||
id: 1,
|
||||
name: "a".into(),
|
||||
image_path: None,
|
||||
total_track_count: 0,
|
||||
total_play_count: 0,
|
||||
top_tracks: vec![],
|
||||
featured_tracks: vec![],
|
||||
releases: (0..=columns)
|
||||
.map(|index| release(10 + index as i64, "album"))
|
||||
.chain((0..2).map(|index| release(100 + index, "compilation")))
|
||||
.collect(),
|
||||
};
|
||||
let mut state = AppState::default();
|
||||
state.artist_views.insert(1, Loadable::Ready(detail));
|
||||
state.global.stack.push(GlobalView::Artist {
|
||||
id: 1,
|
||||
cursor: columns + 1,
|
||||
});
|
||||
|
||||
// Up from the first compilation lands on the album row directly
|
||||
// above, not one flat grid-width jump back.
|
||||
update(&mut state, Action::MoveUp);
|
||||
assert_eq!(
|
||||
state.global.stack.last(),
|
||||
Some(&GlobalView::Artist {
|
||||
id: 1,
|
||||
cursor: columns
|
||||
})
|
||||
);
|
||||
// And back down returns to the compilation row, same column.
|
||||
update(&mut state, Action::MoveDown);
|
||||
assert_eq!(
|
||||
state.global.stack.last(),
|
||||
Some(&GlobalView::Artist {
|
||||
id: 1,
|
||||
cursor: columns + 1
|
||||
})
|
||||
);
|
||||
// Up from the second compilation clamps to the single tile above.
|
||||
state.global.stack.pop();
|
||||
state.global.stack.push(GlobalView::Artist {
|
||||
id: 1,
|
||||
cursor: columns + 2,
|
||||
});
|
||||
update(&mut state, Action::MoveUp);
|
||||
assert_eq!(
|
||||
state.global.stack.last(),
|
||||
Some(&GlobalView::Artist {
|
||||
id: 1,
|
||||
cursor: columns
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_opens_artist_and_back_returns() {
|
||||
let mut state = with_artists(3);
|
||||
state.global.selected = 2;
|
||||
update(&mut state, Action::Select);
|
||||
assert_eq!(
|
||||
state.global.stack.last(),
|
||||
Some(&GlobalView::Artist { id: 2, cursor: 0 })
|
||||
);
|
||||
update(&mut state, Action::Back);
|
||||
assert!(state.global.stack.is_empty());
|
||||
assert_eq!(state.global.selected, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_from_search_resets_search_state() {
|
||||
let mut state = AppState::default();
|
||||
state.global.stack.push(GlobalView::Search { cursor: 0 });
|
||||
state.search.query = "abc".to_string();
|
||||
update(&mut state, Action::Back);
|
||||
assert!(state.global.stack.is_empty());
|
||||
assert!(state.search.query.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_advances_and_respects_repeat() {
|
||||
use crate::app::state::RepeatMode;
|
||||
use crate::library::models::TrackItem;
|
||||
|
||||
let track = |id: i64| TrackItem {
|
||||
id,
|
||||
title: format!("t{id}"),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 1.0,
|
||||
artists: vec![],
|
||||
featured_artists: vec![],
|
||||
release_id: 1,
|
||||
release_title: "r".into(),
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/api/player/stream/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
};
|
||||
let mut state = AppState::default();
|
||||
state.player.queue = vec![track(1), track(2)];
|
||||
state.player.playing = true;
|
||||
|
||||
// Track 1 finishes → play track 2.
|
||||
assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent));
|
||||
assert_eq!(state.player.queue_pos, 1);
|
||||
// Last track, repeat off → stop.
|
||||
assert_eq!(advance_after_finish(&mut state), Some(Effect::StopPlayback));
|
||||
assert!(!state.player.playing);
|
||||
// Repeat all wraps to the start.
|
||||
state.player.playing = true;
|
||||
state.player.repeat = RepeatMode::All;
|
||||
assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent));
|
||||
assert_eq!(state.player.queue_pos, 0);
|
||||
// Repeat one replays the same position.
|
||||
state.player.repeat = RepeatMode::One;
|
||||
assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent));
|
||||
assert_eq!(state.player.queue_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_tab_number_resets_to_root() {
|
||||
let mut state = with_artists(3);
|
||||
update(&mut state, Action::Select);
|
||||
assert!(!state.global.stack.is_empty());
|
||||
update(&mut state, Action::GoToTab(0));
|
||||
assert!(state.global.stack.is_empty());
|
||||
|
||||
state.playlists.opened = Some(OpenedPlaylist {
|
||||
id: crate::app::state::LIKES_PLAYLIST_ID,
|
||||
cursor: 0,
|
||||
});
|
||||
update(&mut state, Action::GoToTab(1));
|
||||
assert_eq!(state.active_tab, Tab::Playlists);
|
||||
assert!(state.playlists.opened.is_some());
|
||||
update(&mut state, Action::GoToTab(1));
|
||||
assert!(state.playlists.opened.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_tab_select_and_clear() {
|
||||
use crate::library::models::TrackItem;
|
||||
let track = |id: i64| TrackItem {
|
||||
id,
|
||||
title: format!("t{id}"),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 1.0,
|
||||
artists: vec![],
|
||||
featured_artists: vec![],
|
||||
release_id: 1,
|
||||
release_title: "r".into(),
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
};
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
..AppState::default()
|
||||
};
|
||||
state.player.queue = vec![track(1), track(2), track(3)];
|
||||
state.player.queue_pos = 2;
|
||||
|
||||
// Cursor moves independently; enter rewinds playback to that track
|
||||
// without dropping anything from the queue.
|
||||
update(&mut state, Action::MoveUp);
|
||||
update(&mut state, Action::MoveUp);
|
||||
assert_eq!(state.queue_tab.cursor, 0);
|
||||
assert_eq!(
|
||||
update(&mut state, Action::Select),
|
||||
Some(Effect::PlayCurrent)
|
||||
);
|
||||
assert_eq!(state.player.queue_pos, 0);
|
||||
assert_eq!(state.player.queue.len(), 3);
|
||||
|
||||
assert_eq!(
|
||||
update(&mut state, Action::ClearQueue),
|
||||
Some(Effect::StopPlayback)
|
||||
);
|
||||
assert!(state.player.queue.is_empty());
|
||||
assert!(!state.player.playing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_mode_hides_pending_federation_tracks_from_playlists_and_playback() {
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Playlists,
|
||||
..AppState::default()
|
||||
};
|
||||
state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 });
|
||||
state.playlist_views.insert(
|
||||
7,
|
||||
Loadable::Ready(crate::library::models::PlaylistDetail {
|
||||
id: 7,
|
||||
title: "mixed".into(),
|
||||
description: None,
|
||||
tracks: vec![test_track(1), pending_fed_track(2), test_track(3)],
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
playlist_tracks(&state, 7)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
);
|
||||
assert_eq!(
|
||||
update(&mut state, Action::Select),
|
||||
Some(Effect::PlayCurrent)
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.player
|
||||
.queue
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
);
|
||||
assert_eq!(state.player.queue_pos, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_modes_show_pending_federation_playlist_tracks() {
|
||||
let mut state = AppState::default();
|
||||
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My;
|
||||
state.playlist_views.insert(
|
||||
7,
|
||||
Loadable::Ready(crate::library::models::PlaylistDetail {
|
||||
id: 7,
|
||||
title: "mixed".into(),
|
||||
description: None,
|
||||
tracks: vec![test_track(1), pending_fed_track(2)],
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(playlist_tracks(&state, 7).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_mode_rejects_async_federation_queue_additions() {
|
||||
let mut state = AppState::default();
|
||||
|
||||
enqueue_tracks(
|
||||
&mut state,
|
||||
vec![test_track(1), pending_fed_track(2), test_track(3)],
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.player
|
||||
.queue
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_local_mode_removes_pending_federation_queue_tracks() {
|
||||
let mut state = AppState::default();
|
||||
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My;
|
||||
state.player.queue = vec![test_track(1), pending_fed_track(2), test_track(3)];
|
||||
state.player.queue_pos = 1;
|
||||
state.player.current = Some(state.player.queue[1].clone());
|
||||
state.player.playing = true;
|
||||
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
|
||||
|
||||
let effect = apply_library_filter_change(&mut state);
|
||||
|
||||
assert!(matches!(
|
||||
effect,
|
||||
Some(Effect::RemoveQueueIndices {
|
||||
indices,
|
||||
restart_paused: Some(false),
|
||||
stop: false,
|
||||
}) if indices == vec![1]
|
||||
));
|
||||
assert_eq!(
|
||||
state
|
||||
.player
|
||||
.queue
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
);
|
||||
assert_eq!(state.player.queue_pos, 1);
|
||||
assert_eq!(state.player.current.as_ref().map(|track| track.id), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_track_info_uses_now_playing_track() {
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
..AppState::default()
|
||||
};
|
||||
state.player.queue = vec![test_track(1), test_track(2)];
|
||||
state.queue_tab.cursor = 0;
|
||||
state.player.current = Some(test_track(2));
|
||||
|
||||
assert_eq!(update(&mut state, Action::OpenCurrentTrackInfo), None);
|
||||
match &state.popup {
|
||||
Some(crate::app::state::Popup::TrackInfo { tracks, .. }) => {
|
||||
assert_eq!(
|
||||
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
|
||||
vec![2]
|
||||
);
|
||||
}
|
||||
other => panic!("expected track info popup, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visualizer_requires_current_track() {
|
||||
let mut state = AppState::default();
|
||||
|
||||
assert_eq!(update(&mut state, Action::ToggleVisualizer), None);
|
||||
|
||||
assert!(!state.visualizer.active);
|
||||
assert_eq!(
|
||||
state.status_message.as_deref(),
|
||||
Some("nothing playing — start a track first")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visualizer_toggles_and_back_closes_it() {
|
||||
let mut state = AppState::default();
|
||||
state.player.current = Some(test_track(7));
|
||||
state.help_visible = true;
|
||||
|
||||
assert_eq!(update(&mut state, Action::ToggleVisualizer), None);
|
||||
assert!(state.visualizer.active);
|
||||
assert!(state.visualizer.started_at.is_some());
|
||||
assert!(!state.help_visible);
|
||||
|
||||
assert_eq!(update(&mut state, Action::Back), None);
|
||||
assert!(!state.visualizer.active);
|
||||
assert!(state.visualizer.started_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_playlist_from_release_carries_selected_track() {
|
||||
use crate::app::state::{PlaylistAddTarget, Popup};
|
||||
use crate::library::models::ReleaseDetail;
|
||||
|
||||
let mut state = AppState::default();
|
||||
state
|
||||
.global
|
||||
.stack
|
||||
.push(GlobalView::Release { id: 1, cursor: 1 });
|
||||
state.release_views.insert(
|
||||
1,
|
||||
Loadable::Ready(ReleaseDetail {
|
||||
id: 1,
|
||||
title: "r".into(),
|
||||
release_type: "album".into(),
|
||||
year: None,
|
||||
cover_path: None,
|
||||
artists: vec![],
|
||||
tracks: vec![test_track(1), test_track(2)],
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(update(&mut state, Action::AddToPlaylist), None);
|
||||
match &state.popup {
|
||||
Some(Popup::AddToPlaylist {
|
||||
target: PlaylistAddTarget::Local(tracks),
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(
|
||||
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
|
||||
vec![2]
|
||||
);
|
||||
}
|
||||
other => panic!("expected add-to-playlist popup with selected track, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_playlist_from_non_track_view_uses_current_track() {
|
||||
use crate::app::state::{PlaylistAddTarget, Popup};
|
||||
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Logs,
|
||||
..AppState::default()
|
||||
};
|
||||
state.player.current = Some(test_track(7));
|
||||
|
||||
assert_eq!(update(&mut state, Action::AddToPlaylist), None);
|
||||
match &state.popup {
|
||||
Some(Popup::AddToPlaylist {
|
||||
target: PlaylistAddTarget::Local(tracks),
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(
|
||||
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
|
||||
vec![7]
|
||||
);
|
||||
}
|
||||
other => panic!("expected add-to-playlist popup with current track, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_selection_removes_queue_range() {
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
..AppState::default()
|
||||
};
|
||||
state.player.queue = (1..=4).map(test_track).collect();
|
||||
state.queue_tab.cursor = 1;
|
||||
|
||||
assert_eq!(update(&mut state, Action::ToggleTrackSelection), None);
|
||||
update(&mut state, Action::MoveDown);
|
||||
|
||||
let selected: Vec<i64> = selected_tracks(&state)
|
||||
.into_iter()
|
||||
.map(|track| track.id)
|
||||
.collect();
|
||||
assert_eq!(selected, vec![2, 3]);
|
||||
assert_eq!(
|
||||
update(&mut state, Action::RemoveFromQueue),
|
||||
Some(Effect::RemoveQueueIndices {
|
||||
indices: vec![1, 2],
|
||||
restart_paused: None,
|
||||
stop: false,
|
||||
})
|
||||
);
|
||||
let remaining: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
||||
assert_eq!(remaining, vec![1, 4]);
|
||||
assert!(!state.track_selection.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_top_track_selection_queues_all_selected_tracks() {
|
||||
let mut state = AppState::default();
|
||||
state
|
||||
.global
|
||||
.stack
|
||||
.push(GlobalView::Artist { id: 9, cursor: 0 });
|
||||
state.artist_views.insert(
|
||||
9,
|
||||
Loadable::Ready(ArtistDetail {
|
||||
id: 9,
|
||||
name: "artist".into(),
|
||||
image_path: None,
|
||||
total_track_count: 3,
|
||||
total_play_count: 0,
|
||||
top_tracks: (1..=3).map(test_track).collect(),
|
||||
releases: vec![],
|
||||
featured_tracks: vec![],
|
||||
}),
|
||||
);
|
||||
|
||||
update(&mut state, Action::ToggleTrackSelection);
|
||||
update(&mut state, Action::MoveDown);
|
||||
assert_eq!(
|
||||
update(&mut state, Action::QueueAddLast),
|
||||
Some(Effect::PlaybackQueueChanged),
|
||||
);
|
||||
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
||||
assert_eq!(queued, vec![1, 2]);
|
||||
assert!(!state.track_selection.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_current_queue_track_requests_paused_restart() {
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
..AppState::default()
|
||||
};
|
||||
state.player.queue = (1..=3).map(test_track).collect();
|
||||
state.player.queue_pos = 1;
|
||||
state.queue_tab.cursor = 1;
|
||||
state.player.current = Some(test_track(2));
|
||||
state.player.playing = true;
|
||||
state.player.paused = true;
|
||||
|
||||
assert_eq!(
|
||||
update(&mut state, Action::RemoveFromQueue),
|
||||
Some(Effect::RemoveQueueIndices {
|
||||
indices: vec![1],
|
||||
restart_paused: Some(true),
|
||||
stop: false,
|
||||
})
|
||||
);
|
||||
let remaining: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
||||
assert_eq!(remaining, vec![1, 3]);
|
||||
assert_eq!(state.player.queue_pos, 1);
|
||||
assert_eq!(state.player.current.as_ref().map(|track| track.id), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_like_targets_only_tracks_that_need_toggle() {
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
..AppState::default()
|
||||
};
|
||||
state.player.queue = (1..=3).map(test_track).collect();
|
||||
state.likes.insert(format!("b3:{:064x}", 1));
|
||||
|
||||
update(&mut state, Action::ToggleTrackSelection);
|
||||
update(&mut state, Action::SelectLast);
|
||||
assert_eq!(
|
||||
update(&mut state, Action::ToggleLike),
|
||||
Some(Effect::ToggleLikes {
|
||||
track_ids: vec![2, 3],
|
||||
fed_tracks: vec![],
|
||||
})
|
||||
);
|
||||
|
||||
state.likes = [1, 2, 3]
|
||||
.into_iter()
|
||||
.map(|id| format!("b3:{id:064x}"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
update(&mut state, Action::ToggleLike),
|
||||
Some(Effect::ToggleLikes {
|
||||
track_ids: vec![1, 2, 3],
|
||||
fed_tracks: vec![],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shuffle_reorders_tail_and_restores() {
|
||||
use crate::library::models::TrackItem;
|
||||
let track = |id: i64| TrackItem {
|
||||
id,
|
||||
title: format!("t{id}"),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 1.0,
|
||||
artists: vec![],
|
||||
featured_artists: vec![],
|
||||
release_id: 1,
|
||||
release_title: "r".into(),
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
};
|
||||
let mut state = AppState::default();
|
||||
state.player.queue = (1..=8).map(track).collect();
|
||||
state.player.queue_pos = 2;
|
||||
state.player.current = Some(track(3));
|
||||
|
||||
update(&mut state, Action::ToggleShuffle);
|
||||
assert!(state.player.shuffle);
|
||||
// Played part and the current track stay in place.
|
||||
let ids: Vec<i64> = state.player.queue.iter().map(|t| t.id).collect();
|
||||
assert_eq!(&ids[..3], &[1, 2, 3]);
|
||||
// The tail is a permutation of the original tail.
|
||||
let mut tail = ids[3..].to_vec();
|
||||
tail.sort_unstable();
|
||||
assert_eq!(tail, vec![4, 5, 6, 7, 8]);
|
||||
|
||||
update(&mut state, Action::ToggleShuffle);
|
||||
assert!(!state.player.shuffle);
|
||||
let restored: Vec<i64> = state.player.queue.iter().map(|t| t.id).collect();
|
||||
assert_eq!(restored, vec![1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
assert!(state.player.original_order.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_j_opens_release_from_queue() {
|
||||
use crate::library::models::{ReleaseDetail, TrackItem};
|
||||
let track = |id: i64, release_id: i64| TrackItem {
|
||||
id,
|
||||
title: format!("t{id}"),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 1.0,
|
||||
artists: vec![],
|
||||
featured_artists: vec![],
|
||||
release_id,
|
||||
release_title: "r".into(),
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
};
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
..AppState::default()
|
||||
};
|
||||
state.player.queue = vec![track(1, 7), track(2, 7)];
|
||||
state.queue_tab.cursor = 1;
|
||||
|
||||
// Release not loaded yet → jump queued as pending focus.
|
||||
update(&mut state, Action::GoToRelease);
|
||||
assert_eq!(state.active_tab, Tab::Global);
|
||||
assert_eq!(
|
||||
state.global.stack.last(),
|
||||
Some(&GlobalView::Release { id: 7, cursor: 0 })
|
||||
);
|
||||
assert_eq!(state.pending_release_focus, Some((7, 2)));
|
||||
|
||||
// Esc returns to the origin tab, not to the Global grid.
|
||||
update(&mut state, Action::Back);
|
||||
assert_eq!(state.active_tab, Tab::Queue);
|
||||
assert!(state.global.stack.is_empty());
|
||||
assert!(state.jump_origin.is_none());
|
||||
|
||||
// With the release cached, the cursor lands on the track directly.
|
||||
state.global.stack.clear();
|
||||
state.pending_release_focus = None;
|
||||
state.release_views.insert(
|
||||
7,
|
||||
Loadable::Ready(ReleaseDetail {
|
||||
id: 7,
|
||||
title: "r".into(),
|
||||
release_type: "album".into(),
|
||||
year: None,
|
||||
cover_path: None,
|
||||
artists: vec![],
|
||||
tracks: vec![track(1, 7), track(2, 7)],
|
||||
}),
|
||||
);
|
||||
state.active_tab = Tab::Queue;
|
||||
update(&mut state, Action::GoToRelease);
|
||||
assert_eq!(
|
||||
state.global.stack.last(),
|
||||
Some(&GlobalView::Release { id: 7, cursor: 1 })
|
||||
);
|
||||
assert!(state.pending_release_focus.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_toggle() {
|
||||
let mut state = AppState::default();
|
||||
update(&mut state, Action::ToggleViewMode);
|
||||
assert_eq!(state.global.view, ViewMode::Table);
|
||||
update(&mut state, Action::ToggleViewMode);
|
||||
assert_eq!(state.global.view, ViewMode::Tiles);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
# command: an Action name, optionally with parameters:
|
||||
# command = { SeekForward = { seconds = 30 } }
|
||||
# context: optional view filter — global (default), library, search,
|
||||
# playlists, queue, devices.
|
||||
# playlists, queue, logs.
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "q"
|
||||
@@ -47,6 +47,14 @@ command = { GoToTab = 2 }
|
||||
key_sequence = "4"
|
||||
command = { GoToTab = 3 }
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "5"
|
||||
command = { GoToTab = 4 }
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "y"
|
||||
command = "DownloadSelected"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "a"
|
||||
command = "QueueAddNext"
|
||||
@@ -57,7 +65,16 @@ command = "QueueAddLast"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-c"
|
||||
command = "ClearQueue"
|
||||
command = "OpenConnectedDevices"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "d"
|
||||
command = "RemoveFromQueue"
|
||||
context = "queue"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "delete"
|
||||
command = "RemoveFromQueue"
|
||||
context = "queue"
|
||||
|
||||
[[keymaps]]
|
||||
@@ -68,11 +85,6 @@ command = "GoToRelease"
|
||||
key_sequence = "shift-p"
|
||||
command = "AddToPlaylist"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "n"
|
||||
command = "NewPlaylist"
|
||||
context = "playlists"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "j"
|
||||
command = "MoveDown"
|
||||
@@ -181,22 +193,47 @@ command = "ToggleShuffle"
|
||||
key_sequence = "r"
|
||||
command = "CycleRepeat"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-l"
|
||||
command = "ToggleVisualizer"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "x"
|
||||
command = "ToggleLike"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-l"
|
||||
command = "Logout"
|
||||
key_sequence = "shift-v"
|
||||
command = "ToggleTrackSelection"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "i"
|
||||
command = "OpenTrackInfo"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-i"
|
||||
command = "OpenCurrentTrackInfo"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "e"
|
||||
command = "EditSelected"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-d"
|
||||
command = "OpenDevices"
|
||||
command = "DeleteSelected"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "v"
|
||||
command = "ToggleViewMode"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "m"
|
||||
command = "CycleSourceMode"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "f"
|
||||
command = "OpenLibraryFilters"
|
||||
context = "library"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = ":"
|
||||
command = "OpenCommandLine"
|
||||
|
||||
+118
-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();
|
||||
|
||||
+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,133 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LibrarySourceMode {
|
||||
#[default]
|
||||
Local,
|
||||
My,
|
||||
Global,
|
||||
}
|
||||
|
||||
impl LibrarySourceMode {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
LibrarySourceMode::Local => "Local",
|
||||
LibrarySourceMode::My => "My",
|
||||
LibrarySourceMode::Global => "Global",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn includes_network(self) -> bool {
|
||||
!matches!(self, LibrarySourceMode::Local)
|
||||
}
|
||||
|
||||
pub fn includes_global_peers(self) -> bool {
|
||||
matches!(self, LibrarySourceMode::Global)
|
||||
}
|
||||
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
LibrarySourceMode::Local => LibrarySourceMode::My,
|
||||
LibrarySourceMode::My => LibrarySourceMode::Global,
|
||||
LibrarySourceMode::Global => LibrarySourceMode::Local,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LibraryFilters {
|
||||
#[serde(default)]
|
||||
pub hide_featured_only: bool,
|
||||
#[serde(default)]
|
||||
pub source_mode: LibrarySourceMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AppSettings {
|
||||
#[serde(default = "default_volume")]
|
||||
pub volume: u8,
|
||||
#[serde(default)]
|
||||
pub library: LibraryFilters,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
volume: default_volume(),
|
||||
library: LibraryFilters::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
pub fn normalized(mut self) -> Self {
|
||||
self.volume = self.volume.min(100);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn default_volume() -> u8 {
|
||||
80
|
||||
}
|
||||
|
||||
pub fn load() -> (AppSettings, Option<String>) {
|
||||
let Some(path) = settings_path() else {
|
||||
return (AppSettings::default(), None);
|
||||
};
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => match toml::from_str::<AppSettings>(&text) {
|
||||
Ok(settings) => (settings.normalized(), None),
|
||||
Err(err) => (
|
||||
AppSettings::default(),
|
||||
Some(format!("settings.toml is malformed: {err}")),
|
||||
),
|
||||
},
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => (AppSettings::default(), None),
|
||||
Err(err) => (
|
||||
AppSettings::default(),
|
||||
Some(format!("settings.toml could not be read: {err}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(settings: &AppSettings) -> Result<()> {
|
||||
let path = settings_path().context("cannot determine the config directory")?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(
|
||||
&path,
|
||||
toml::to_string_pretty(&settings.clone().normalized())?,
|
||||
)
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn settings_path() -> Option<std::path::PathBuf> {
|
||||
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("settings.toml"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_parse_and_normalize() {
|
||||
let settings: AppSettings = toml::from_str(
|
||||
r#"
|
||||
volume = 150
|
||||
|
||||
[library]
|
||||
hide_featured_only = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = settings.normalized();
|
||||
|
||||
assert_eq!(settings.volume, 100);
|
||||
assert!(settings.library.hide_featured_only);
|
||||
assert_eq!(settings.library.source_mode, LibrarySourceMode::Local);
|
||||
}
|
||||
}
|
||||
+3542
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,506 @@
|
||||
use super::*;
|
||||
|
||||
static NEXT_TEST_DB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
fn test_sync() -> DeviceSync {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
init_schema(&conn).unwrap();
|
||||
let unique = NEXT_TEST_DB.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let library_path = std::env::temp_dir().join(format!(
|
||||
"furumi-devices-test-{}-{}-{}.sqlite3",
|
||||
std::process::id(),
|
||||
now_ms(),
|
||||
unique
|
||||
));
|
||||
let sync = DeviceSync {
|
||||
conn: Arc::new(std::sync::Mutex::new(conn)),
|
||||
library: Arc::new(Library::open(&library_path).unwrap()),
|
||||
event_tx: Arc::new(std::sync::Mutex::new(None)),
|
||||
playback: Arc::new(std::sync::Mutex::new(PlaybackShared::default())),
|
||||
};
|
||||
sync.ensure_identity().unwrap();
|
||||
sync
|
||||
}
|
||||
|
||||
fn device_revoked(sync: &DeviceSync, device_id: &str) -> bool {
|
||||
let conn = lock(&sync.conn);
|
||||
conn.query_row(
|
||||
"SELECT revoked_at_ms IS NOT NULL
|
||||
FROM sync_devices
|
||||
WHERE device_id = ?1",
|
||||
[device_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap()
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
}
|
||||
|
||||
fn device_known(sync: &DeviceSync, device_id: &str) -> bool {
|
||||
let conn = lock(&sync.conn);
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM sync_devices WHERE device_id = ?1",
|
||||
[device_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn test_fed_track(content_id: &str) -> crate::federation::FedTrack {
|
||||
crate::federation::FedTrack {
|
||||
item_id: "fed_item_1".to_string(),
|
||||
owner: "fed_owner_1".to_string(),
|
||||
own: false,
|
||||
title: "Remote Song".to_string(),
|
||||
artist_names: vec!["Remote Artist".to_string()],
|
||||
featured_artist_names: Vec::new(),
|
||||
year: Some(2026),
|
||||
duration_seconds: Some(123),
|
||||
content_id: Some(content_id.to_string()),
|
||||
release_title: Some("Remote Release".to_string()),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64url_round_trip_without_padding() {
|
||||
for input in [b"".as_slice(), b"a", b"ab", b"abc", b"abcdef"] {
|
||||
let encoded = base64url_encode(input);
|
||||
assert!(!encoded.contains('='));
|
||||
assert_eq!(base64url_decode(&encoded).unwrap(), input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_detection() {
|
||||
assert!(
|
||||
SyncOpPayload::TrackLikeSet {
|
||||
content_id: "b3:0".into(),
|
||||
liked: false,
|
||||
fed: None,
|
||||
}
|
||||
.is_tombstone()
|
||||
);
|
||||
assert!(
|
||||
!SyncOpPayload::TrackLikeSet {
|
||||
content_id: "b3:0".into(),
|
||||
liked: true,
|
||||
fed: None,
|
||||
}
|
||||
.is_tombstone()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_tracks_do_not_sync_device_local_paths() {
|
||||
let source = TrackItem {
|
||||
id: 7,
|
||||
title: "Local Song".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
duration_seconds: 180.0,
|
||||
artists: vec![ArtistRef {
|
||||
id: 1,
|
||||
name: "Local Artist".to_string(),
|
||||
}],
|
||||
featured_artists: Vec::new(),
|
||||
release_id: 2,
|
||||
release_title: "Local Release".to_string(),
|
||||
release_year: Some(2026),
|
||||
file_path: r"C:\Users\me\Music\song.mp3".to_string(),
|
||||
content_id: Some(format!("b3:{}", "a".repeat(64))),
|
||||
cover_path: None,
|
||||
audio_format: Some("mp3".to_string()),
|
||||
audio_bitrate: Some(320),
|
||||
audio_sample_rate: Some(44_100),
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: Some(123_456),
|
||||
play_count: 3,
|
||||
fed: None,
|
||||
};
|
||||
|
||||
let wire = PlaybackTrack::from_track(&source);
|
||||
assert!(wire.file_path.is_empty());
|
||||
|
||||
let mut legacy_wire = wire.clone();
|
||||
legacy_wire.file_path = "/Users/me/Music/song.mp3".to_string();
|
||||
let restored = legacy_wire.to_track_item();
|
||||
assert!(restored.id < 0);
|
||||
assert_ne!(restored.id, source.id);
|
||||
assert!(restored.file_path.is_empty());
|
||||
assert_eq!(restored.content_id, source.content_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacted_device_revoke_removes_device_row() {
|
||||
let sync = test_sync();
|
||||
let device_id = "dev_old";
|
||||
|
||||
sync.apply_device_trusted(device_id, 10).unwrap();
|
||||
assert!(device_known(&sync, device_id));
|
||||
|
||||
sync.revoke_device(device_id).unwrap();
|
||||
assert!(!device_known(&sync, device_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leave_group_self_revokes_then_resets_to_new_group() {
|
||||
let sync = test_sync();
|
||||
let identity = sync.ensure_identity().unwrap();
|
||||
let old_group = identity.group_id.clone();
|
||||
sync.apply_device_trusted("dev_peer", 10).unwrap();
|
||||
|
||||
let op_id = sync.record_leave_group_revoke().unwrap();
|
||||
assert!(device_revoked(&sync, &identity.device_id));
|
||||
{
|
||||
let conn = lock(&sync.conn);
|
||||
let payload_json: String = conn
|
||||
.query_row(
|
||||
"SELECT payload_json FROM sync_ops WHERE op_id = ?1",
|
||||
[&op_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let payload: SyncOpPayload = serde_json::from_str(&payload_json).unwrap();
|
||||
match payload {
|
||||
SyncOpPayload::DeviceRevoked {
|
||||
target_device_id,
|
||||
target_max_seq_seen,
|
||||
} => {
|
||||
assert_eq!(target_device_id, identity.device_id);
|
||||
assert_eq!(target_max_seq_seen, 1);
|
||||
}
|
||||
other => panic!("unexpected payload: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
let new_group = sync.finish_leave_group_reset().unwrap();
|
||||
assert_ne!(old_group, new_group);
|
||||
let status = sync.status();
|
||||
assert_eq!(status.group_id, new_group);
|
||||
assert_eq!(status.active_devices, 1);
|
||||
assert_eq!(status.devices.len(), 1);
|
||||
assert!(status.devices[0].is_self);
|
||||
assert!(!status.devices[0].revoked);
|
||||
assert_eq!(status.ops_total, 0);
|
||||
assert_eq!(status.outbox_ops, 0);
|
||||
assert!(!device_known(&sync, "dev_peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_command_is_targeted_and_deduplicated() {
|
||||
let sync = test_sync();
|
||||
let identity = sync.ensure_identity().unwrap();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
sync.set_event_tx(tx);
|
||||
let command = PlaybackCommand::SetState {
|
||||
state: PlaybackStateWire {
|
||||
queue: Vec::new(),
|
||||
queue_pos: 0,
|
||||
playing: false,
|
||||
paused: false,
|
||||
idle_since_ms: None,
|
||||
position_secs: 0.0,
|
||||
volume: 42,
|
||||
shuffle: false,
|
||||
repeat: PlaybackRepeat::Off,
|
||||
},
|
||||
seek: false,
|
||||
};
|
||||
|
||||
sync.apply_playback_command("dev_other", &command, "op_other")
|
||||
.unwrap();
|
||||
assert!(rx.try_recv().is_err());
|
||||
|
||||
sync.apply_playback_command(&identity.device_id, &command, "op_1")
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
rx.try_recv().unwrap(),
|
||||
crate::app::event::AppEvent::PlaybackCommand(_)
|
||||
));
|
||||
|
||||
sync.apply_playback_command(&identity.device_id, &command, "op_1")
|
||||
.unwrap();
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_device_trust_reactivates_revoked_device() {
|
||||
let sync = test_sync();
|
||||
let device_id = "dev_readd";
|
||||
|
||||
sync.apply_device_trusted(device_id, 10).unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_revoked(device_id, 20, "dev_owner", 0)
|
||||
.unwrap();
|
||||
assert!(device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_trusted(device_id, 30).unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_revoked(device_id, 25, "dev_owner", 0)
|
||||
.unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_profile(
|
||||
&DeviceProfileWire {
|
||||
device_id: device_id.to_string(),
|
||||
name: "readded".to_string(),
|
||||
client_version: CLIENT_VERSION.to_string(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
endpoint_id: String::new(),
|
||||
endpoint_ticket: String::new(),
|
||||
revoked: true,
|
||||
revoke_cutoff_seq: Some(0),
|
||||
updated_at_ms: 20,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_gc_waits_for_every_active_remote_ack() {
|
||||
let sync = test_sync();
|
||||
let origin = sync.ensure_identity().unwrap().device_id;
|
||||
sync.apply_device_trusted("dev_a", 1).unwrap();
|
||||
sync.apply_device_trusted("dev_b", 1).unwrap();
|
||||
|
||||
sync.record_local_op(SyncOpPayload::PlaylistDeleted {
|
||||
playlist_id: "pl_deleted".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
{
|
||||
let conn = lock(&sync.conn);
|
||||
let tombstones: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tombstones, 1);
|
||||
}
|
||||
|
||||
let ack = BTreeMap::from([(origin, 1)]);
|
||||
sync.note_peer_vector("dev_a", &ack).unwrap();
|
||||
sync.gc_tombstones().unwrap();
|
||||
{
|
||||
let conn = lock(&sync.conn);
|
||||
let tombstones: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tombstones, 1);
|
||||
}
|
||||
|
||||
sync.note_peer_vector("dev_b", &ack).unwrap();
|
||||
sync.gc_tombstones().unwrap();
|
||||
let conn = lock(&sync.conn);
|
||||
let tombstones: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tombstones, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_carries_deleted_playlists_to_repair_stale_peers() {
|
||||
let source = test_sync();
|
||||
let source_playlist = source.library.create_playlist("Gone").unwrap();
|
||||
let playlist_sync_id = source
|
||||
.library
|
||||
.ensure_playlist_sync_id(source_playlist.id)
|
||||
.unwrap();
|
||||
source
|
||||
.apply_playlist_state(&playlist_sync_id, "Gone", false, 10, "dev_remote:1")
|
||||
.unwrap();
|
||||
source
|
||||
.apply_playlist_state(&playlist_sync_id, "", true, 20, "dev_remote:2")
|
||||
.unwrap();
|
||||
let snapshot = source.snapshot().unwrap();
|
||||
assert!(
|
||||
snapshot
|
||||
.deleted_playlists
|
||||
.iter()
|
||||
.any(|playlist| playlist.playlist_id == playlist_sync_id)
|
||||
);
|
||||
|
||||
let peer = test_sync();
|
||||
let peer_playlist = peer
|
||||
.library
|
||||
.upsert_synced_playlist(&playlist_sync_id, "Gone")
|
||||
.unwrap();
|
||||
assert!(peer.library.playlist(peer_playlist).is_ok());
|
||||
|
||||
peer.apply_snapshot(snapshot).unwrap();
|
||||
assert!(
|
||||
!peer
|
||||
.library
|
||||
.playlists()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|playlist| playlist.title == "Gone")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_fed_like_metadata_repairs_existing_like_state() {
|
||||
let sync = test_sync();
|
||||
let content_id = format!("b3:{}", "a".repeat(64));
|
||||
let fed = test_fed_track(&content_id);
|
||||
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
|
||||
|
||||
assert!(
|
||||
sync.apply_like_state(&content_id, true, None, 10, "dev_remote:1")
|
||||
.unwrap()
|
||||
);
|
||||
assert!(sync.library.fed_like_ids().unwrap().is_empty());
|
||||
|
||||
assert!(
|
||||
sync.apply_like_state(&content_id, true, Some(&synced), 10, "dev_remote:1")
|
||||
.unwrap()
|
||||
);
|
||||
let keys = sync.library.fed_like_ids().unwrap();
|
||||
assert!(keys.contains(&fed.item_id));
|
||||
assert!(keys.contains(&content_id));
|
||||
|
||||
assert!(
|
||||
sync.apply_like_state(&content_id, false, None, 11, "dev_remote:2")
|
||||
.unwrap()
|
||||
);
|
||||
assert!(sync.library.fed_like_ids().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_fed_likes_are_ordered_by_hlc_not_receive_time() {
|
||||
let sync = test_sync();
|
||||
let old_content_id = format!("b3:{}", "c".repeat(64));
|
||||
let new_content_id = format!("b3:{}", "d".repeat(64));
|
||||
let mut old_fed = test_fed_track(&old_content_id);
|
||||
old_fed.item_id = "fed_old".to_string();
|
||||
old_fed.title = "Old Fed".to_string();
|
||||
let mut new_fed = test_fed_track(&new_content_id);
|
||||
new_fed.item_id = "fed_new".to_string();
|
||||
new_fed.title = "New Fed".to_string();
|
||||
|
||||
let new_synced = SyncedFedTrack::from_fed(&new_fed).unwrap();
|
||||
let old_synced = SyncedFedTrack::from_fed(&old_fed).unwrap();
|
||||
sync.apply_like_state(&new_content_id, true, Some(&new_synced), 20, "dev_remote:2")
|
||||
.unwrap();
|
||||
sync.apply_like_state(&old_content_id, true, Some(&old_synced), 10, "dev_remote:1")
|
||||
.unwrap();
|
||||
|
||||
let titles: Vec<String> = sync
|
||||
.library
|
||||
.playlist(crate::library::LIKES_PLAYLIST_ID)
|
||||
.unwrap()
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| track.title)
|
||||
.collect();
|
||||
assert_eq!(titles, vec!["New Fed", "Old Fed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_playlist_item_metadata_creates_pending_fed_track() {
|
||||
let sync = test_sync();
|
||||
let playlist = sync.library.create_playlist("Remote Mix").unwrap();
|
||||
let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap();
|
||||
let content_id = format!("b3:{}", "b".repeat(64));
|
||||
let fed = test_fed_track(&content_id);
|
||||
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
true,
|
||||
3,
|
||||
Some(&synced),
|
||||
10,
|
||||
"dev_remote:2",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let detail = sync.library.playlist(playlist.id).unwrap();
|
||||
assert_eq!(detail.tracks.len(), 1);
|
||||
assert!(detail.tracks[0].is_fed_pending());
|
||||
assert_eq!(detail.tracks[0].title, fed.title);
|
||||
|
||||
let conn = lock(&sync.conn);
|
||||
assert_eq!(
|
||||
sync.unresolved_playlist_item_count_with_conn(&conn)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
false,
|
||||
0,
|
||||
None,
|
||||
11,
|
||||
"dev_remote:3",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_synced_playlist_item_metadata_repairs_pending_fed_track() {
|
||||
let sync = test_sync();
|
||||
let playlist = sync.library.create_playlist("Remote Mix").unwrap();
|
||||
let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap();
|
||||
let content_id = format!("b3:{}", "e".repeat(64));
|
||||
let fed = test_fed_track(&content_id);
|
||||
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
true,
|
||||
7,
|
||||
None,
|
||||
10,
|
||||
"dev_remote:2",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0);
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
true,
|
||||
7,
|
||||
Some(&synced),
|
||||
10,
|
||||
"dev_remote:2",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let detail = sync.library.playlist(playlist.id).unwrap();
|
||||
assert_eq!(detail.tracks.len(), 1);
|
||||
assert!(detail.tracks[0].is_fed_pending());
|
||||
assert_eq!(detail.tracks[0].title, fed.title);
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
//! The peer-to-peer audio protocol, wire compatible with furumi-fd.
|
||||
//!
|
||||
//! One byte stream per request: the requester sends one JSON line
|
||||
//! ([`AudioRequest`]) and receives one JSON line ([`AudioResponseHeader`])
|
||||
//! followed by the raw file bytes from the requested offset, unless the
|
||||
//! requester asked for metadata only.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
|
||||
use crate::library::Library;
|
||||
|
||||
/// ALPN of the audio streaming protocol (shared with furumi-fd).
|
||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||
|
||||
/// Maximum size of a JSON protocol line (request or response header).
|
||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||
const STREAM_START_BUFFER_BYTES: u64 = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DownloadProgress {
|
||||
pub stream_key: u64,
|
||||
pub transport_phase: &'static str,
|
||||
pub received: u64,
|
||||
pub total: u64,
|
||||
pub transport: Option<music_dht::ByteStreamConnectionStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StreamingStart {
|
||||
pub reader: crate::streaming::GrowingFileReader,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AudioRequest {
|
||||
/// Hex-encoded [`ItemId`] of the track.
|
||||
item_id: String,
|
||||
/// Byte offset to start streaming from (audio only; the cover, when
|
||||
/// requested, is always sent whole).
|
||||
offset: u64,
|
||||
/// Ask the owner to send the cover art between the header and the
|
||||
/// audio bytes. Default false keeps the wire layout compatible with
|
||||
/// older peers in both directions.
|
||||
#[serde(default)]
|
||||
want_cover: bool,
|
||||
/// Ask only for the response header with metadata and file facts.
|
||||
/// Older peers ignore the field and may start streaming audio; the
|
||||
/// requester simply drops the stream after reading the header.
|
||||
#[serde(default)]
|
||||
metadata_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AudioResponseHeader {
|
||||
ok: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
mime_type: String,
|
||||
#[serde(default)]
|
||||
total_size: u64,
|
||||
#[serde(default)]
|
||||
offset: u64,
|
||||
/// Full track metadata from the owner's database — richer and more
|
||||
/// authoritative than whatever tags the file itself carries. Absent
|
||||
/// when the peer predates the field (the header is extensible).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
metadata: Option<TrackMetadata>,
|
||||
/// Size of the cover-art segment sent between this header and the
|
||||
/// audio bytes; 0 = no cover (not requested, not available).
|
||||
#[serde(default)]
|
||||
cover_size: u64,
|
||||
#[serde(default)]
|
||||
cover_mime: String,
|
||||
/// Size of the main artist's image segment, sent after the cover and
|
||||
/// before the audio; 0 = none. Governed by the same `want_cover` flag.
|
||||
#[serde(default)]
|
||||
artist_image_size: u64,
|
||||
#[serde(default)]
|
||||
artist_image_mime: String,
|
||||
}
|
||||
|
||||
/// Covers above this size are skipped rather than transferred.
|
||||
const MAX_COVER_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
fn image_mime(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"png" => "image/png",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
"bmp" => "image/bmp",
|
||||
_ => "image/jpeg",
|
||||
}
|
||||
}
|
||||
|
||||
/// File extension for a received cover, from its mime type.
|
||||
pub fn image_extension(mime: &str) -> &'static str {
|
||||
match mime {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/bmp" => "bmp",
|
||||
_ => "jpg",
|
||||
}
|
||||
}
|
||||
|
||||
/// Track metadata exchanged alongside the audio bytes.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TrackMetadata {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub artists: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub featured_artists: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub album_artists: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub release_title: String,
|
||||
#[serde(default)]
|
||||
pub release_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub year: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub track_number: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub disc_number: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub audio_format: Option<String>,
|
||||
#[serde(default)]
|
||||
pub audio_bitrate: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub audio_bit_depth: Option<i32>,
|
||||
}
|
||||
|
||||
pub fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn hex_decode_item_id(value: &str) -> Option<ItemId> {
|
||||
if value.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(ItemId::from_bytes(bytes))
|
||||
}
|
||||
|
||||
fn guess_mime(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"mp3" => "audio/mpeg",
|
||||
"flac" => "audio/flac",
|
||||
"ogg" | "oga" => "audio/ogg",
|
||||
"opus" => "audio/opus",
|
||||
"wav" => "audio/wav",
|
||||
"m4a" | "mp4" | "alac" => "audio/mp4",
|
||||
"aac" => "audio/aac",
|
||||
"aiff" | "aif" => "audio/aiff",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension for a downloaded file, from the mime type the peer reported.
|
||||
fn extension_for_mime(mime: &str) -> &'static str {
|
||||
match mime {
|
||||
"audio/mpeg" => "mp3",
|
||||
"audio/flac" | "audio/x-flac" => "flac",
|
||||
"audio/ogg" => "ogg",
|
||||
"audio/opus" => "opus",
|
||||
"audio/wav" | "audio/x-wav" => "wav",
|
||||
"audio/mp4" | "audio/x-m4a" => "m4a",
|
||||
"audio/aac" => "aac",
|
||||
"audio/aiff" => "aiff",
|
||||
_ => "bin",
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort audio format label for metadata previews, from the owner's
|
||||
/// response mime type.
|
||||
pub fn format_for_mime(mime: &str) -> Option<String> {
|
||||
let extension = extension_for_mime(mime);
|
||||
(extension != "bin").then(|| extension.to_string())
|
||||
}
|
||||
|
||||
/// Reads one `\n`-terminated line, bounded by [`MAX_PROTOCOL_LINE`].
|
||||
pub(super) async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = reader.read(&mut byte).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("stream ended before the protocol line was complete");
|
||||
}
|
||||
if byte[0] == b'\n' {
|
||||
return Ok(line);
|
||||
}
|
||||
line.push(byte[0]);
|
||||
if line.len() > MAX_PROTOCOL_LINE {
|
||||
anyhow::bail!("protocol line exceeds {MAX_PROTOCOL_LINE} bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_line<W: AsyncWriteExt + Unpin>(
|
||||
writer: &mut W,
|
||||
value: &impl Serialize,
|
||||
) -> Result<()> {
|
||||
let mut line = serde_json::to_vec(value)?;
|
||||
line.push(b'\n');
|
||||
writer.write_all(&line).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requesting side: download a track from its owner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Outcome of [`download_track`].
|
||||
pub struct Downloaded {
|
||||
pub path: PathBuf,
|
||||
pub mime_type: String,
|
||||
pub metadata: Option<TrackMetadata>,
|
||||
/// Cover art (bytes, file extension) sent by the owner, if any.
|
||||
pub cover: Option<(Vec<u8>, &'static str)>,
|
||||
/// The main artist's image (bytes, file extension), if any.
|
||||
pub artist_image: Option<(Vec<u8>, &'static str)>,
|
||||
}
|
||||
|
||||
/// Header-only metadata fetched without downloading the audio bytes.
|
||||
pub struct FetchedMetadata {
|
||||
pub mime_type: String,
|
||||
pub total_size: u64,
|
||||
pub metadata: Option<TrackMetadata>,
|
||||
}
|
||||
|
||||
/// Fetches the owner's response header for a track and closes the stream
|
||||
/// before audio bytes are read.
|
||||
pub async fn fetch_metadata(
|
||||
service: &MusicDhtService,
|
||||
owner: EndpointId,
|
||||
item_id_hex: &str,
|
||||
) -> Result<FetchedMetadata> {
|
||||
let mut stream = service
|
||||
.open_stream(owner, AUDIO_ALPN)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("cannot reach the owner peer: {err}"))?;
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioRequest {
|
||||
item_id: item_id_hex.to_string(),
|
||||
offset: 0,
|
||||
want_cover: false,
|
||||
metadata_only: true,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let header: AudioResponseHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)
|
||||
.context("malformed response header")?;
|
||||
if !header.ok {
|
||||
anyhow::bail!(
|
||||
"peer refused the metadata: {}",
|
||||
header.error.unwrap_or_else(|| "unknown error".to_string())
|
||||
);
|
||||
}
|
||||
Ok(FetchedMetadata {
|
||||
mime_type: header.mime_type,
|
||||
total_size: header.total_size,
|
||||
metadata: header.metadata,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn download_track_with_streaming<F>(
|
||||
service: &MusicDhtService,
|
||||
owner: EndpointId,
|
||||
item_id_hex: &str,
|
||||
dir: &Path,
|
||||
stem: &str,
|
||||
want_images: bool,
|
||||
mut progress: F,
|
||||
mut stream_start: Option<&mut (dyn FnMut(StreamingStart) + Send)>,
|
||||
) -> Result<Downloaded>
|
||||
where
|
||||
F: FnMut(DownloadProgress) + Send,
|
||||
{
|
||||
let started = Instant::now();
|
||||
let mut stream = service
|
||||
.open_stream(owner, AUDIO_ALPN)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("cannot reach the owner peer: {err}"))?;
|
||||
let stream_key = super::stream_transport_key(&stream);
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioRequest {
|
||||
item_id: item_id_hex.to_string(),
|
||||
offset: 0,
|
||||
want_cover: want_images,
|
||||
metadata_only: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let header: AudioResponseHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)
|
||||
.context("malformed response header")?;
|
||||
if !header.ok {
|
||||
anyhow::bail!(
|
||||
"peer refused the stream: {}",
|
||||
header.error.unwrap_or_else(|| "unknown error".to_string())
|
||||
);
|
||||
}
|
||||
progress(DownloadProgress {
|
||||
stream_key,
|
||||
transport_phase: "download-open",
|
||||
received: 0,
|
||||
total: header.total_size,
|
||||
transport: Some(stream.connection_stats()),
|
||||
});
|
||||
tracing::info!(
|
||||
owner = %owner,
|
||||
item_id = %item_id_hex,
|
||||
audio_bytes = header.total_size,
|
||||
cover_bytes = header.cover_size,
|
||||
artist_image_bytes = header.artist_image_size,
|
||||
want_images,
|
||||
"federated audio stream opened"
|
||||
);
|
||||
|
||||
// The image segments precede the audio bytes and are read regardless of
|
||||
// the cache state — they sit first in the stream.
|
||||
let mut read_image =
|
||||
async |size: u64, mime: &str, what: &str| -> Result<Option<(Vec<u8>, &'static str)>> {
|
||||
if size == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
size <= MAX_COVER_BYTES,
|
||||
"{what} of {size} bytes exceeds the {MAX_COVER_BYTES} byte limit"
|
||||
);
|
||||
let mut bytes = vec![0u8; size as usize];
|
||||
stream
|
||||
.recv
|
||||
.read_exact(&mut bytes)
|
||||
.await
|
||||
.with_context(|| format!("stream ended inside the {what} segment"))?;
|
||||
Ok(Some((bytes, image_extension(mime))))
|
||||
};
|
||||
let cover = read_image(header.cover_size, &header.cover_mime, "cover").await?;
|
||||
let artist_image = read_image(
|
||||
header.artist_image_size,
|
||||
&header.artist_image_mime,
|
||||
"artist image",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let extension = extension_for_mime(&header.mime_type);
|
||||
let path = dir.join(format!("{stem}.{extension}"));
|
||||
if let Ok(metadata) = tokio::fs::metadata(&path).await
|
||||
&& metadata.len() == header.total_size
|
||||
&& header.total_size > 0
|
||||
{
|
||||
// Audio already fully downloaded earlier; no need to fetch again.
|
||||
progress(DownloadProgress {
|
||||
stream_key,
|
||||
transport_phase: "download-done",
|
||||
received: header.total_size,
|
||||
total: header.total_size,
|
||||
transport: Some(stream.connection_stats()),
|
||||
});
|
||||
tracing::info!(
|
||||
owner = %owner,
|
||||
item_id = %item_id_hex,
|
||||
bytes = header.total_size,
|
||||
path = %path.display(),
|
||||
"federated audio reused from cache"
|
||||
);
|
||||
return Ok(Downloaded {
|
||||
path,
|
||||
mime_type: header.mime_type,
|
||||
metadata: header.metadata,
|
||||
cover,
|
||||
artist_image,
|
||||
});
|
||||
}
|
||||
|
||||
let temp_path = dir.join(format!(".{stem}.{extension}.part"));
|
||||
let mut file = tokio::fs::File::create(&temp_path).await?;
|
||||
let mut streaming = match stream_start.as_ref() {
|
||||
Some(_) => match crate::streaming::growing_file(&temp_path) {
|
||||
Ok((reader, writer)) => Some((Some(reader), writer, false)),
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, path = %temp_path.display(), "streaming reader disabled");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let stream_start_at = stream_start_buffer(header.total_size);
|
||||
let mut received: u64 = 0;
|
||||
let mut chunk = vec![0u8; 64 * 1024];
|
||||
// quinn's inherent read returns None when the peer finished the stream.
|
||||
while let Some(n) = stream.recv.read(&mut chunk).await? {
|
||||
file.write_all(&chunk[..n]).await?;
|
||||
received += n as u64;
|
||||
if let Some((reader, writer, started)) = &mut streaming {
|
||||
writer.add_available(n as u64);
|
||||
if !*started
|
||||
&& received >= stream_start_at
|
||||
&& let (Some(reader), Some(callback)) = (reader.take(), stream_start.as_mut())
|
||||
{
|
||||
callback(StreamingStart {
|
||||
reader,
|
||||
mime_type: header.mime_type.clone(),
|
||||
});
|
||||
*started = true;
|
||||
}
|
||||
}
|
||||
progress(DownloadProgress {
|
||||
stream_key,
|
||||
transport_phase: "download",
|
||||
received,
|
||||
total: header.total_size,
|
||||
transport: None,
|
||||
});
|
||||
}
|
||||
if let Some((reader, writer, started)) = &mut streaming {
|
||||
if !*started
|
||||
&& received > 0
|
||||
&& let (Some(reader), Some(callback)) = (reader.take(), stream_start.as_mut())
|
||||
{
|
||||
callback(StreamingStart {
|
||||
reader,
|
||||
mime_type: header.mime_type.clone(),
|
||||
});
|
||||
*started = true;
|
||||
}
|
||||
writer.finish();
|
||||
}
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
if header.total_size > 0 && received != header.total_size {
|
||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||
anyhow::bail!(
|
||||
"download incomplete: got {received} of {} bytes",
|
||||
header.total_size
|
||||
);
|
||||
}
|
||||
tokio::fs::rename(&temp_path, &path).await?;
|
||||
progress(DownloadProgress {
|
||||
stream_key,
|
||||
transport_phase: "download-done",
|
||||
received,
|
||||
total: header.total_size,
|
||||
transport: Some(stream.connection_stats()),
|
||||
});
|
||||
let elapsed = started.elapsed();
|
||||
let kib_per_sec = if elapsed.as_secs_f64() > 0.0 {
|
||||
received as f64 / 1024.0 / elapsed.as_secs_f64()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
tracing::info!(
|
||||
owner = %owner,
|
||||
item_id = %item_id_hex,
|
||||
bytes = received,
|
||||
elapsed_ms = elapsed.as_millis(),
|
||||
kib_per_sec,
|
||||
"federated audio downloaded"
|
||||
);
|
||||
Ok(Downloaded {
|
||||
path,
|
||||
mime_type: header.mime_type,
|
||||
metadata: header.metadata,
|
||||
cover,
|
||||
artist_image,
|
||||
})
|
||||
}
|
||||
|
||||
fn stream_start_buffer(total_size: u64) -> u64 {
|
||||
if total_size == 0 {
|
||||
STREAM_START_BUFFER_BYTES
|
||||
} else {
|
||||
total_size.min(STREAM_START_BUFFER_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serving side: answer audio requests from other peers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Finds the local track whose derived DHT item id matches `item_id`.
|
||||
pub fn resolve_local_track_id(
|
||||
library: &Library,
|
||||
own: EndpointId,
|
||||
item_id: ItemId,
|
||||
) -> Result<Option<i64>> {
|
||||
let export = library.federation_export()?;
|
||||
for track in export.tracks {
|
||||
let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{}", track.id));
|
||||
if derived == item_id {
|
||||
return Ok(Some(track.id));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// What the serving side needs to answer one audio request.
|
||||
struct Served {
|
||||
file_path: String,
|
||||
metadata: TrackMetadata,
|
||||
cover_path: Option<String>,
|
||||
artist_image_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolves the item to the audio file, metadata and cover.
|
||||
fn resolve_for_serving(
|
||||
library: &Library,
|
||||
own: EndpointId,
|
||||
item_id: ItemId,
|
||||
) -> Result<Option<Served>> {
|
||||
let Some(track_id) = resolve_local_track_id(library, own, item_id)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(track) = library.tracks_by_ids(&[track_id])?.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Release type and album artists live on the release row.
|
||||
let (release_type, album_artists) = match library.release(track.release_id) {
|
||||
Ok(detail) => (
|
||||
Some(detail.release_type),
|
||||
detail.artists.iter().map(|a| a.name.clone()).collect(),
|
||||
),
|
||||
Err(_) => (None, Vec::new()),
|
||||
};
|
||||
let metadata = TrackMetadata {
|
||||
title: track.title.clone(),
|
||||
artists: track.artists.iter().map(|a| a.name.clone()).collect(),
|
||||
featured_artists: track
|
||||
.featured_artists
|
||||
.iter()
|
||||
.map(|a| a.name.clone())
|
||||
.collect(),
|
||||
album_artists,
|
||||
release_title: track.release_title.clone(),
|
||||
release_type,
|
||||
year: track.release_year,
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
duration_seconds: Some(track.duration_seconds),
|
||||
audio_format: track.audio_format.clone(),
|
||||
audio_bitrate: track.audio_bitrate,
|
||||
audio_sample_rate: track.audio_sample_rate,
|
||||
audio_bit_depth: track.audio_bit_depth,
|
||||
};
|
||||
let artist_image_path = track
|
||||
.artists
|
||||
.first()
|
||||
.and_then(|artist| library.artist_image(artist.id).ok().flatten());
|
||||
Ok(Some(Served {
|
||||
cover_path: track.cover_path.clone(),
|
||||
artist_image_path,
|
||||
file_path: track.file_path,
|
||||
metadata,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Runs the accept loop of the audio protocol until the acceptor closes.
|
||||
/// Every track of the local library is downloadable by every peer of the
|
||||
/// network — the libraries of all participants are equal.
|
||||
pub async fn serve_peers(
|
||||
mut acceptor: StreamAcceptor,
|
||||
library: Arc<Library>,
|
||||
own: EndpointId,
|
||||
transport_stats: Arc<crate::federation::TransportStats>,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let library = Arc::clone(&library);
|
||||
let transport_stats = Arc::clone(&transport_stats);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_one(stream, library, own, transport_stats).await {
|
||||
tracing::warn!(peer = %peer, "audio stream failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(
|
||||
mut stream: ByteStream,
|
||||
library: Arc<Library>,
|
||||
own: EndpointId,
|
||||
transport_stats: Arc<crate::federation::TransportStats>,
|
||||
) -> Result<()> {
|
||||
crate::federation::record_stream_transport(
|
||||
&transport_stats,
|
||||
"audio",
|
||||
"inbound",
|
||||
"open",
|
||||
&stream,
|
||||
);
|
||||
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
item = %request.item_id,
|
||||
offset = request.offset,
|
||||
"peer requested audio"
|
||||
);
|
||||
|
||||
let resolved = match hex_decode_item_id(&request.item_id) {
|
||||
Some(item_id) => {
|
||||
let library = Arc::clone(&library);
|
||||
match tokio::task::spawn_blocking(move || resolve_for_serving(&library, own, item_id))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(found))) => Ok(found),
|
||||
Ok(Ok(None)) => Err("track not found in the library".to_string()),
|
||||
Ok(Err(err)) => Err(format!("library lookup failed: {err:#}")),
|
||||
Err(err) => Err(format!("lookup task failed: {err}")),
|
||||
}
|
||||
}
|
||||
None => Err("malformed item_id".to_string()),
|
||||
};
|
||||
let served = match resolved {
|
||||
Ok(found) => found,
|
||||
Err(message) => return refuse(stream, message).await,
|
||||
};
|
||||
|
||||
let path = PathBuf::from(&served.file_path);
|
||||
let mut file = match tokio::fs::File::open(&path).await {
|
||||
Ok(file) => file,
|
||||
Err(err) => return refuse(stream, format!("audio file is not readable: {err}")).await,
|
||||
};
|
||||
let total_size = file.metadata().await?.len();
|
||||
let offset = request.offset.min(total_size);
|
||||
if offset > 0 {
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||
}
|
||||
|
||||
// Images ride between the header and the audio, when asked for.
|
||||
let (cover, artist_image) = if request.want_cover && !request.metadata_only {
|
||||
(
|
||||
load_cover(served.cover_path.as_deref()).await,
|
||||
load_cover(served.artist_image_path.as_deref()).await,
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioResponseHeader {
|
||||
ok: true,
|
||||
error: None,
|
||||
mime_type: guess_mime(&path).to_string(),
|
||||
total_size,
|
||||
offset,
|
||||
metadata: Some(served.metadata),
|
||||
cover_size: cover.as_ref().map_or(0, |(bytes, _)| bytes.len() as u64),
|
||||
cover_mime: cover
|
||||
.as_ref()
|
||||
.map(|(_, mime)| mime.to_string())
|
||||
.unwrap_or_default(),
|
||||
artist_image_size: artist_image
|
||||
.as_ref()
|
||||
.map_or(0, |(bytes, _)| bytes.len() as u64),
|
||||
artist_image_mime: artist_image
|
||||
.as_ref()
|
||||
.map(|(_, mime)| mime.to_string())
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if request.metadata_only {
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
return Ok(());
|
||||
}
|
||||
let cover_bytes = cover.as_ref().map_or(0, |(bytes, _)| bytes.len() as u64);
|
||||
let artist_image_bytes = artist_image
|
||||
.as_ref()
|
||||
.map_or(0, |(bytes, _)| bytes.len() as u64);
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
item = %request.item_id,
|
||||
audio_bytes = total_size.saturating_sub(offset),
|
||||
cover_bytes,
|
||||
artist_image_bytes,
|
||||
want_images = request.want_cover,
|
||||
"serving federated audio stream"
|
||||
);
|
||||
let started = Instant::now();
|
||||
if let Some((bytes, _)) = &cover {
|
||||
stream.send.write_all(bytes).await?;
|
||||
}
|
||||
if let Some((bytes, _)) = &artist_image {
|
||||
stream.send.write_all(bytes).await?;
|
||||
}
|
||||
let audio_sent = tokio::io::copy(&mut file, &mut stream.send).await?;
|
||||
stream.send.finish()?;
|
||||
// Wait until the peer read everything (or gave up) before dropping the
|
||||
// stream, otherwise the tail of the file is lost.
|
||||
let _ = stream.send.stopped().await;
|
||||
crate::federation::record_stream_transport(
|
||||
&transport_stats,
|
||||
"audio",
|
||||
"inbound",
|
||||
"done",
|
||||
&stream,
|
||||
);
|
||||
let elapsed = started.elapsed();
|
||||
let total_sent = cover_bytes + artist_image_bytes + audio_sent;
|
||||
let kib_per_sec = if elapsed.as_secs_f64() > 0.0 {
|
||||
total_sent as f64 / 1024.0 / elapsed.as_secs_f64()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
item = %request.item_id,
|
||||
audio_bytes = audio_sent,
|
||||
total_bytes = total_sent,
|
||||
elapsed_ms = elapsed.as_millis(),
|
||||
kib_per_sec,
|
||||
"served federated audio stream"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads a cover image from disk, skipping unreadable or oversized files.
|
||||
async fn load_cover(cover_path: Option<&str>) -> Option<(Vec<u8>, &'static str)> {
|
||||
let path = PathBuf::from(cover_path?);
|
||||
let size = tokio::fs::metadata(&path).await.ok()?.len();
|
||||
if size == 0 || size > MAX_COVER_BYTES {
|
||||
return None;
|
||||
}
|
||||
let bytes = tokio::fs::read(&path).await.ok()?;
|
||||
Some((bytes, image_mime(&path)))
|
||||
}
|
||||
|
||||
/// Sends a refusal header and waits until the peer read it.
|
||||
async fn refuse(mut stream: ByteStream, message: String) -> Result<()> {
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioResponseHeader {
|
||||
ok: false,
|
||||
error: Some(message.clone()),
|
||||
mime_type: String::new(),
|
||||
total_size: 0,
|
||||
offset: 0,
|
||||
metadata: None,
|
||||
cover_size: 0,
|
||||
cover_mime: String::new(),
|
||||
artist_image_size: 0,
|
||||
artist_image_mime: String::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
anyhow::bail!("refused audio request: {message}");
|
||||
}
|
||||
@@ -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,95 @@
|
||||
use super::*;
|
||||
|
||||
fn test_owner() -> EndpointId {
|
||||
music_dht::SecretKey::from_bytes(&[7; 32]).public()
|
||||
}
|
||||
|
||||
fn dht_track(main: &[&str], featured: &[&str]) -> LibraryItem {
|
||||
let owner = test_owner();
|
||||
LibraryItem {
|
||||
id: music_dht::ItemId::derive(&owner, ItemKind::Track, "track:1"),
|
||||
owner,
|
||||
kind: ItemKind::Track,
|
||||
name: "Guest Verse".into(),
|
||||
normalized_name: music_dht::normalize_name("Guest Verse"),
|
||||
artist_names: main.iter().map(|name| name.to_string()).collect(),
|
||||
featured_artist_names: featured.iter().map(|name| name.to_string()).collect(),
|
||||
year: Some(2024),
|
||||
release_type: Some("album".into()),
|
||||
release_title: Some("Host Album".into()),
|
||||
track_number: Some(2),
|
||||
disc_number: Some(1),
|
||||
duration_seconds: Some(180.0),
|
||||
content_id: Some(
|
||||
"b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
|
||||
),
|
||||
revision: 1,
|
||||
deleted: false,
|
||||
updated_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dht_appearance_requires_explicit_featured_artist() {
|
||||
let normalized = music_dht::normalize_name("Guest");
|
||||
assert!(dht_appearance_hit(&dht_track(&["Guest"], &[]), &normalized, "Guest").is_none());
|
||||
|
||||
let hit = dht_appearance_hit(&dht_track(&["Host"], &["Guest"]), &normalized, "Guest").unwrap();
|
||||
assert_eq!(hit.release_title, "Host Album");
|
||||
assert_eq!(hit.release_type, "album");
|
||||
assert_eq!(hit.year, Some(2024));
|
||||
assert_eq!(hit.track.artists, vec!["Host"]);
|
||||
assert_eq!(hit.track.featured_artists, vec!["Guest"]);
|
||||
assert_eq!(hit.track.track_number, Some(2));
|
||||
assert_eq!(hit.track.disc_number, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn federation_search_ranks_exact_names_first() {
|
||||
let normalized = music_dht::normalize_name("ежемесячные");
|
||||
let mut artists = vec![
|
||||
FedArtistHit {
|
||||
name: "Booker".into(),
|
||||
peers: 3,
|
||||
},
|
||||
FedArtistHit {
|
||||
name: "Ежемесячные".into(),
|
||||
peers: 1,
|
||||
},
|
||||
];
|
||||
let mut tracks = vec![
|
||||
FedTrack {
|
||||
item_id: "a".into(),
|
||||
owner: "peer-a".into(),
|
||||
own: false,
|
||||
title: "Гость".into(),
|
||||
artist_names: vec!["Other".into()],
|
||||
featured_artist_names: vec!["Ежемесячные".into()],
|
||||
year: None,
|
||||
duration_seconds: None,
|
||||
content_id: None,
|
||||
release_title: None,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
},
|
||||
FedTrack {
|
||||
item_id: "b".into(),
|
||||
owner: "peer-b".into(),
|
||||
own: false,
|
||||
title: "Ежемесячные".into(),
|
||||
artist_names: vec!["Other".into()],
|
||||
featured_artist_names: Vec::new(),
|
||||
year: None,
|
||||
duration_seconds: None,
|
||||
content_id: None,
|
||||
release_title: None,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
},
|
||||
];
|
||||
|
||||
rank_fed_search_results(&mut artists, &mut tracks, &normalized);
|
||||
|
||||
assert_eq!(artists[0].name, "Ежемесячные");
|
||||
assert_eq!(tracks[0].title, "Ежемесячные");
|
||||
}
|
||||
@@ -0,0 +1,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());
|
||||
}
|
||||
}
|
||||
+2753
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,509 @@
|
||||
use super::*;
|
||||
|
||||
fn test_library() -> Library {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
register_norm_function(&conn).unwrap();
|
||||
conn.execute_batch(SCHEMA).unwrap();
|
||||
Library {
|
||||
conn: Mutex::new(conn),
|
||||
db_path: std::env::temp_dir().join("furumi-test-library.db"),
|
||||
covers_dir: std::env::temp_dir().join("furumi-test-covers-unused"),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 {
|
||||
add_track_with_featured(lib, title, artist, &[], album)
|
||||
}
|
||||
|
||||
fn add_track_with_featured(
|
||||
lib: &Library,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
featured: &[&str],
|
||||
album: &str,
|
||||
) -> i64 {
|
||||
let import = import::TrackImport {
|
||||
release_type: None,
|
||||
file_path: format!("/music/{artist}/{album}/{title}.mp3"),
|
||||
title: title.to_string(),
|
||||
artists: vec![artist.to_string()],
|
||||
featured_artists: featured.iter().map(|name| (*name).to_string()).collect(),
|
||||
album_artists: vec![artist.to_string()],
|
||||
release_title: album.to_string(),
|
||||
year: Some(2020),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 60.0,
|
||||
audio_format: Some("mp3".into()),
|
||||
audio_bitrate: Some(320),
|
||||
audio_sample_rate: Some(44100),
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: Some(1),
|
||||
cover: None,
|
||||
};
|
||||
let id = import::upsert_track(lib, &import).unwrap().0;
|
||||
let content_id = format!("b3:{}", blake3::hash(import.file_path.as_bytes()).to_hex());
|
||||
lib.lock()
|
||||
.execute(
|
||||
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
|
||||
params![id, content_id],
|
||||
)
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn artist_filters(hide_featured_only: bool) -> crate::config::settings::LibraryFilters {
|
||||
crate::config::settings::LibraryFilters {
|
||||
hide_featured_only,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_stats_counts_library_rows_and_audio_bytes() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "One", "Artist", "First");
|
||||
add_track(&lib, "Two", "Artist", "Second");
|
||||
|
||||
let stats = lib.local_stats().unwrap();
|
||||
assert_eq!(stats.artist_count, 1);
|
||||
assert_eq!(stats.release_count, 2);
|
||||
assert_eq!(stats.track_count, 2);
|
||||
assert_eq!(stats.audio_bytes, 2);
|
||||
assert_eq!(stats.tracks_without_size, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artists_page_prioritizes_releases_then_tracks() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Solo", "Zed", "Zed Album");
|
||||
add_track_with_featured(&lib, "Guest One", "A Host", &["Guest"], "A Host Album");
|
||||
add_track_with_featured(&lib, "Guest Two", "B Host", &["Guest"], "B Host Album");
|
||||
|
||||
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
|
||||
let zed_pos = page
|
||||
.items
|
||||
.iter()
|
||||
.position(|artist| artist.name == "Zed")
|
||||
.unwrap();
|
||||
let guest_pos = page
|
||||
.items
|
||||
.iter()
|
||||
.position(|artist| artist.name == "Guest")
|
||||
.unwrap();
|
||||
let guest = &page.items[guest_pos];
|
||||
|
||||
assert_eq!(guest.release_count, 0);
|
||||
assert_eq!(guest.track_count, 2);
|
||||
assert!(zed_pos < guest_pos);
|
||||
|
||||
let filtered = lib.artists(1, 10, artist_filters(true)).unwrap();
|
||||
assert!(filtered.items.iter().all(|artist| artist.release_count > 0));
|
||||
assert!(!filtered.items.iter().any(|artist| artist.name == "Guest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_artist_image_hint_becomes_local_image_after_fetch() {
|
||||
let lib = test_library();
|
||||
let artist_key = music_dht::normalize_name("Remote Artist");
|
||||
lib.replace_network_artist_cache(
|
||||
"peer-a",
|
||||
"personal",
|
||||
&[NetworkArtistPreview {
|
||||
artist_key: artist_key.clone(),
|
||||
name: "Remote Artist".into(),
|
||||
image_path: Some("peer-local/image.jpg".into()),
|
||||
release_count: 1,
|
||||
track_count: 3,
|
||||
}],
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let filters = crate::config::settings::LibraryFilters {
|
||||
source_mode: crate::config::settings::LibrarySourceMode::My,
|
||||
..Default::default()
|
||||
};
|
||||
let page = lib.artists(1, 10, filters).unwrap();
|
||||
assert_eq!(page.items[0].image_path, None);
|
||||
|
||||
let requests = lib
|
||||
.network_artist_image_requests(filters, &["Remote Artist".into()], 8)
|
||||
.unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].source_id, "peer-a");
|
||||
assert_eq!(requests[0].artist_key, artist_key);
|
||||
|
||||
lib.set_network_artist_image("peer-a", &artist_key, "/tmp/remote-artist.jpg")
|
||||
.unwrap();
|
||||
let page = lib.artists(1, 10, filters).unwrap();
|
||||
assert_eq!(
|
||||
page.items[0].image_path.as_deref(),
|
||||
Some("/tmp/remote-artist.jpg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_creates_artist_release_track() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.items[0].name, "Artist");
|
||||
assert_eq!(page.items[0].track_count, 1);
|
||||
|
||||
let detail = lib.artist(page.items[0].id).unwrap();
|
||||
assert_eq!(detail.releases.len(), 1);
|
||||
assert_eq!(detail.top_tracks.len(), 1);
|
||||
|
||||
let release = lib.release(detail.releases[0].id).unwrap();
|
||||
assert_eq!(release.tracks.len(), 1);
|
||||
assert_eq!(release.tracks[0].id, track_id);
|
||||
assert_eq!(release.tracks[0].artists[0].name, "Artist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reimport_updates_instead_of_duplicating() {
|
||||
let lib = test_library();
|
||||
let first = add_track(&lib, "Song", "Artist", "Album");
|
||||
let second = add_track(&lib, "Song", "Artist", "Album");
|
||||
assert_eq!(first, second);
|
||||
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
|
||||
assert_eq!(page.items[0].track_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_id_backfill_hashes_missing_track_ids() {
|
||||
let lib = test_library();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"furumi-content-id-test-{}-{}.bin",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::write(&path, b"portable content id").unwrap();
|
||||
let file_path = path.to_string_lossy().into_owned();
|
||||
let import = import::TrackImport {
|
||||
release_type: None,
|
||||
file_path: file_path.clone(),
|
||||
title: "Portable".to_string(),
|
||||
artists: vec!["Artist".to_string()],
|
||||
featured_artists: Vec::new(),
|
||||
album_artists: vec!["Artist".to_string()],
|
||||
release_title: "Album".to_string(),
|
||||
year: Some(2026),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 60.0,
|
||||
audio_format: Some("bin".into()),
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: Some(19),
|
||||
cover: None,
|
||||
};
|
||||
let track_id = import::upsert_track(&lib, &import).unwrap().0;
|
||||
let expected = audio_content_id(&file_path).unwrap();
|
||||
{
|
||||
let conn = lib.lock();
|
||||
conn.execute(
|
||||
"UPDATE tracks SET content_id = NULL WHERE id = ?1",
|
||||
[track_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let stats = lib.backfill_missing_content_ids().unwrap();
|
||||
assert_eq!(stats.hashed, 1);
|
||||
assert_eq!(stats.updated(), 1);
|
||||
assert_eq!(
|
||||
lib.track_content_id_by_id(track_id).unwrap().as_deref(),
|
||||
Some(expected.as_str())
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_all_kinds() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Neon Lights", "Neon Artist", "Neon Album");
|
||||
let results = lib.search("neon", 10).unwrap();
|
||||
assert_eq!(results.artists.len(), 1);
|
||||
assert_eq!(results.releases.len(), 1);
|
||||
assert_eq!(results.tracks.len(), 1);
|
||||
// LIKE wildcards in the query must not match everything.
|
||||
assert_eq!(lib.search("%", 10).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_ranks_exact_names_first() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "A Needle", "A Needle Artist", "A Needle Album");
|
||||
add_track(&lib, "Needle", "Needle", "Needle");
|
||||
|
||||
let results = lib.search("needle", 10).unwrap();
|
||||
assert_eq!(results.artists[0].name, "Needle");
|
||||
assert_eq!(results.releases[0].title, "Needle");
|
||||
assert_eq!(results.tracks[0].title, "Needle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_folds_case_beyond_ascii() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Nothing Else Matters", "Металлика", "Чёрный альбом");
|
||||
// SQLite's LIKE/NOCASE only fold ASCII; norm() folds every script.
|
||||
assert_eq!(lib.search("металлика", 10).unwrap().artists.len(), 1);
|
||||
assert_eq!(lib.search("МЕТАЛЛИКА", 10).unwrap().artists.len(), 1);
|
||||
assert_eq!(lib.search("чёрный", 10).unwrap().releases.len(), 1);
|
||||
assert_eq!(lib.search("matters", 10).unwrap().tracks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlists_and_likes_round_trip() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
let playlist = lib.create_playlist("Mix").unwrap();
|
||||
lib.add_tracks_to_playlist(playlist.id, &[track_id])
|
||||
.unwrap();
|
||||
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1);
|
||||
|
||||
let content_id = lib.track_content_id_by_id(track_id).unwrap().unwrap();
|
||||
assert!(lib.toggle_like_by_content_id(&content_id).unwrap());
|
||||
assert_eq!(lib.liked_content_ids().unwrap(), vec![content_id.clone()]);
|
||||
assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1);
|
||||
assert!(!lib.toggle_like_by_content_id(&content_id).unwrap());
|
||||
|
||||
lib.remove_tracks_from_playlist(playlist.id, &[track_id])
|
||||
.unwrap();
|
||||
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0);
|
||||
lib.delete_playlist(playlist.id).unwrap();
|
||||
// Only the virtual Likes playlist remains.
|
||||
assert_eq!(lib.playlists().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn likes_playlist_orders_local_and_federated_by_liked_at() {
|
||||
let lib = test_library();
|
||||
let old_id = add_track(&lib, "Old Local", "Artist", "Album");
|
||||
let new_id = add_track(&lib, "New Local", "Artist", "Album");
|
||||
let old_content_id = lib.track_content_id_by_id(old_id).unwrap().unwrap();
|
||||
let new_content_id = lib.track_content_id_by_id(new_id).unwrap().unwrap();
|
||||
let content_id = format!("b3:{}", "c".repeat(64));
|
||||
let fed = crate::federation::FedTrack {
|
||||
item_id: "fed_item_order".to_string(),
|
||||
owner: "fed_owner_order".to_string(),
|
||||
own: false,
|
||||
title: "Middle Fed".to_string(),
|
||||
artist_names: vec!["Remote Artist".to_string()],
|
||||
featured_artist_names: Vec::new(),
|
||||
year: Some(2026),
|
||||
duration_seconds: Some(123),
|
||||
content_id: Some(content_id),
|
||||
release_title: Some("Remote Release".to_string()),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
};
|
||||
|
||||
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
|
||||
assert!(lib.toggle_like_by_content_id(&new_content_id).unwrap());
|
||||
assert!(lib.toggle_fed_like(&fed).unwrap());
|
||||
{
|
||||
let conn = lib.lock();
|
||||
conn.execute(
|
||||
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
|
||||
params![old_id, "2026-01-01 00:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
|
||||
params![new_id, "2026-01-02 00:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE fed_likes SET liked_at = ?2 WHERE item_id = ?1",
|
||||
params![fed.item_id, "2026-01-03 00:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let titles: Vec<String> = lib
|
||||
.playlist(LIKES_PLAYLIST_ID)
|
||||
.unwrap()
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| track.title)
|
||||
.collect();
|
||||
assert_eq!(titles, vec!["Middle Fed", "New Local", "Old Local"]);
|
||||
|
||||
assert!(!lib.toggle_like_by_content_id(&old_content_id).unwrap());
|
||||
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
|
||||
{
|
||||
let conn = lib.lock();
|
||||
conn.execute(
|
||||
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
|
||||
params![old_id, "2026-01-04 00:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let titles: Vec<String> = lib
|
||||
.playlist(LIKES_PLAYLIST_ID)
|
||||
.unwrap()
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| track.title)
|
||||
.collect();
|
||||
assert_eq!(titles, vec!["Old Local", "Middle Fed", "New Local"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_playlist_can_show_federated_pending_tracks() {
|
||||
let lib = test_library();
|
||||
let playlist = lib.create_playlist("Remote Mix").unwrap();
|
||||
let sync_id = lib.ensure_playlist_sync_id(playlist.id).unwrap();
|
||||
let content_id = format!("b3:{}", "a".repeat(64));
|
||||
let fed = crate::federation::FedTrack {
|
||||
item_id: "fed_item_1".to_string(),
|
||||
owner: "fed_owner_1".to_string(),
|
||||
own: false,
|
||||
title: "Remote Song".to_string(),
|
||||
artist_names: vec!["Remote Artist".to_string()],
|
||||
featured_artist_names: vec!["Remote Guest".to_string()],
|
||||
year: Some(2026),
|
||||
duration_seconds: Some(123),
|
||||
content_id: Some(content_id.clone()),
|
||||
release_title: Some("Remote Release".to_string()),
|
||||
track_number: Some(2),
|
||||
disc_number: Some(1),
|
||||
};
|
||||
|
||||
assert!(lib.upsert_fed_playlist_track(&sync_id, &fed, 4).unwrap());
|
||||
assert!(
|
||||
lib.has_playlist_content_reference(&sync_id, &content_id)
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let detail = lib.playlist(playlist.id).unwrap();
|
||||
assert_eq!(detail.tracks.len(), 1);
|
||||
let track = &detail.tracks[0];
|
||||
assert!(track.is_fed_pending());
|
||||
assert_eq!(track.title, "Remote Song");
|
||||
assert_eq!(track.artist_line(), "Remote Artist feat. Remote Guest");
|
||||
assert_eq!(track.release_title, "Remote Release");
|
||||
assert_eq!(track.content_id.as_deref(), Some(content_id.as_str()));
|
||||
|
||||
let card = lib
|
||||
.playlists()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|card| card.id == playlist.id)
|
||||
.unwrap();
|
||||
assert_eq!(card.track_count, 1);
|
||||
|
||||
lib.remove_content_ids_from_playlist(playlist.id, std::slice::from_ref(&content_id))
|
||||
.unwrap();
|
||||
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0);
|
||||
assert!(
|
||||
lib.fed_playlist_track_by_content_id(&sync_id, &content_id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_federated_pending_track_to_playlist_records_position() {
|
||||
let lib = test_library();
|
||||
let local_id = add_track(&lib, "Local Song", "Artist", "Album");
|
||||
let playlist = lib.create_playlist("Remote Mix").unwrap();
|
||||
let content_id = format!("b3:{}", "b".repeat(64));
|
||||
let fed = crate::federation::FedTrack {
|
||||
item_id: "fed_item_2".to_string(),
|
||||
owner: "fed_owner_2".to_string(),
|
||||
own: false,
|
||||
title: "Remote Song".to_string(),
|
||||
artist_names: vec!["Remote Artist".to_string()],
|
||||
featured_artist_names: Vec::new(),
|
||||
year: Some(2026),
|
||||
duration_seconds: Some(123),
|
||||
content_id: Some(content_id.clone()),
|
||||
release_title: Some("Remote Release".to_string()),
|
||||
track_number: Some(2),
|
||||
disc_number: Some(1),
|
||||
};
|
||||
|
||||
lib.add_tracks_to_playlist(playlist.id, &[local_id])
|
||||
.unwrap();
|
||||
lib.add_fed_tracks_to_playlist(playlist.id, std::slice::from_ref(&fed))
|
||||
.unwrap();
|
||||
|
||||
let position = lib
|
||||
.playlist_content_position(playlist.id, &content_id)
|
||||
.unwrap();
|
||||
assert_eq!(position, Some(1));
|
||||
let detail = lib.playlist(playlist.id).unwrap();
|
||||
assert_eq!(
|
||||
detail
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| track.title)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Local Song", "Remote Song"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_edit_relinks_artists() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
lib.update_track(
|
||||
track_id,
|
||||
&TrackEdit {
|
||||
title: "Renamed".into(),
|
||||
artists: vec!["Other".into()],
|
||||
featured_artists: vec!["Guest".into()],
|
||||
track_number: Some(2),
|
||||
disc_number: None,
|
||||
cover_path: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
||||
assert_eq!(track.title, "Renamed");
|
||||
assert_eq!(track.artists[0].name, "Other");
|
||||
assert_eq!(track.featured_artists[0].name, "Guest");
|
||||
assert_eq!(track.track_number, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_artist_cleans_up_own_content() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Song", "Solo", "Solo Album");
|
||||
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
|
||||
lib.delete_artist(page.items[0].id).unwrap();
|
||||
assert_eq!(lib.artists(1, 10, artist_filters(false)).unwrap().total, 0);
|
||||
assert_eq!(lib.search("Song", 10).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_track_drops_empty_release() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Only", "Artist", "Album");
|
||||
lib.delete_track(track_id).unwrap();
|
||||
let detail = lib
|
||||
.artist(lib.artists(1, 10, artist_filters(false)).unwrap().items[0].id)
|
||||
.unwrap();
|
||||
assert!(detail.releases.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_counts_completed_plays() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
lib.add_history(track_id, None, 60, true).unwrap();
|
||||
lib.add_history(track_id, None, 10, false).unwrap();
|
||||
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
||||
assert_eq!(track.play_count, 1);
|
||||
}
|
||||
+47
-2
@@ -1,10 +1,15 @@
|
||||
mod api;
|
||||
mod app;
|
||||
mod art;
|
||||
mod config;
|
||||
mod devices;
|
||||
mod federation;
|
||||
mod library;
|
||||
mod media;
|
||||
mod player;
|
||||
mod share;
|
||||
mod streaming;
|
||||
mod ui;
|
||||
mod visualizer;
|
||||
|
||||
use std::io;
|
||||
|
||||
@@ -15,6 +20,11 @@ use crossterm::event::{
|
||||
};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
if std::env::args_os().any(|arg| arg == "--version" || arg == "-V") {
|
||||
println!("furumi {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut startup_warning = None;
|
||||
if let Err(err) = config::logging::init() {
|
||||
startup_warning = Some(format!("logging disabled: {err:#}"));
|
||||
@@ -122,7 +132,42 @@ fn capture_stderr() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
fn capture_stderr() {
|
||||
use std::io::BufRead as _;
|
||||
use std::os::windows::io::FromRawHandle as _;
|
||||
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Console::{STD_ERROR_HANDLE, SetStdHandle};
|
||||
use windows_sys::Win32::System::Pipes::CreatePipe;
|
||||
|
||||
unsafe {
|
||||
let mut read = core::ptr::null_mut();
|
||||
let mut write = core::ptr::null_mut();
|
||||
if CreatePipe(&mut read, &mut write, core::ptr::null(), 0) == 0 {
|
||||
return;
|
||||
}
|
||||
if SetStdHandle(STD_ERROR_HANDLE, write) == 0 {
|
||||
CloseHandle(read);
|
||||
CloseHandle(write);
|
||||
return;
|
||||
}
|
||||
let reader = std::fs::File::from_raw_handle(read);
|
||||
std::thread::Builder::new()
|
||||
.name("stderr".to_string())
|
||||
.spawn(move || {
|
||||
for line in std::io::BufReader::new(reader).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if !line.trim().is_empty() {
|
||||
tracing::warn!(target: "stderr", "{line}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn capture_stderr() {}
|
||||
|
||||
/// Kitty keyboard protocol, where supported, disambiguates Esc from alt-keys
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI16, AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use rodio::source::SeekError;
|
||||
use rodio::{ChannelCount, Sample, SampleRate, Source};
|
||||
|
||||
const LEVEL_SCALE: f32 = 1_000_000.0;
|
||||
const SCOPE_SAMPLES: usize = 256;
|
||||
const SCOPE_SAMPLE_SCALE: f32 = i16::MAX as f32;
|
||||
const TARGET_ANALYSIS_HZ: f32 = 30.0;
|
||||
const TARGET_SCOPE_HZ: f32 = 240.0;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AudioAnalysisSnapshot {
|
||||
pub sequence: u64,
|
||||
pub energy: f64,
|
||||
pub bass: f64,
|
||||
pub mid: f64,
|
||||
pub treble: f64,
|
||||
pub beat: f64,
|
||||
pub scope: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AnalyzerShared {
|
||||
sequence: AtomicU64,
|
||||
energy: AtomicU32,
|
||||
bass: AtomicU32,
|
||||
mid: AtomicU32,
|
||||
treble: AtomicU32,
|
||||
beat: AtomicU32,
|
||||
scope_write: AtomicUsize,
|
||||
scope: Box<[AtomicI16]>,
|
||||
}
|
||||
|
||||
impl Default for AnalyzerShared {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sequence: AtomicU64::new(0),
|
||||
energy: AtomicU32::new(0),
|
||||
bass: AtomicU32::new(0),
|
||||
mid: AtomicU32::new(0),
|
||||
treble: AtomicU32::new(0),
|
||||
beat: AtomicU32::new(0),
|
||||
scope_write: AtomicUsize::new(0),
|
||||
scope: (0..SCOPE_SAMPLES)
|
||||
.map(|_| AtomicI16::new(0))
|
||||
.collect::<Vec<_>>()
|
||||
.into_boxed_slice(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AnalyzerShared {
|
||||
pub fn clear(&self) {
|
||||
self.energy.store(0, Ordering::Relaxed);
|
||||
self.bass.store(0, Ordering::Relaxed);
|
||||
self.mid.store(0, Ordering::Relaxed);
|
||||
self.treble.store(0, Ordering::Relaxed);
|
||||
self.beat.store(0, Ordering::Relaxed);
|
||||
self.scope_write.store(0, Ordering::Relaxed);
|
||||
for sample in self.scope.iter() {
|
||||
sample.store(0, Ordering::Relaxed);
|
||||
}
|
||||
self.sequence.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> AudioAnalysisSnapshot {
|
||||
let write = self.scope_write.load(Ordering::Relaxed);
|
||||
let len = self.scope.len().max(1);
|
||||
let scope = (0..self.scope.len())
|
||||
.map(|offset| {
|
||||
let index = (write + offset) % len;
|
||||
f64::from(self.scope[index].load(Ordering::Relaxed)) / f64::from(i16::MAX)
|
||||
})
|
||||
.collect();
|
||||
|
||||
AudioAnalysisSnapshot {
|
||||
sequence: self.sequence.load(Ordering::Relaxed),
|
||||
energy: load_norm(&self.energy),
|
||||
bass: load_norm(&self.bass),
|
||||
mid: load_norm(&self.mid),
|
||||
treble: load_norm(&self.treble),
|
||||
beat: load_norm(&self.beat),
|
||||
scope,
|
||||
}
|
||||
}
|
||||
|
||||
fn store_levels(&self, energy: f32, bass: f32, mid: f32, treble: f32, beat: f32) {
|
||||
store_norm(&self.energy, energy);
|
||||
store_norm(&self.bass, bass);
|
||||
store_norm(&self.mid, mid);
|
||||
store_norm(&self.treble, treble);
|
||||
store_norm(&self.beat, beat);
|
||||
self.sequence.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn push_scope(&self, sample: f32) {
|
||||
let index = self.scope_write.fetch_add(1, Ordering::Relaxed) % self.scope.len();
|
||||
let value = (sample.clamp(-1.0, 1.0) * SCOPE_SAMPLE_SCALE).round() as i16;
|
||||
self.scope[index].store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AnalyzedSource<S> {
|
||||
input: S,
|
||||
shared: Arc<AnalyzerShared>,
|
||||
state: AnalyzerState,
|
||||
}
|
||||
|
||||
impl<S> AnalyzedSource<S>
|
||||
where
|
||||
S: Source,
|
||||
{
|
||||
pub fn new(input: S, shared: Arc<AnalyzerShared>) -> Self {
|
||||
let channels = input.channels();
|
||||
let sample_rate = input.sample_rate();
|
||||
Self {
|
||||
input,
|
||||
shared,
|
||||
state: AnalyzerState::new(channels, sample_rate),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Iterator for AnalyzedSource<S>
|
||||
where
|
||||
S: Source,
|
||||
{
|
||||
type Item = Sample;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let sample = self.input.next()?;
|
||||
self.state
|
||||
.ensure_format(self.input.channels(), self.input.sample_rate());
|
||||
self.state.accept_sample(sample as f32, &self.shared);
|
||||
Some(sample)
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
self.input.size_hint()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Source for AnalyzedSource<S>
|
||||
where
|
||||
S: Source,
|
||||
{
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
self.input.current_span_len()
|
||||
}
|
||||
|
||||
fn channels(&self) -> ChannelCount {
|
||||
self.input.channels()
|
||||
}
|
||||
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
self.input.sample_rate()
|
||||
}
|
||||
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
self.input.total_duration()
|
||||
}
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
|
||||
let result = self.input.try_seek(pos);
|
||||
if result.is_ok() {
|
||||
self.shared.clear();
|
||||
self.state.reset_filters();
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AnalyzerState {
|
||||
channels: usize,
|
||||
sample_rate: f32,
|
||||
bass_alpha: f32,
|
||||
mid_alpha: f32,
|
||||
channel_index: usize,
|
||||
frame_sum: f32,
|
||||
frame_count: usize,
|
||||
window_frames: usize,
|
||||
scope_counter: usize,
|
||||
scope_stride: usize,
|
||||
low_bass: f32,
|
||||
low_mid: f32,
|
||||
full_sq: f32,
|
||||
bass_sq: f32,
|
||||
mid_sq: f32,
|
||||
treble_sq: f32,
|
||||
slow_energy: f32,
|
||||
smooth_energy: f32,
|
||||
smooth_bass: f32,
|
||||
smooth_mid: f32,
|
||||
smooth_treble: f32,
|
||||
smooth_beat: f32,
|
||||
}
|
||||
|
||||
impl AnalyzerState {
|
||||
fn new(channels: ChannelCount, sample_rate: SampleRate) -> Self {
|
||||
let mut state = Self {
|
||||
channels: channels.get() as usize,
|
||||
sample_rate: sample_rate.get() as f32,
|
||||
bass_alpha: 0.0,
|
||||
mid_alpha: 0.0,
|
||||
channel_index: 0,
|
||||
frame_sum: 0.0,
|
||||
frame_count: 0,
|
||||
window_frames: 0,
|
||||
scope_counter: 0,
|
||||
scope_stride: 0,
|
||||
low_bass: 0.0,
|
||||
low_mid: 0.0,
|
||||
full_sq: 0.0,
|
||||
bass_sq: 0.0,
|
||||
mid_sq: 0.0,
|
||||
treble_sq: 0.0,
|
||||
slow_energy: 0.0,
|
||||
smooth_energy: 0.0,
|
||||
smooth_bass: 0.0,
|
||||
smooth_mid: 0.0,
|
||||
smooth_treble: 0.0,
|
||||
smooth_beat: 0.0,
|
||||
};
|
||||
state.configure();
|
||||
state
|
||||
}
|
||||
|
||||
fn ensure_format(&mut self, channels: ChannelCount, sample_rate: SampleRate) {
|
||||
let channels = channels.get() as usize;
|
||||
let sample_rate = sample_rate.get() as f32;
|
||||
if self.channels != channels || (self.sample_rate - sample_rate).abs() >= 1.0 {
|
||||
self.channels = channels;
|
||||
self.sample_rate = sample_rate;
|
||||
self.configure();
|
||||
self.reset_filters();
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(&mut self) {
|
||||
self.channels = self.channels.max(1);
|
||||
self.sample_rate = self.sample_rate.max(1.0);
|
||||
self.bass_alpha = lowpass_alpha(180.0, self.sample_rate);
|
||||
self.mid_alpha = lowpass_alpha(2_400.0, self.sample_rate);
|
||||
self.window_frames = (self.sample_rate / TARGET_ANALYSIS_HZ).round().max(256.0) as usize;
|
||||
self.scope_stride = (self.sample_rate / TARGET_SCOPE_HZ).round().max(1.0) as usize;
|
||||
}
|
||||
|
||||
fn reset_filters(&mut self) {
|
||||
self.channel_index = 0;
|
||||
self.frame_sum = 0.0;
|
||||
self.frame_count = 0;
|
||||
self.scope_counter = 0;
|
||||
self.low_bass = 0.0;
|
||||
self.low_mid = 0.0;
|
||||
self.full_sq = 0.0;
|
||||
self.bass_sq = 0.0;
|
||||
self.mid_sq = 0.0;
|
||||
self.treble_sq = 0.0;
|
||||
self.slow_energy = 0.0;
|
||||
self.smooth_energy = 0.0;
|
||||
self.smooth_bass = 0.0;
|
||||
self.smooth_mid = 0.0;
|
||||
self.smooth_treble = 0.0;
|
||||
self.smooth_beat = 0.0;
|
||||
}
|
||||
|
||||
fn accept_sample(&mut self, sample: f32, shared: &AnalyzerShared) {
|
||||
let sample = if sample.is_finite() {
|
||||
sample.clamp(-1.5, 1.5)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.frame_sum += sample;
|
||||
self.channel_index += 1;
|
||||
if self.channel_index < self.channels {
|
||||
return;
|
||||
}
|
||||
|
||||
let mono = self.frame_sum / self.channels as f32;
|
||||
self.channel_index = 0;
|
||||
self.frame_sum = 0.0;
|
||||
self.accept_frame(mono, shared);
|
||||
}
|
||||
|
||||
fn accept_frame(&mut self, sample: f32, shared: &AnalyzerShared) {
|
||||
self.low_bass += self.bass_alpha * (sample - self.low_bass);
|
||||
self.low_mid += self.mid_alpha * (sample - self.low_mid);
|
||||
|
||||
let bass = self.low_bass;
|
||||
let mid = self.low_mid - self.low_bass;
|
||||
let treble = sample - self.low_mid;
|
||||
|
||||
self.full_sq += sample * sample;
|
||||
self.bass_sq += bass * bass;
|
||||
self.mid_sq += mid * mid;
|
||||
self.treble_sq += treble * treble;
|
||||
self.frame_count += 1;
|
||||
|
||||
self.scope_counter += 1;
|
||||
if self.scope_counter >= self.scope_stride {
|
||||
self.scope_counter = 0;
|
||||
shared.push_scope(sample);
|
||||
}
|
||||
|
||||
if self.frame_count >= self.window_frames {
|
||||
self.publish_window(shared);
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_window(&mut self, shared: &AnalyzerShared) {
|
||||
let frames = self.frame_count.max(1) as f32;
|
||||
let energy = compress_rms((self.full_sq / frames).sqrt(), 7.5);
|
||||
let bass = compress_rms((self.bass_sq / frames).sqrt(), 11.0);
|
||||
let mid = compress_rms((self.mid_sq / frames).sqrt(), 15.0);
|
||||
let treble = compress_rms((self.treble_sq / frames).sqrt(), 22.0);
|
||||
|
||||
if self.slow_energy == 0.0 {
|
||||
self.slow_energy = energy;
|
||||
} else {
|
||||
self.slow_energy = self.slow_energy * 0.94 + energy * 0.06;
|
||||
}
|
||||
let beat_raw = ((energy - self.slow_energy * 1.18) * 5.5).clamp(0.0, 1.0);
|
||||
|
||||
self.smooth_energy = smooth_level(self.smooth_energy, energy, 0.35, 0.82);
|
||||
self.smooth_bass = smooth_level(self.smooth_bass, bass, 0.30, 0.80);
|
||||
self.smooth_mid = smooth_level(self.smooth_mid, mid, 0.35, 0.82);
|
||||
self.smooth_treble = smooth_level(self.smooth_treble, treble, 0.28, 0.76);
|
||||
self.smooth_beat = smooth_level(self.smooth_beat, beat_raw, 0.18, 0.70);
|
||||
|
||||
shared.store_levels(
|
||||
self.smooth_energy,
|
||||
self.smooth_bass,
|
||||
self.smooth_mid,
|
||||
self.smooth_treble,
|
||||
self.smooth_beat,
|
||||
);
|
||||
|
||||
self.full_sq = 0.0;
|
||||
self.bass_sq = 0.0;
|
||||
self.mid_sq = 0.0;
|
||||
self.treble_sq = 0.0;
|
||||
self.frame_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn lowpass_alpha(cutoff_hz: f32, sample_rate: f32) -> f32 {
|
||||
1.0 - (-std::f32::consts::TAU * cutoff_hz / sample_rate.max(1.0)).exp()
|
||||
}
|
||||
|
||||
fn compress_rms(rms: f32, scale: f32) -> f32 {
|
||||
(1.0 - (-rms.max(0.0) * scale).exp()).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn smooth_level(previous: f32, next: f32, attack: f32, release: f32) -> f32 {
|
||||
let keep = if next > previous { attack } else { release };
|
||||
previous * keep + next * (1.0 - keep)
|
||||
}
|
||||
|
||||
fn store_norm(target: &AtomicU32, value: f32) {
|
||||
target.store(
|
||||
(value.clamp(0.0, 1.0) * LEVEL_SCALE).round() as u32,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
fn load_norm(source: &AtomicU32) -> f64 {
|
||||
f64::from(source.load(Ordering::Relaxed)) / f64::from(LEVEL_SCALE)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestSource {
|
||||
samples: Vec<Sample>,
|
||||
cursor: usize,
|
||||
channels: ChannelCount,
|
||||
sample_rate: SampleRate,
|
||||
}
|
||||
|
||||
impl TestSource {
|
||||
fn sine(frames: usize, channels: u16, sample_rate: u32, frequency: f32) -> Self {
|
||||
let channels_count = channels.max(1);
|
||||
let mut samples = Vec::with_capacity(frames * usize::from(channels_count));
|
||||
for frame in 0..frames {
|
||||
let time = frame as f32 / sample_rate as f32;
|
||||
let sample = (std::f32::consts::TAU * frequency * time).sin() * 0.5;
|
||||
for _ in 0..channels_count {
|
||||
samples.push(sample as Sample);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
samples,
|
||||
cursor: 0,
|
||||
channels: ChannelCount::new(channels_count).unwrap(),
|
||||
sample_rate: SampleRate::new(sample_rate).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for TestSource {
|
||||
type Item = Sample;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let sample = self.samples.get(self.cursor).copied()?;
|
||||
self.cursor += 1;
|
||||
Some(sample)
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let remaining = self.samples.len().saturating_sub(self.cursor);
|
||||
(remaining, Some(remaining))
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for TestSource {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.samples.len().saturating_sub(self.cursor))
|
||||
}
|
||||
|
||||
fn channels(&self) -> ChannelCount {
|
||||
self.channels
|
||||
}
|
||||
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
let frames = self.samples.len() / usize::from(self.channels.get());
|
||||
Some(Duration::from_secs_f64(
|
||||
frames as f64 / f64::from(self.sample_rate.get()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyzed_source_publishes_levels_and_scope() {
|
||||
let shared = Arc::new(AnalyzerShared::default());
|
||||
let source = TestSource::sine(4_096, 2, 48_000, 110.0);
|
||||
let analyzed = AnalyzedSource::new(source, Arc::clone(&shared));
|
||||
|
||||
for _ in analyzed {}
|
||||
|
||||
let snapshot = shared.snapshot();
|
||||
assert!(snapshot.sequence > 0);
|
||||
assert!(snapshot.energy > 0.0);
|
||||
assert!(snapshot.bass > 0.0);
|
||||
assert!(snapshot.scope.iter().any(|sample| sample.abs() > 0.001));
|
||||
}
|
||||
}
|
||||
+60
-33
@@ -4,16 +4,23 @@
|
||||
//! through the callback given to `spawn` — this module knows nothing about
|
||||
//! the UI or app state.
|
||||
|
||||
mod analyzer;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
||||
use std::time::Duration;
|
||||
|
||||
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
|
||||
use stream_download::StreamDownload;
|
||||
use stream_download::storage::temp::TempStorageProvider;
|
||||
|
||||
pub type TrackReader = StreamDownload<TempStorageProvider>;
|
||||
pub use analyzer::AudioAnalysisSnapshot;
|
||||
|
||||
pub trait TrackReadSeek: std::io::Read + std::io::Seek + Send + Sync {}
|
||||
|
||||
impl<T> TrackReadSeek for T where T: std::io::Read + std::io::Seek + Send + Sync {}
|
||||
|
||||
/// Playback sources are either ordinary files or growing streaming cache files.
|
||||
pub type TrackReader = Box<dyn TrackReadSeek>;
|
||||
|
||||
/// Perceptual volume: cubic mapping from percent to linear amplitude, so
|
||||
/// equal percent steps sound like equal loudness steps and low percentages
|
||||
@@ -25,6 +32,8 @@ pub fn amplitude(percent: u8) -> f32 {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PlayerEvent {
|
||||
/// A newly requested source was decoded and handed to rodio.
|
||||
Started,
|
||||
/// A track played to its end. `has_next` is true when a prefetched
|
||||
/// source was already queued and is now playing gaplessly.
|
||||
TrackFinished {
|
||||
@@ -35,17 +44,18 @@ pub enum PlayerEvent {
|
||||
|
||||
enum Command {
|
||||
Play {
|
||||
reader: Box<TrackReader>,
|
||||
reader: TrackReader,
|
||||
byte_len: Option<u64>,
|
||||
mime_type: Option<String>,
|
||||
seekable: bool,
|
||||
volume: f32,
|
||||
},
|
||||
/// Append the next track behind the current one without interrupting
|
||||
/// playback — rodio switches sources back to back (gapless-ish).
|
||||
Enqueue {
|
||||
reader: Box<TrackReader>,
|
||||
reader: TrackReader,
|
||||
byte_len: Option<u64>,
|
||||
},
|
||||
TogglePause,
|
||||
Pause,
|
||||
Resume,
|
||||
Stop,
|
||||
@@ -58,6 +68,7 @@ enum Command {
|
||||
pub struct Shared {
|
||||
position_ms: AtomicU64,
|
||||
paused: AtomicBool,
|
||||
analysis: Arc<analyzer::AnalyzerShared>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
@@ -68,6 +79,10 @@ impl Shared {
|
||||
pub fn paused(&self) -> bool {
|
||||
self.paused.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn audio_analysis(&self) -> AudioAnalysisSnapshot {
|
||||
self.analysis.snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -79,21 +94,26 @@ pub struct Controller {
|
||||
impl Controller {
|
||||
pub fn play(&self, reader: TrackReader, byte_len: Option<u64>, volume: f32) {
|
||||
let _ = self.tx.send(Command::Play {
|
||||
reader: Box::new(reader),
|
||||
reader,
|
||||
byte_len,
|
||||
mime_type: None,
|
||||
seekable: true,
|
||||
volume,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn play_stream(&self, reader: TrackReader, mime_type: Option<String>, volume: f32) {
|
||||
let _ = self.tx.send(Command::Play {
|
||||
reader,
|
||||
byte_len: None,
|
||||
mime_type,
|
||||
seekable: false,
|
||||
volume,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn enqueue(&self, reader: TrackReader, byte_len: Option<u64>) {
|
||||
let _ = self.tx.send(Command::Enqueue {
|
||||
reader: Box::new(reader),
|
||||
byte_len,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn toggle_pause(&self) {
|
||||
let _ = self.tx.send(Command::TogglePause);
|
||||
let _ = self.tx.send(Command::Enqueue { reader, byte_len });
|
||||
}
|
||||
|
||||
pub fn pause(&self) {
|
||||
@@ -144,7 +164,7 @@ fn run(rx: Receiver<Command>, shared: Arc<Shared>, on_event: impl Fn(PlayerEvent
|
||||
Ok(command) => {
|
||||
// Commands change the source queue legitimately; resync the
|
||||
// length so the next tick doesn't read it as a track ending.
|
||||
handle(command, &mut output, &mut track_loaded, &on_event);
|
||||
handle(command, &shared, &mut output, &mut track_loaded, &on_event);
|
||||
last_len = output.as_ref().map_or(0, |out| out.player.len());
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
@@ -174,6 +194,7 @@ fn run(rx: Receiver<Command>, shared: Arc<Shared>, on_event: impl Fn(PlayerEvent
|
||||
|
||||
fn handle(
|
||||
command: Command,
|
||||
shared: &Arc<Shared>,
|
||||
output: &mut Option<Output>,
|
||||
track_loaded: &mut bool,
|
||||
on_event: &impl Fn(PlayerEvent),
|
||||
@@ -182,6 +203,8 @@ fn handle(
|
||||
Command::Play {
|
||||
reader,
|
||||
byte_len,
|
||||
mime_type,
|
||||
seekable,
|
||||
volume,
|
||||
} => {
|
||||
// The device is opened lazily on first playback so the app works
|
||||
@@ -205,18 +228,26 @@ fn handle(
|
||||
|
||||
let mut builder = Decoder::builder()
|
||||
.with_data(reader)
|
||||
.with_seekable(true)
|
||||
.with_seekable(seekable)
|
||||
.with_gapless(true);
|
||||
if let Some(len) = byte_len {
|
||||
builder = builder.with_byte_len(len);
|
||||
}
|
||||
if let Some(mime_type) = mime_type.as_deref() {
|
||||
builder = builder.with_mime_type(mime_type);
|
||||
}
|
||||
match builder.build() {
|
||||
Ok(decoder) => {
|
||||
shared.analysis.clear();
|
||||
out.player.stop();
|
||||
out.player.set_volume(volume);
|
||||
out.player.append(decoder);
|
||||
out.player.append(analyzer::AnalyzedSource::new(
|
||||
decoder,
|
||||
Arc::clone(&shared.analysis),
|
||||
));
|
||||
out.player.play();
|
||||
*track_loaded = true;
|
||||
on_event(PlayerEvent::Started);
|
||||
}
|
||||
Err(err) => {
|
||||
on_event(PlayerEvent::Failed(format!("cannot decode track: {err}")));
|
||||
@@ -235,7 +266,10 @@ fn handle(
|
||||
builder = builder.with_byte_len(len);
|
||||
}
|
||||
match builder.build() {
|
||||
Ok(decoder) => out.player.append(decoder),
|
||||
Ok(decoder) => out.player.append(analyzer::AnalyzedSource::new(
|
||||
decoder,
|
||||
Arc::clone(&shared.analysis),
|
||||
)),
|
||||
Err(err) => {
|
||||
on_event(PlayerEvent::Failed(format!(
|
||||
"cannot decode next track: {err}"
|
||||
@@ -243,15 +277,6 @@ fn handle(
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::TogglePause => {
|
||||
if let Some(out) = output {
|
||||
if out.player.is_paused() {
|
||||
out.player.play();
|
||||
} else {
|
||||
out.player.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Pause => {
|
||||
if let Some(out) = output {
|
||||
out.player.pause();
|
||||
@@ -263,17 +288,19 @@ fn handle(
|
||||
}
|
||||
}
|
||||
Command::Stop => {
|
||||
if let Some(out) = output {
|
||||
if let Some(out) = output.take() {
|
||||
out.player.stop();
|
||||
}
|
||||
shared.analysis.clear();
|
||||
*track_loaded = false;
|
||||
}
|
||||
Command::Seek(position) => {
|
||||
if let Some(out) = output {
|
||||
if let Err(err) = out.player.try_seek(position) {
|
||||
tracing::warn!(%err, "seek failed");
|
||||
}
|
||||
if let Some(out) = output
|
||||
&& let Err(err) = out.player.try_seek(position)
|
||||
{
|
||||
tracing::warn!(%err, "seek failed");
|
||||
}
|
||||
shared.analysis.clear();
|
||||
}
|
||||
Command::SetVolume(volume) => {
|
||||
if let Some(out) = output {
|
||||
|
||||
+243
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
+814
-76
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()),
|
||||
}
|
||||
|
||||
+296
-122
@@ -1,6 +1,6 @@
|
||||
pub mod art;
|
||||
mod federation;
|
||||
mod global;
|
||||
mod login;
|
||||
mod logs;
|
||||
mod playlists;
|
||||
mod popup;
|
||||
@@ -12,14 +12,41 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
|
||||
|
||||
use crate::app::state::{AppState, Screen, Tab};
|
||||
use crate::app::input::LineEdit;
|
||||
use crate::app::state::{AppState, GlobalView, Loadable, Tab, TrackSelectionScope};
|
||||
use crate::config::keymap::Keymap;
|
||||
use crate::library::models::Availability;
|
||||
|
||||
pub(crate) fn availability_marker(
|
||||
availability: Availability,
|
||||
selected: bool,
|
||||
) -> (&'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()),
|
||||
};
|
||||
if selected {
|
||||
(label, theme::tab_active())
|
||||
} else {
|
||||
(label, style)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn availability_prefix(availability: Availability) -> Span<'static> {
|
||||
let (label, style) = availability_marker(availability, false);
|
||||
Span::styled(format!("{label} "), 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 +59,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 +128,7 @@ fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let tabs = Tabs::new(titles)
|
||||
.select(state.active_tab.index())
|
||||
.style(theme::dim())
|
||||
.highlight_style(theme::tab_active())
|
||||
.highlight_style(theme::tab_active_for(state))
|
||||
.divider("");
|
||||
frame.render_widget(tabs, area);
|
||||
}
|
||||
@@ -60,18 +139,54 @@ pub(crate) fn track_row(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
track: &crate::api::models::TrackItem,
|
||||
track: &crate::library::models::TrackItem,
|
||||
index_label: String,
|
||||
selected: bool,
|
||||
visual_selected: bool,
|
||||
) {
|
||||
let heart = if state.likes.contains(&track.id) {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
track_row_with_like_marker(
|
||||
frame,
|
||||
area,
|
||||
state,
|
||||
track,
|
||||
index_label,
|
||||
selected,
|
||||
visual_selected,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn track_row_with_like_marker(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
track: &crate::library::models::TrackItem,
|
||||
index_label: String,
|
||||
selected: bool,
|
||||
visual_selected: bool,
|
||||
show_like_marker: bool,
|
||||
) {
|
||||
let heart = if !show_like_marker {
|
||||
Span::raw("")
|
||||
} else if state.track_liked(track) {
|
||||
Span::styled("♥ ", theme::accent_for(state))
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
let fed_marker = if track.fed.is_some() {
|
||||
let availability = if state.track_content_local(track) {
|
||||
Availability::Local
|
||||
} else {
|
||||
Availability::Remote
|
||||
};
|
||||
availability_prefix(availability)
|
||||
} else {
|
||||
Span::raw("")
|
||||
};
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{index_label:>3} "), theme::dim()),
|
||||
heart,
|
||||
fed_marker,
|
||||
Span::raw(track.title.clone()),
|
||||
Span::styled(format!(" {}", track.artist_line()), theme::dim()),
|
||||
]);
|
||||
@@ -82,13 +197,20 @@ pub(crate) fn track_row(
|
||||
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
|
||||
area,
|
||||
);
|
||||
if visual_selected {
|
||||
frame
|
||||
.buffer_mut()
|
||||
.set_style(area, theme::selection_for(state));
|
||||
}
|
||||
if selected {
|
||||
frame.buffer_mut().set_style(area, theme::tab_active());
|
||||
frame
|
||||
.buffer_mut()
|
||||
.set_style(area, theme::tab_active_for(state));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn track_meta_suffix(
|
||||
track: &crate::api::models::TrackItem,
|
||||
track: &crate::library::models::TrackItem,
|
||||
include_tech: bool,
|
||||
) -> String {
|
||||
let has_tech = track.audio_format.is_some()
|
||||
@@ -125,11 +247,12 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let player = &state.player;
|
||||
let block = Block::bordered()
|
||||
.title(format!(
|
||||
" Queue — {} tracks · enter: play · shift-c: clear ",
|
||||
player.queue.len()
|
||||
" Queue — {} tracks · Mode: {} · enter: play · d: remove · shift-v: select · :clear ",
|
||||
player.queue.len(),
|
||||
state.global.filters.source_mode.label()
|
||||
))
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::dim());
|
||||
.title_style(theme::header_for(state))
|
||||
.border_style(theme::border_for(state));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
@@ -168,10 +291,21 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
} else {
|
||||
(index + 1).to_string()
|
||||
};
|
||||
track_row(frame, row, state, track, label, index == cursor);
|
||||
let visual_selected = state
|
||||
.track_selection
|
||||
.contains(&TrackSelectionScope::Queue, index);
|
||||
track_row(
|
||||
frame,
|
||||
row,
|
||||
state,
|
||||
track,
|
||||
label,
|
||||
index == cursor,
|
||||
visual_selected,
|
||||
);
|
||||
// Tracks before the playing one are history: greyed out unless the
|
||||
// cursor is on them.
|
||||
if index < player.queue_pos && index != cursor {
|
||||
if index < player.queue_pos && index != cursor && !visual_selected {
|
||||
frame.buffer_mut().set_style(row, played_style);
|
||||
}
|
||||
}
|
||||
@@ -184,47 +318,48 @@ fn format_secs(secs: f64) -> String {
|
||||
|
||||
/// Playback time, progress bar, queue position, volume and mode flags.
|
||||
/// Wider consoles get a longer bar and full flags; narrow ones drop pieces.
|
||||
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
|
||||
fn player_right_line(state: &AppState, width: u16) -> Line<'static> {
|
||||
let player = &state.player;
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(track) = &player.current {
|
||||
if player.playing {
|
||||
let bar_width: usize = match width {
|
||||
0..=59 => 0,
|
||||
60..=79 => 8,
|
||||
80..=109 => 14,
|
||||
_ => 22,
|
||||
};
|
||||
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
|
||||
if bar_width > 0 && track.duration_seconds > 0.0 {
|
||||
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
|
||||
let filled = (ratio * bar_width as f64).round() as usize;
|
||||
spans.push(Span::styled("━".repeat(filled), theme::accent()));
|
||||
spans.push(Span::styled("─".repeat(bar_width - filled), theme::dim()));
|
||||
spans.push(Span::raw(" "));
|
||||
} else {
|
||||
spans.push(Span::styled("/ ", theme::dim()));
|
||||
}
|
||||
spans.push(Span::raw(track.duration_label()));
|
||||
if !player.queue.is_empty() && width >= 70 {
|
||||
spans.push(Span::styled(
|
||||
format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()),
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
if let Some(track) = &player.current
|
||||
&& player.playing
|
||||
{
|
||||
let bar_width: usize = match width {
|
||||
0..=59 => 0,
|
||||
60..=79 => 8,
|
||||
80..=109 => 14,
|
||||
_ => 22,
|
||||
};
|
||||
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
|
||||
if bar_width > 0 && track.duration_seconds > 0.0 {
|
||||
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
|
||||
let filled = (ratio * bar_width as f64).round() as usize;
|
||||
spans.push(Span::styled("━".repeat(filled), theme::accent_for(state)));
|
||||
spans.push(Span::styled("─".repeat(bar_width - filled), theme::dim()));
|
||||
spans.push(Span::raw(" "));
|
||||
} else {
|
||||
spans.push(Span::styled("/ ", theme::dim()));
|
||||
}
|
||||
spans.push(Span::raw(track.duration_label()));
|
||||
if !player.queue.is_empty() && width >= 70 {
|
||||
spans.push(Span::styled(
|
||||
format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()),
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if width >= 80 {
|
||||
let volume_cells = usize::from(player.volume / 10);
|
||||
spans.extend([
|
||||
Span::styled(" vol ", theme::dim()),
|
||||
Span::styled("█".repeat(volume_cells), theme::accent()),
|
||||
Span::styled("█".repeat(volume_cells), theme::accent_for(state)),
|
||||
Span::styled("░".repeat(10 - volume_cells), theme::dim()),
|
||||
Span::raw(format!(" {:3}%", player.volume)),
|
||||
Span::raw(" "),
|
||||
]);
|
||||
// Enabled modes light up as filled chips; disabled stay dim text.
|
||||
if player.shuffle {
|
||||
spans.push(Span::styled(" shuffle ", theme::tab_active()));
|
||||
spans.push(Span::styled(" shuffle ", theme::tab_active_for(state)));
|
||||
} else {
|
||||
spans.push(Span::styled("shuffle off", theme::dim()));
|
||||
}
|
||||
@@ -234,76 +369,41 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
|
||||
} else {
|
||||
spans.push(Span::styled(
|
||||
format!(" repeat {} ", player.repeat.label()),
|
||||
theme::tab_active(),
|
||||
theme::tab_active_for(state),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
spans.push(Span::styled(format!(" {}%", player.volume), theme::dim()));
|
||||
}
|
||||
if width >= 70 {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", state.device_playback.role.label()),
|
||||
theme::role_pill(state.device_playback.role),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
format!(" · online {}", state.device_playback.online_devices.max(1)),
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
// Keep a gap between the flags and the username block to the right.
|
||||
spans.push(Span::raw(" "));
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, max: usize) -> String {
|
||||
let mut out: String = value.chars().take(max).collect();
|
||||
if value.chars().count() > max {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn device_status_line(state: &AppState) -> Line<'static> {
|
||||
if state.devices.is_playback_device() {
|
||||
return Line::from(vec![Span::styled("playing here", theme::accent())]);
|
||||
}
|
||||
let name = state
|
||||
.devices
|
||||
.active_device_name()
|
||||
.map(|name| truncate_chars(name, 26))
|
||||
.unwrap_or_else(|| "remote device".to_string());
|
||||
Line::from(vec![
|
||||
Span::styled("controlling ", theme::dim()),
|
||||
Span::styled(name, theme::accent()),
|
||||
])
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let [player_row, message_row] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
|
||||
|
||||
let player = &state.player;
|
||||
// Layout: track title left, time/progress/flags centered, user right.
|
||||
// The center block is built first and gets a fixed width; the title
|
||||
// Layout: track title left, time/progress/flags on the right. The
|
||||
// right block is built first and gets a fixed width; the title
|
||||
// truncates into whatever is left.
|
||||
let center = player_right_line(player, area.width);
|
||||
let center = player_right_line(state, area.width);
|
||||
let center_width = (center.width() as u16).min(area.width);
|
||||
let device_line = device_status_line(state);
|
||||
let device_width = (device_line.width() as u16).min(32);
|
||||
let user_line = state.user.as_ref().map(|user| {
|
||||
Line::from(vec![
|
||||
Span::styled("◉ ", theme::accent()),
|
||||
Span::raw(user.name.clone()),
|
||||
])
|
||||
});
|
||||
let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16);
|
||||
let [title_area, right_area, device_area, user_area] = Layout::horizontal([
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(center_width),
|
||||
Constraint::Length(device_width.saturating_add(2)),
|
||||
Constraint::Length(user_width),
|
||||
])
|
||||
.areas(player_row);
|
||||
frame.render_widget(
|
||||
Paragraph::new(device_line).alignment(Alignment::Right),
|
||||
device_area,
|
||||
);
|
||||
if let Some(user_line) = user_line {
|
||||
frame.render_widget(
|
||||
Paragraph::new(user_line).alignment(Alignment::Right),
|
||||
user_area,
|
||||
);
|
||||
}
|
||||
let [title_area, right_area] =
|
||||
Layout::horizontal([Constraint::Min(8), Constraint::Length(center_width)])
|
||||
.areas(player_row);
|
||||
|
||||
let mut spans = Vec::new();
|
||||
match &player.current {
|
||||
@@ -311,10 +411,10 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
if player.paused {
|
||||
spans.push(Span::styled("⏸ ", theme::dim()));
|
||||
} else {
|
||||
spans.push(Span::styled("▶ ", theme::accent()));
|
||||
spans.push(Span::styled("▶ ", theme::accent_for(state)));
|
||||
}
|
||||
if state.likes.contains(&track.id) {
|
||||
spans.push(Span::styled("♥ ", theme::accent()));
|
||||
if state.track_liked(track) {
|
||||
spans.push(Span::styled("♥ ", theme::accent_for(state)));
|
||||
}
|
||||
spans.push(Span::raw(track.title.clone()));
|
||||
spans.push(Span::styled(
|
||||
@@ -331,36 +431,63 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
|
||||
if state.cmdline.active {
|
||||
// Vim-style command line takes over the message row.
|
||||
let line = Line::from(vec![
|
||||
Span::styled(":", theme::header()),
|
||||
Span::raw(state.cmdline.input.clone()),
|
||||
Span::styled("█", theme::accent()),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), message_row);
|
||||
let mut spans = vec![Span::styled(":", theme::header_for(state))];
|
||||
spans.extend(line_edit_spans(
|
||||
&state.cmdline.input,
|
||||
usize::from(message_row.width.saturating_sub(2)),
|
||||
));
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), message_row);
|
||||
draw_version(frame, message_row);
|
||||
return;
|
||||
}
|
||||
|
||||
let message = match &state.status_message {
|
||||
Some(message) => Line::styled(message.clone(), theme::accent()),
|
||||
None => match &state.player.current {
|
||||
// Idle line doubles as the current track's tech data display.
|
||||
Some(track) if state.player.playing && !track.tech_label_full().is_empty() => {
|
||||
Line::styled(track.tech_label_full(), theme::dim())
|
||||
}
|
||||
_ => Line::styled("press ? for keybindings", theme::dim()),
|
||||
Some(message) if is_waiting_message(message) => Line::from(vec![
|
||||
Span::styled(format!("{} ", state.spinner()), theme::accent_for(state)),
|
||||
Span::styled(message.clone(), theme::accent_for(state)),
|
||||
]),
|
||||
Some(message) => Line::styled(message.clone(), theme::accent_for(state)),
|
||||
None => match active_artist_peer_search(state) {
|
||||
Some(message) => loading_line(state, message),
|
||||
None => match &state.player.current {
|
||||
// Idle line doubles as the current track's tech data display.
|
||||
Some(track) if state.player.playing && !track.tech_label_full().is_empty() => {
|
||||
Line::styled(track.tech_label_full(), theme::dim())
|
||||
}
|
||||
_ => Line::styled("press ? for keybindings", theme::dim()),
|
||||
},
|
||||
},
|
||||
};
|
||||
frame.render_widget(Paragraph::new(message), message_row);
|
||||
draw_version(frame, message_row);
|
||||
|
||||
if let Some(pending) = &state.pending_keys {
|
||||
let pending = Paragraph::new(Line::styled(format!("{pending} …"), theme::header()))
|
||||
.alignment(Alignment::Right);
|
||||
let pending = Paragraph::new(Line::styled(
|
||||
format!("{pending} …"),
|
||||
theme::header_for(state),
|
||||
))
|
||||
.alignment(Alignment::Right);
|
||||
frame.render_widget(pending, message_row);
|
||||
}
|
||||
}
|
||||
|
||||
fn active_artist_peer_search(state: &AppState) -> Option<String> {
|
||||
if state.active_tab != Tab::Global {
|
||||
return None;
|
||||
}
|
||||
let Some(GlobalView::Artist { id, .. }) = state.global.stack.last() else {
|
||||
return None;
|
||||
};
|
||||
if !matches!(state.artist_fed_views.get(id), Some(Loadable::Loading)) {
|
||||
return None;
|
||||
}
|
||||
let name = match state.artist_views.get(id) {
|
||||
Some(Loadable::Ready(detail)) => detail.name.as_str(),
|
||||
_ => "artist",
|
||||
};
|
||||
Some(format!("searching peers for \"{name}\"…"))
|
||||
}
|
||||
|
||||
fn draw_version(frame: &mut Frame, area: Rect) {
|
||||
let version = format!("v{}", env!("CARGO_PKG_VERSION"));
|
||||
frame.render_widget(
|
||||
@@ -371,7 +498,7 @@ fn draw_version(frame: &mut Frame, area: Rect) {
|
||||
|
||||
/// Help window: bindings merged per action (j / down on one row), grouped
|
||||
/// into titled sections and laid out in two balanced columns.
|
||||
fn draw_help(frame: &mut Frame, keymap: &Keymap) {
|
||||
fn draw_help(frame: &mut Frame, keymap: &Keymap, state: &AppState) {
|
||||
use crate::app::action::{Action, Category};
|
||||
use crate::config::keymap::KeyContext;
|
||||
|
||||
@@ -405,7 +532,7 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) {
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut lines = vec![Line::styled(category.title(), theme::header())];
|
||||
let mut lines = vec![Line::styled(category.title(), theme::header_for(state))];
|
||||
for row in rows {
|
||||
let keys = row.keys.join(" / ");
|
||||
let context = if row.context == KeyContext::Global {
|
||||
@@ -415,17 +542,33 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) {
|
||||
};
|
||||
let command = row.action.command_hint().unwrap_or("");
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{keys:<13}"), theme::accent()),
|
||||
Span::styled(format!("{keys:<13}"), theme::accent_for(state)),
|
||||
Span::raw(format!(
|
||||
"{:<24}",
|
||||
format!("{}{context}", row.action.describe())
|
||||
)),
|
||||
Span::styled(command.to_string(), theme::accent()),
|
||||
Span::styled(command.to_string(), theme::accent_for(state)),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::default());
|
||||
blocks.push(lines);
|
||||
}
|
||||
blocks.push(vec![
|
||||
Line::styled("Status icons", theme::header_for(state)),
|
||||
Line::from(vec![
|
||||
Span::styled("●", Style::new().fg(Color::Green)),
|
||||
Span::raw(" Local on this device"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("◐", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(" Local + peer sources"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("⇅", theme::accent_for(state)),
|
||||
Span::raw(" Network only"),
|
||||
]),
|
||||
Line::default(),
|
||||
]);
|
||||
|
||||
// Balance the blocks across two columns.
|
||||
let total: usize = blocks.iter().map(Vec::len).sum();
|
||||
@@ -446,8 +589,8 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) {
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Keybindings & commands ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
.title_style(theme::header_for(state))
|
||||
.border_style(theme::strong_border_for(state));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
@@ -481,3 +624,34 @@ fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
|
||||
.areas(rect);
|
||||
rect
|
||||
}
|
||||
|
||||
/// Renders a [`LineEdit`] as spans with a visible cursor, windowed so the
|
||||
/// cursor always stays on screen when the value is wider than `width`.
|
||||
pub(crate) fn line_edit_spans(edit: &LineEdit, width: usize) -> Vec<Span<'static>> {
|
||||
let width = width.max(2);
|
||||
let chars: Vec<char> = edit.as_str().chars().collect();
|
||||
let cursor = edit.cursor().min(chars.len());
|
||||
// Window start: keep the cursor within the visible slice (one cell is
|
||||
// reserved for the cursor block itself when it sits at the end).
|
||||
let start = (cursor + 1).saturating_sub(width);
|
||||
let end = (start + width.saturating_sub(1)).min(chars.len());
|
||||
let before: String = chars[start..cursor].iter().collect();
|
||||
let (under, after): (String, String) = if cursor < chars.len() {
|
||||
(
|
||||
chars[cursor].to_string(),
|
||||
chars[cursor + 1..end.max(cursor + 1)].iter().collect(),
|
||||
)
|
||||
} else {
|
||||
("█".to_string(), String::new())
|
||||
};
|
||||
let cursor_style = if cursor < chars.len() {
|
||||
ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
|
||||
} else {
|
||||
theme::accent()
|
||||
};
|
||||
vec![
|
||||
Span::raw(before),
|
||||
Span::styled(under, cursor_style),
|
||||
Span::raw(after),
|
||||
]
|
||||
}
|
||||
|
||||
+36
-37
@@ -4,8 +4,8 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
|
||||
use super::{theme, track_row};
|
||||
use crate::app::state::{AppState, Loadable};
|
||||
use super::{loading_line, theme, track_row_with_like_marker};
|
||||
use crate::app::state::{AppState, LIKES_PLAYLIST_ID, Loadable, TrackSelectionScope};
|
||||
use crate::app::update::playlist_tracks;
|
||||
|
||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
@@ -15,11 +15,11 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect {
|
||||
fn bordered(frame: &mut Frame, area: Rect, state: &AppState, title: String) -> Rect {
|
||||
let block = Block::bordered()
|
||||
.title(title)
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::dim());
|
||||
.title_style(theme::header_for(state))
|
||||
.border_style(theme::border_for(state));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
inner
|
||||
@@ -38,7 +38,15 @@ fn centered_line(frame: &mut Frame, area: Rect, line: Line) {
|
||||
}
|
||||
|
||||
fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let inner = bordered(frame, area, " Playlists ".to_string());
|
||||
let inner = bordered(
|
||||
frame,
|
||||
area,
|
||||
state,
|
||||
format!(
|
||||
" Playlists · Mode: {} ",
|
||||
state.global.filters.source_mode.label()
|
||||
),
|
||||
);
|
||||
let selected = state.playlists.selected;
|
||||
|
||||
let list = match &state.playlists.list {
|
||||
@@ -51,11 +59,7 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
return centered_line(
|
||||
frame,
|
||||
inner,
|
||||
Line::styled("loading playlists…", theme::dim()),
|
||||
);
|
||||
return centered_line(frame, inner, loading_line(state, "loading playlists…"));
|
||||
}
|
||||
};
|
||||
if list.is_empty() {
|
||||
@@ -74,30 +78,12 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
height: 1,
|
||||
};
|
||||
let marker = if playlist.kind == "likes" {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
Span::styled("♥ ", theme::accent_for(state))
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
let mut flags = Vec::new();
|
||||
if !playlist.is_own {
|
||||
if let Some(owner) = &playlist.owner_name {
|
||||
flags.push(format!("by {owner}"));
|
||||
}
|
||||
}
|
||||
if playlist.is_public {
|
||||
flags.push("public".to_string());
|
||||
}
|
||||
let suffix = if flags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", flags.join(" · "))
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
marker,
|
||||
Span::raw(playlist.title.clone()),
|
||||
Span::styled(suffix, theme::dim()),
|
||||
])),
|
||||
Paragraph::new(Line::from(vec![marker, Span::raw(playlist.title.clone())])),
|
||||
row,
|
||||
);
|
||||
frame.render_widget(
|
||||
@@ -109,7 +95,9 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
row,
|
||||
);
|
||||
if index == selected {
|
||||
frame.buffer_mut().set_style(row, theme::tab_active());
|
||||
frame
|
||||
.buffer_mut()
|
||||
.set_style(row, theme::tab_active_for(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,10 +105,17 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
|
||||
let loadable = state.playlist_views.get(&id);
|
||||
let title = match loadable {
|
||||
Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title),
|
||||
_ => " Playlists ▸ … ".to_string(),
|
||||
Some(Loadable::Ready(detail)) => format!(
|
||||
" Playlists ▸ {} · Mode: {} ",
|
||||
detail.title,
|
||||
state.global.filters.source_mode.label()
|
||||
),
|
||||
_ => format!(
|
||||
" Playlists ▸ … · Mode: {} ",
|
||||
state.global.filters.source_mode.label()
|
||||
),
|
||||
};
|
||||
let inner = bordered(frame, area, title);
|
||||
let inner = bordered(frame, area, state, title);
|
||||
|
||||
if let Some(Loadable::Failed(error)) = loadable {
|
||||
return centered_line(
|
||||
@@ -130,7 +125,7 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
);
|
||||
}
|
||||
let Some(tracks) = playlist_tracks(state, id) else {
|
||||
return centered_line(frame, inner, Line::styled("loading…", theme::dim()));
|
||||
return centered_line(frame, inner, loading_line(state, "loading…"));
|
||||
};
|
||||
if tracks.is_empty() {
|
||||
return centered_line(
|
||||
@@ -151,13 +146,17 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
track_row(
|
||||
track_row_with_like_marker(
|
||||
frame,
|
||||
row,
|
||||
state,
|
||||
track,
|
||||
(index + 1).to_string(),
|
||||
index == cursor,
|
||||
state
|
||||
.track_selection
|
||||
.contains(&TrackSelectionScope::Playlist(id), index),
|
||||
id != LIKES_PLAYLIST_ID,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1274
-103
File diff suppressed because it is too large
Load Diff
+66
-2
@@ -1,12 +1,19 @@
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
use crate::app::state::{AppState, DevicePlaybackRole};
|
||||
|
||||
pub const ACCENT: Color = Color::Cyan;
|
||||
pub const CONTROL_ACCENT: Color = Color::Yellow;
|
||||
pub const DIM: Color = Color::DarkGray;
|
||||
|
||||
pub fn accent() -> Style {
|
||||
Style::new().fg(ACCENT)
|
||||
}
|
||||
|
||||
pub fn accent_for(state: &AppState) -> Style {
|
||||
Style::new().fg(accent_color_for(state))
|
||||
}
|
||||
|
||||
pub fn dim() -> Style {
|
||||
Style::new().fg(DIM)
|
||||
}
|
||||
@@ -18,6 +25,63 @@ pub fn tab_active() -> Style {
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn header() -> Style {
|
||||
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
|
||||
pub fn tab_active_for(state: &AppState) -> Style {
|
||||
Style::new()
|
||||
.fg(Color::Black)
|
||||
.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)
|
||||
}
|
||||
|
||||
pub fn selection() -> Style {
|
||||
Style::new().fg(Color::White).bg(Color::Rgb(24, 68, 72))
|
||||
}
|
||||
|
||||
pub fn selection_for(state: &AppState) -> Style {
|
||||
if state.device_playback.is_control() {
|
||||
Style::new().fg(Color::White).bg(Color::Rgb(92, 72, 0))
|
||||
} else {
|
||||
selection()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header_for(state: &AppState) -> Style {
|
||||
accent_for(state).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn border_for(state: &AppState) -> Style {
|
||||
if state.device_playback.is_control() {
|
||||
Style::new().fg(CONTROL_ACCENT)
|
||||
} else {
|
||||
dim()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strong_border_for(state: &AppState) -> Style {
|
||||
accent_for(state)
|
||||
}
|
||||
|
||||
pub fn role_pill(role: DevicePlaybackRole) -> Style {
|
||||
let bg = match role {
|
||||
DevicePlaybackRole::Active => Color::Green,
|
||||
DevicePlaybackRole::Control => CONTROL_ACCENT,
|
||||
};
|
||||
Style::new()
|
||||
.fg(Color::Black)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn accent_color_for(state: &AppState) -> Color {
|
||||
if state.device_playback.is_control() {
|
||||
CONTROL_ACCENT
|
||||
} else {
|
||||
ACCENT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user