Compare commits
25
Commits
v0.1.8
...
federation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
079d87a831 | ||
|
|
add764e51d | ||
|
|
87cb7fe74c | ||
|
|
e95d2e7fe1 | ||
|
|
03f74cc91e | ||
|
|
c971e81eed | ||
|
|
f73e3e7b95 | ||
|
|
cced546cd5 | ||
|
|
52c2f7f7fe | ||
|
|
9b9f5d9ce5 | ||
|
|
1f9539ffd7 | ||
|
|
d854a4d4e0 | ||
|
|
6175423b0c | ||
|
|
93dc8f02fb | ||
|
|
89c78dcadc | ||
|
|
34153cca9d | ||
|
|
d4c219837b | ||
|
|
1c721b68f5 | ||
|
|
29466d70e8 | ||
|
|
abf9ad782c | ||
|
|
28189bae95 | ||
|
|
a7f41ff205 | ||
|
|
40c318a1d0 | ||
|
|
0d6c89761f | ||
|
|
f55571ab34 |
@@ -89,6 +89,20 @@ joining another user's trusted-device group.
|
|||||||
Keeping these layers separate prevents discovery convenience from silently
|
Keeping these layers separate prevents discovery convenience from silently
|
||||||
becoming a synchronization trust decision.
|
becoming a synchronization trust decision.
|
||||||
|
|
||||||
|
### Federation Jam control
|
||||||
|
|
||||||
|
Jam is a third, deliberately narrow authority boundary. A host creates an
|
||||||
|
opaque `frid://j/...` runtime capability and remains the only node producing
|
||||||
|
audio. Other TUI peers use a dedicated Jam ALPN to submit the same portable
|
||||||
|
playback commands used by connected-device control and receive the host's
|
||||||
|
playback snapshot. They receive queue metadata, not audio.
|
||||||
|
|
||||||
|
Jam never exchanges trusted membership, likes, playlists, or listening
|
||||||
|
history. Volume remains local. Commands carry unique IDs and are retried until
|
||||||
|
the host acknowledges them, while inactive participants expire from the
|
||||||
|
runtime session. Regenerating the capability or restarting the host invalidates
|
||||||
|
the previous link.
|
||||||
|
|
||||||
## Discovery and direct communication
|
## Discovery and direct communication
|
||||||
|
|
||||||
Furumi separates finding content from transferring it.
|
Furumi separates finding content from transferring it.
|
||||||
@@ -109,6 +123,22 @@ The DHT is the distributed index. Nodes publish compact searchable
|
|||||||
descriptions of their local library and query the network without contacting a
|
descriptions of their local library and query the network without contacting a
|
||||||
central search service.
|
central search service.
|
||||||
|
|
||||||
|
Similarity discovery uses a separate schema-independent DHT overlay. A node
|
||||||
|
derives a deterministic 256-bit routing signature from every local embedding,
|
||||||
|
groups them into fixed two-level LSH buckets, and publishes compact summaries
|
||||||
|
containing only fine-bucket representatives, its peer identity, and the ticket
|
||||||
|
needed to dial a previously unknown owner. The owner signs every summary with
|
||||||
|
its existing transport key, so storage peers can relay and cache it but cannot
|
||||||
|
impersonate or modify it. Summaries expire with the ordinary library-record TTL
|
||||||
|
and are replaceable federation cache, never local library authority.
|
||||||
|
|
||||||
|
A similarity search first performs bounded multi-probe LSH lookups to rank
|
||||||
|
likely owners, then sends the existing normalized-vector request directly to
|
||||||
|
at most 16 peers initially and 48 on fallback. Known peers remain a rollout
|
||||||
|
fallback. The model, preprocessing, durable embeddings, exact cosine search,
|
||||||
|
and consent policy remain client-owned; `music-dht` owns only compatible
|
||||||
|
routing math, signed records, replication, and wire bounds.
|
||||||
|
|
||||||
Once a peer is known, communication moves to direct P2P streams provided by
|
Once a peer is known, communication moves to direct P2P streams provided by
|
||||||
iroh through `music-dht`. Furumi defines separate application protocols for
|
iroh through `music-dht`. Furumi defines separate application protocols for
|
||||||
catalog requests, audio transfer, and trusted-device synchronization. This
|
catalog requests, audio transfer, and trusted-device synchronization. This
|
||||||
@@ -268,6 +298,31 @@ intended role: transforming current audio features into drawing commands. It
|
|||||||
is not a plugin mechanism for accessing the library, network, or player
|
is not a plugin mechanism for accessing the library, network, or player
|
||||||
controls.
|
controls.
|
||||||
|
|
||||||
|
## Music-similarity indexing
|
||||||
|
|
||||||
|
Similarity search is an optional local capability and is disabled by default.
|
||||||
|
When enabled, a background pipeline downloads a selected ONNX model, verifies
|
||||||
|
its pinned SHA-256 digest, decodes durable local tracks, and stores normalized
|
||||||
|
embeddings in the library SQLite database. Embeddings are keyed by an exact
|
||||||
|
fingerprint of the model artifact and preprocessing profile. Old profile rows
|
||||||
|
remain available while a new profile is calculated, and the in-memory exact
|
||||||
|
cosine index switches only after the replacement profile is usable.
|
||||||
|
|
||||||
|
The SQLite rows are the canonical derived store. The in-memory index can be
|
||||||
|
discarded and rebuilt, and neither is required for import, browsing, or
|
||||||
|
playback. Remote/cache-only tracks are never scheduled for local embedding.
|
||||||
|
|
||||||
|
Federated similarity uses a separate versioned direct-stream protocol. With
|
||||||
|
explicit privacy consent, the requester sends only a normalized embedding and
|
||||||
|
its profile fingerprint to a bounded set of known peers. It does not publish
|
||||||
|
queries to the DHT. Each peer searches its own active local index and returns a
|
||||||
|
bounded metadata result with a compact embedding SimHash. The requester uses
|
||||||
|
that signature to suppress near-duplicate recordings across peers without
|
||||||
|
receiving every result vector. Fan-out, concurrency, message sizes, and
|
||||||
|
timeouts are bounded; incompatible profiles are rejected. This direct
|
||||||
|
peer-selection layer can later be replaced by DHT routing without changing
|
||||||
|
local storage or ranking.
|
||||||
|
|
||||||
## Persistence boundaries
|
## Persistence boundaries
|
||||||
|
|
||||||
Furumi stores different kinds of state according to their lifetime:
|
Furumi stores different kinds of state according to their lifetime:
|
||||||
@@ -276,6 +331,7 @@ Furumi stores different kinds of state according to their lifetime:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Library, playlists, likes, history | SQLite | Durable local source of truth |
|
| Library, playlists, likes, history | SQLite | Durable local source of truth |
|
||||||
| Device operation log and replicas | SQLite | Offline synchronization |
|
| Device operation log and replicas | SQLite | Offline synchronization |
|
||||||
|
| Versioned track embeddings | SQLite | Durable, locally rebuildable similarity data |
|
||||||
| Federation catalog cache | SQLite/cache | Faster network browsing |
|
| Federation catalog cache | SQLite/cache | Faster network browsing |
|
||||||
| Audio and artwork cache | Filesystem cache | Reusable fetched data |
|
| Audio and artwork cache | Filesystem cache | Reusable fetched data |
|
||||||
| Settings, keymap, identity | Platform config/data dirs | Node configuration |
|
| Settings, keymap, identity | Platform config/data dirs | Node configuration |
|
||||||
@@ -307,6 +363,8 @@ The source tree follows the architectural responsibilities:
|
|||||||
|
|
||||||
- `library/` owns the local catalog and import pipeline;
|
- `library/` owns the local catalog and import pipeline;
|
||||||
- `player/` owns audio playback and analysis;
|
- `player/` owns audio playback and analysis;
|
||||||
|
- `similarity.rs` owns model acquisition, preprocessing, background indexing,
|
||||||
|
and the replaceable exact in-memory index;
|
||||||
- `federation/` owns DHT-facing search, peer catalogs, and audio exchange;
|
- `federation/` owns DHT-facing search, peer catalogs, and audio exchange;
|
||||||
- `devices.rs` owns trusted-device replication and playback coordination;
|
- `devices.rs` owns trusted-device replication and playback coordination;
|
||||||
- `app/` owns state transitions and runtime orchestration;
|
- `app/` owns state transitions and runtime orchestration;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Furumi are documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Optional offline music-similarity search for local tracks, backed by
|
||||||
|
versioned SQLite embeddings and a replaceable exact in-memory cosine index.
|
||||||
|
- Automatic SHA-256-verified download and local inference for the first ONNX
|
||||||
|
embedding model, with retained model/profile generations and background
|
||||||
|
backfilling of existing library tracks.
|
||||||
|
- Similarity settings for enablement, model/profile information, numeric worker
|
||||||
|
count, derived-data cleanup, processing progress, and federation privacy
|
||||||
|
consent.
|
||||||
|
- Track-seeded similarity search from the track-information popup, including
|
||||||
|
bounded federated queries to compatible known peers.
|
||||||
|
- The `furumi-fd/similarity/1` protocol in the visible protocol-version status.
|
||||||
|
- Decentralized `similarity_dht` routing with signed anonymous two-level LSH
|
||||||
|
summaries, multi-probe lookup beyond the locally known peer set, and known-
|
||||||
|
peer fallback during gradual network upgrades.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Similarity wire types, bounds, validation, and stream framing now come from
|
||||||
|
the shared `music-dht 0.4.0` API so native, web, and future clients can
|
||||||
|
interoperate without sharing an embedding implementation.
|
||||||
|
- Existing SQLite embeddings are backfilled once with compact 256-bit routing
|
||||||
|
signatures; new embeddings store them immediately without changing exact
|
||||||
|
local cosine search.
|
||||||
|
- A similarity result page keeps the source track first as query context while
|
||||||
|
excluding it from the actual nearest-neighbor ranking, labels the mode as
|
||||||
|
`Search similar to`, and suppresses near-identical embeddings across releases
|
||||||
|
and federated peer responses.
|
||||||
|
- Preprocessing profiles now open a read-only details window describing their
|
||||||
|
audio selection, resampling, spectrogram, patching, and aggregation contract.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Current-track information (`Shift+I`) now uses the enriched queue entry, so
|
||||||
|
it shows the same complete metadata and similarity action as `I` on that
|
||||||
|
track in the queue.
|
||||||
|
|
||||||
|
## [0.2.5] - 2026-08-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Command history navigation with the Up and Down arrow keys.
|
||||||
|
- A copy-friendly plain terminal view for connection tickets, device and Jam
|
||||||
|
invites, and track-sharing links.
|
||||||
|
- A configurable permanent music directory with write validation and an
|
||||||
|
optional safe migration of existing Furumi-managed files.
|
||||||
|
- Queue reordering for one track or a `Shift+V` selection with `Alt+K` and
|
||||||
|
`Alt+J`.
|
||||||
|
- Reproducible Nix/devenv tooling with Rust 1.97 and ALSA development files.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Sequential `a` actions now build one ordered play-next block instead of
|
||||||
|
reversing independently inserted tracks.
|
||||||
|
- Federated artist images and release covers are stored beside permanent music
|
||||||
|
in an `Artist/Release` directory tree.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Running a development build next to another Furumi instance no longer lets
|
||||||
|
an MPRIS name collision disable terminal raw mode and freeze keyboard input.
|
||||||
|
- `nix develop` now keeps devenv state outside the read-only Nix store and
|
||||||
|
isolates Rust 1.97 build artifacts from other toolchains.
|
||||||
|
- Music-directory validation and migration now reject overlapping changes,
|
||||||
|
resolve canonical paths, and produce Windows-portable managed filenames.
|
||||||
|
|
||||||
|
[Unreleased]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.5...HEAD
|
||||||
|
[0.2.5]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.4...v0.2.5
|
||||||
Generated
+886
-163
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.1.8"
|
version = "0.2.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.97"
|
rust-version = "1.97"
|
||||||
description = "A federated P2P player for personal music libraries"
|
description = "A federated P2P player for personal music libraries"
|
||||||
@@ -21,21 +21,30 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg", "pn
|
|||||||
lofty = "0.22"
|
lofty = "0.22"
|
||||||
# P2P federation: library index in a shared DHT + audio streaming between
|
# P2P federation: library index in a shared DHT + audio streaming between
|
||||||
# peers (same protocol as furumi-fd).
|
# peers (same protocol as furumi-fd).
|
||||||
music-dht = { git = "https://gt.hexor.cy/ab/frid.git", rev = "a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" }
|
music-dht = "0.4.0"
|
||||||
ratatui = "0.30.1"
|
ratatui = "0.30.1"
|
||||||
|
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] }
|
||||||
rhai = { version = "1", features = ["sync"] }
|
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"] }
|
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
|
||||||
|
rustfft = "6.4.1"
|
||||||
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.150"
|
serde_json = "1.0.150"
|
||||||
|
sha2 = "0.10.9"
|
||||||
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
|
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
|
||||||
toml = "1.1.2"
|
toml = "1.1.2"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
tract-onnx = "0.23.4"
|
||||||
unicode-width = "0.2.2"
|
unicode-width = "0.2.2"
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
# souvlaki 0.8.3 still depends on the unmaintained block 0.1.6. Keep its API
|
||||||
|
# intact while using an opaque inhabited FFI type accepted by current Rust.
|
||||||
|
block = { path = "vendor/block" }
|
||||||
|
|
||||||
[target.'cfg(target_os="macos")'.dependencies]
|
[target.'cfg(target_os="macos")'.dependencies]
|
||||||
core-foundation = "0.10.1"
|
core-foundation = "0.10.1"
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ library management are included. Visualizations are runtime-loadable Rhai
|
|||||||
scripts executed in a resource-limited sandbox, so they can be added or edited
|
scripts executed in a resource-limited sandbox, so they can be added or edited
|
||||||
without rebuilding the player.
|
without rebuilding the player.
|
||||||
|
|
||||||
|
Optional similarity search calculates versioned embeddings for local tracks
|
||||||
|
in the background and keeps them in SQLite. It works offline; after a separate
|
||||||
|
privacy consent it can also ask a bounded set of federation peers for matches.
|
||||||
|
Compatible peers are selected through signed, anonymous LSH summaries in a
|
||||||
|
decentralized DHT; no central recommendation index or shared calibration file
|
||||||
|
is required.
|
||||||
|
The first selectable model is downloaded on demand and is licensed separately
|
||||||
|
by MTG under CC BY-NC-SA 4.0 (a proprietary license is also available from
|
||||||
|
MTG); Furumi itself remains WTFPL.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
@@ -114,6 +124,29 @@ Import a music directory from Furumi's command line:
|
|||||||
Federation, trusted-device pairing, and key bindings are configured directly
|
Federation, trusted-device pairing, and key bindings are configured directly
|
||||||
inside the player.
|
inside the player.
|
||||||
|
|
||||||
|
### Now playing in tmux
|
||||||
|
|
||||||
|
While Furumi is running, a second invocation can print a cheap, single-line
|
||||||
|
playback snapshot without opening the TUI or library:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
furumi --status
|
||||||
|
# ▶ Artist — Track 1:23/4:05
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, add this to `.tmux.conf`:
|
||||||
|
|
||||||
|
```tmux
|
||||||
|
set -g status-interval 1
|
||||||
|
set -g status-right '#(furumi --status) | %H:%M'
|
||||||
|
```
|
||||||
|
|
||||||
|
`furumi --status-json` returns the same snapshot as JSON, including playback
|
||||||
|
state, title, artist, album, position, duration, and volume. Both commands
|
||||||
|
print nothing when Furumi is stopped or no track is loaded. On Linux, Furumi
|
||||||
|
also exposes the existing MPRIS player `cy.hexor.furumi`, which can be queried
|
||||||
|
with tools such as `playerctl`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Furumi is a Rust application built with:
|
Furumi is a Rust application built with:
|
||||||
@@ -121,6 +154,7 @@ Furumi is a Rust application built with:
|
|||||||
- `ratatui` and `crossterm` for the cross-platform TUI;
|
- `ratatui` and `crossterm` for the cross-platform TUI;
|
||||||
- `rodio` for local audio playback;
|
- `rodio` for local audio playback;
|
||||||
- SQLite for the personal library and synchronization state;
|
- SQLite for the personal library and synchronization state;
|
||||||
|
- tract ONNX inference for optional local music embeddings;
|
||||||
- a dedicated DHT for decentralized discovery;
|
- a dedicated DHT for decentralized discovery;
|
||||||
- iroh-based P2P streams for client-to-client communication;
|
- iroh-based P2P streams for client-to-client communication;
|
||||||
- an offline-tolerant operation log for trusted-device synchronization;
|
- an offline-tolerant operation log for trusted-device synchronization;
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
sourceRoot = builtins.toString ./.;
|
||||||
|
sourceKey = builtins.substring 0 12 (builtins.hashString "sha256" sourceRoot);
|
||||||
|
transientDotfile = "/tmp/furumi-tui-devenv-${sourceKey}";
|
||||||
|
rustToolchain = builtins.fromTOML (builtins.readFile ./rust-toolchain.toml);
|
||||||
|
rustVersion = rustToolchain.toolchain.channel;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# Pure flakes only expose their read-only store source. Keep devenv's own
|
||||||
|
# task/profile state writable, then restore the real checkout path in-shell.
|
||||||
|
devenv.root = sourceRoot;
|
||||||
|
devenv.dotfile = transientDotfile;
|
||||||
|
devenv.state = "${transientDotfile}/state";
|
||||||
|
|
||||||
|
languages.rust = {
|
||||||
|
enable = true;
|
||||||
|
toolchainFile = ./rust-toolchain.toml;
|
||||||
|
};
|
||||||
|
|
||||||
|
packages = [
|
||||||
|
pkgs.pkg-config
|
||||||
|
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||||
|
pkgs.alsa-lib
|
||||||
|
];
|
||||||
|
|
||||||
|
env.RUST_BACKTRACE = "1";
|
||||||
|
|
||||||
|
enterShell = lib.mkAfter ''
|
||||||
|
export DEVENV_ROOT="$PWD"
|
||||||
|
# Keep Nix builds separate from artifacts produced by rustup or another
|
||||||
|
# devenv generation. Cargo metadata is not compatible across compilers.
|
||||||
|
export CARGO_TARGET_DIR="$PWD/target/devenv-rust-${rustVersion}"
|
||||||
|
export PATH="${config.languages.rust.toolchainPackage}/bin:$PATH"
|
||||||
|
export RUSTC="${config.languages.rust.toolchainPackage}/bin/rustc"
|
||||||
|
export RUSTDOC="${config.languages.rust.toolchainPackage}/bin/rustdoc"
|
||||||
|
hash -r
|
||||||
|
'';
|
||||||
|
}
|
||||||
Generated
+399
@@ -0,0 +1,399 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"cachix": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"git-hooks": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks"
|
||||||
|
],
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1777487137,
|
||||||
|
"narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "cachix",
|
||||||
|
"rev": "a66a440c321d35f7193472c317f42a55ccd1cb93",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "latest",
|
||||||
|
"repo": "cachix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crate2nix": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772186516,
|
||||||
|
"narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=",
|
||||||
|
"owner": "rossng",
|
||||||
|
"repo": "crate2nix",
|
||||||
|
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "rossng",
|
||||||
|
"repo": "crate2nix",
|
||||||
|
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devenv": {
|
||||||
|
"inputs": {
|
||||||
|
"cachix": "cachix",
|
||||||
|
"crate2nix": "crate2nix",
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"ghostty": "ghostty",
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"nix": "nix",
|
||||||
|
"nixd": "nixd",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785616853,
|
||||||
|
"narHash": "sha256-WKA53CfbSwXh/QuGHc3gsWoLNHYaKw4xgvkc9ceRgUA=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "a3feed9ca96c66548496e8af2804d2a3a045d22e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1767039857,
|
||||||
|
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778716662,
|
||||||
|
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts_2": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1782949081,
|
||||||
|
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ghostty": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784602798,
|
||||||
|
"narHash": "sha256-298x90knBUWX5GHGXh2SKsAKvStjU2ri9UgOGoF79/8=",
|
||||||
|
"owner": "ghostty-org",
|
||||||
|
"repo": "ghostty",
|
||||||
|
"rev": "88b4cd047fa627cdca6781bc7e7dc8b75a2cecb9",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "ghostty-org",
|
||||||
|
"repo": "ghostty",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1782908218,
|
||||||
|
"narHash": "sha256-wLMOrPgVyeF3XmP+qfYcLqnVdTxikdcSvbIY7rA9jTA=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "9f7e99119ece7705299595299f3b031f39356de1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mk-shell-bin": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1677004959,
|
||||||
|
"narHash": "sha256-/uEkr1UkJrh11vD02aqufCxtbF5YnhRTIKlx5kyvf+I=",
|
||||||
|
"owner": "rrbutani",
|
||||||
|
"repo": "nix-mk-shell-bin",
|
||||||
|
"rev": "ff5d8bd4d68a347be5042e2f16caee391cd75887",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "rrbutani",
|
||||||
|
"repo": "nix-mk-shell-bin",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"flake-parts": [
|
||||||
|
"devenv",
|
||||||
|
"flake-parts"
|
||||||
|
],
|
||||||
|
"git-hooks-nix": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nixpkgs-23-11": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"nixpkgs-regression": [
|
||||||
|
"devenv"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785349663,
|
||||||
|
"narHash": "sha256-JSD8lPe5kalvPKx5X+inX8ZZdGLeXkAbd3Jiv7UDf+I=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "nix",
|
||||||
|
"rev": "f33db89fd6db6edc337d93212f6628ab6d25f407",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "devenv-2.34",
|
||||||
|
"repo": "nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nix2container": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1775487831,
|
||||||
|
"narHash": "sha256-2lguQpLPQaxpQCJjXhmEEAfabwsAhkP29Z7fgLzHARA=",
|
||||||
|
"owner": "nlewo",
|
||||||
|
"repo": "nix2container",
|
||||||
|
"rev": "76be9608a7f4d6c985d28b0e7be903ae2547df3e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nlewo",
|
||||||
|
"repo": "nix2container",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixd": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-parts": [
|
||||||
|
"devenv",
|
||||||
|
"flake-parts"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"treefmt-nix": "treefmt-nix"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1783935112,
|
||||||
|
"narHash": "sha256-IAQ14nteIKXAz4cd75UcZrsHGEVJ7QNrkUwX9rmeZ/Y=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixd",
|
||||||
|
"rev": "a64cd33e53b316b6b092ea0a966640cd2309bf3d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixd",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772624091,
|
||||||
|
"narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "80bdc1e5ce51f56b19791b52b2901187931f5353",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-lib": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1782614948,
|
||||||
|
"narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785571196,
|
||||||
|
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": "devenv",
|
||||||
|
"flake-parts": "flake-parts_2",
|
||||||
|
"mk-shell-bin": "mk-shell-bin",
|
||||||
|
"nix2container": "nix2container",
|
||||||
|
"nixpkgs": "nixpkgs_2",
|
||||||
|
"rust-overlay": "rust-overlay_2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1782875958,
|
||||||
|
"narHash": "sha256-5eqDcnBjb1424HRQdnhuhNOBZguq1Z2tqSa2OMF/m2c=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "13139aefa973f3d96c60c0fbab801de058ae25ca",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay_2": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785562362,
|
||||||
|
"narHash": "sha256-J15aBa3d6B1SUUAQydQ06wFjPzrrcVceb6jkdfwfGls=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "5f29c219a7655519f8a9f8c6968064b82c17cc93",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"treefmt-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixd",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1780220602,
|
||||||
|
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
description = "Furumi TUI development environment";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
|
devenv.url = "github:cachix/devenv";
|
||||||
|
devenv.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
|
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
nix2container.url = "github:nlewo/nix2container";
|
||||||
|
nix2container.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
mk-shell-bin.url = "github:rrbutani/nix-mk-shell-bin";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = inputs@{ flake-parts, ... }:
|
||||||
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
|
||||||
|
imports = [ inputs.devenv.flakeModule ];
|
||||||
|
|
||||||
|
perSystem = { ... }: {
|
||||||
|
devenv.shells.default = {
|
||||||
|
imports = [ ./devenv.nix ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.97.0"
|
||||||
|
profile = "minimal"
|
||||||
|
components = ["clippy", "rust-src", "rustfmt"]
|
||||||
+11
-1
@@ -38,11 +38,14 @@ pub enum Action {
|
|||||||
OpenCurrentTrackInfo,
|
OpenCurrentTrackInfo,
|
||||||
QueueAddNext,
|
QueueAddNext,
|
||||||
QueueAddLast,
|
QueueAddLast,
|
||||||
|
MoveQueueUp,
|
||||||
|
MoveQueueDown,
|
||||||
/// Download the selected federated track(s) into the local library.
|
/// Download the selected federated track(s) into the local library.
|
||||||
DownloadSelected,
|
DownloadSelected,
|
||||||
RemoveFromQueue,
|
RemoveFromQueue,
|
||||||
ClearQueue,
|
ClearQueue,
|
||||||
OpenConnectedDevices,
|
OpenConnectedDevices,
|
||||||
|
OpenListenHistory,
|
||||||
GoToRelease,
|
GoToRelease,
|
||||||
AddToPlaylist,
|
AddToPlaylist,
|
||||||
NewPlaylist,
|
NewPlaylist,
|
||||||
@@ -102,9 +105,12 @@ impl Action {
|
|||||||
| Action::ToggleShuffle
|
| Action::ToggleShuffle
|
||||||
| Action::CycleRepeat
|
| Action::CycleRepeat
|
||||||
| Action::ToggleVisualizer
|
| Action::ToggleVisualizer
|
||||||
| Action::OpenConnectedDevices => Category::Playback,
|
| Action::OpenConnectedDevices
|
||||||
|
| Action::OpenListenHistory => Category::Playback,
|
||||||
Action::QueueAddNext
|
Action::QueueAddNext
|
||||||
| Action::QueueAddLast
|
| Action::QueueAddLast
|
||||||
|
| Action::MoveQueueUp
|
||||||
|
| Action::MoveQueueDown
|
||||||
| Action::DownloadSelected
|
| Action::DownloadSelected
|
||||||
| Action::RemoveFromQueue
|
| Action::RemoveFromQueue
|
||||||
| Action::ClearQueue
|
| Action::ClearQueue
|
||||||
@@ -152,6 +158,7 @@ impl Action {
|
|||||||
Action::CycleRepeat => Some(":repeat [off|one|all]"),
|
Action::CycleRepeat => Some(":repeat [off|one|all]"),
|
||||||
Action::ClearQueue => Some(":clear"),
|
Action::ClearQueue => Some(":clear"),
|
||||||
Action::OpenConnectedDevices => None,
|
Action::OpenConnectedDevices => None,
|
||||||
|
Action::OpenListenHistory => None,
|
||||||
Action::ToggleHelp => Some(":help"),
|
Action::ToggleHelp => Some(":help"),
|
||||||
Action::OpenSearch => Some("/text"),
|
Action::OpenSearch => Some("/text"),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -190,10 +197,13 @@ impl Action {
|
|||||||
Action::OpenCurrentTrackInfo => "Current track info".into(),
|
Action::OpenCurrentTrackInfo => "Current track info".into(),
|
||||||
Action::QueueAddNext => "Queue: add next".into(),
|
Action::QueueAddNext => "Queue: add next".into(),
|
||||||
Action::QueueAddLast => "Queue: add to end".into(),
|
Action::QueueAddLast => "Queue: add to end".into(),
|
||||||
|
Action::MoveQueueUp => "Queue: move track/selection up".into(),
|
||||||
|
Action::MoveQueueDown => "Queue: move track/selection down".into(),
|
||||||
Action::DownloadSelected => "Federation: download to library".into(),
|
Action::DownloadSelected => "Federation: download to library".into(),
|
||||||
Action::RemoveFromQueue => "Queue: remove selected".into(),
|
Action::RemoveFromQueue => "Queue: remove selected".into(),
|
||||||
Action::ClearQueue => "Queue: clear".into(),
|
Action::ClearQueue => "Queue: clear".into(),
|
||||||
Action::OpenConnectedDevices => "Connected devices…".into(),
|
Action::OpenConnectedDevices => "Connected devices…".into(),
|
||||||
|
Action::OpenListenHistory => "Listening history…".into(),
|
||||||
Action::GoToRelease => "Open the track's release".into(),
|
Action::GoToRelease => "Open the track's release".into(),
|
||||||
Action::AddToPlaylist => "Add track to a playlist…".into(),
|
Action::AddToPlaylist => "Add track to a playlist…".into(),
|
||||||
Action::NewPlaylist => "Create a playlist".into(),
|
Action::NewPlaylist => "Create a playlist".into(),
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc => cancel(state),
|
KeyCode::Esc => cancel(state),
|
||||||
KeyCode::Enter => commit(state, runtime),
|
KeyCode::Enter => commit(state, runtime),
|
||||||
|
KeyCode::Up => {
|
||||||
|
if state.cmdline.history_previous() {
|
||||||
|
after_change(state, runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if state.cmdline.history_next() {
|
||||||
|
after_change(state, runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Backspace on an empty line closes it, like vim.
|
// Backspace on an empty line closes it, like vim.
|
||||||
KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state),
|
KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -87,6 +97,11 @@ fn set_view_cursor_zero(state: &mut AppState) {
|
|||||||
/// spawned task only queries if it is still the latest after the debounce,
|
/// spawned task only queries if it is still the latest after the debounce,
|
||||||
/// and the receiver drops responses that arrive out of date.
|
/// and the receiver drops responses that arrive out of date.
|
||||||
pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||||
|
state.search.similarity_source = None;
|
||||||
|
state.search.similarity_source_track = None;
|
||||||
|
state.search.similarity_tracks.clear();
|
||||||
|
state.search.similarity_stats = None;
|
||||||
|
state.search.similarity_error = None;
|
||||||
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
let query = state.search.query.clone();
|
let query = state.search.query.clone();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
@@ -135,6 +150,53 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn schedule_similarity_search(
|
||||||
|
state: &mut AppState,
|
||||||
|
runtime: &Runtime,
|
||||||
|
track: &crate::library::models::TrackItem,
|
||||||
|
) {
|
||||||
|
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
let artist = track.artist_line();
|
||||||
|
state.search.query = if artist.is_empty() {
|
||||||
|
track.title.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} — {artist}", track.title)
|
||||||
|
};
|
||||||
|
state.search.similarity_source = Some(track.id);
|
||||||
|
state.search.similarity_source_track = Some(track.clone());
|
||||||
|
state.search.similarity_tracks.clear();
|
||||||
|
state.search.similarity_stats = None;
|
||||||
|
state.search.similarity_error = None;
|
||||||
|
state.search.loading = true;
|
||||||
|
state.search.results = None;
|
||||||
|
state.search.fed_tracks.clear();
|
||||||
|
state.search.fed_artists.clear();
|
||||||
|
state.search.fed_loading = false;
|
||||||
|
state.active_tab = crate::app::state::Tab::Global;
|
||||||
|
if let Some(crate::app::state::GlobalView::Search { cursor }) = state.global.stack.last_mut() {
|
||||||
|
*cursor = 0;
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.global
|
||||||
|
.stack
|
||||||
|
.push(crate::app::state::GlobalView::Search { cursor: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let similarity = Arc::clone(&runtime.similarity);
|
||||||
|
let tx = runtime.event_tx.clone();
|
||||||
|
let track_id = track.id;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let result = similarity
|
||||||
|
.search_track(track_id, 49)
|
||||||
|
.map(|(matches, query)| (matches, query));
|
||||||
|
let (result, query) = match result {
|
||||||
|
Ok((results, query)) => (Ok(results), Some(query)),
|
||||||
|
Err(err) => (Err(format!("{err:#}")), None),
|
||||||
|
};
|
||||||
|
let _ = tx.send(AppEvent::SimilaritySearchLoaded { seq, result, query });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Refresh only the local-library half of an already open search.
|
/// Refresh only the local-library half of an already open search.
|
||||||
///
|
///
|
||||||
/// Library/device sync notifications can arrive while federated search
|
/// Library/device sync notifications can arrive while federated search
|
||||||
@@ -142,6 +204,9 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
|||||||
/// federation rows and bump the shared sequence, causing valid network
|
/// federation rows and bump the shared sequence, causing valid network
|
||||||
/// responses to be dropped or flicker away.
|
/// responses to be dropped or flicker away.
|
||||||
pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
|
pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
|
||||||
|
if state.search.similarity_source.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let query = state.search.query.clone();
|
let query = state.search.query.clone();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -162,7 +227,9 @@ pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
|
|||||||
/// Enter: close the line. Live commands already took effect (their view
|
/// Enter: close the line. Live commands already took effect (their view
|
||||||
/// stays open); one-shot commands execute here.
|
/// stays open); one-shot commands execute here.
|
||||||
fn commit(state: &mut AppState, runtime: &mut Runtime) {
|
fn commit(state: &mut AppState, runtime: &mut Runtime) {
|
||||||
|
let remembered = state.cmdline.input.as_str().to_string();
|
||||||
let parsed = command::parse(&state.cmdline.input);
|
let parsed = command::parse(&state.cmdline.input);
|
||||||
|
state.cmdline.remember(&remembered);
|
||||||
close(state);
|
close(state);
|
||||||
match parsed {
|
match parsed {
|
||||||
Parsed::Empty => {}
|
Parsed::Empty => {}
|
||||||
@@ -272,6 +339,7 @@ fn close(state: &mut AppState) {
|
|||||||
state.cmdline.active = false;
|
state.cmdline.active = false;
|
||||||
state.cmdline.input.clear();
|
state.cmdline.input.clear();
|
||||||
state.cmdline.live = false;
|
state.cmdline.live = false;
|
||||||
|
state.cmdline.begin_history_navigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pop the live search view if this command-line session opened it.
|
/// Pop the live search view if this command-line session opened it.
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ pub enum AppEvent {
|
|||||||
seq: u64,
|
seq: u64,
|
||||||
result: Result<SearchResults, String>,
|
result: Result<SearchResults, String>,
|
||||||
},
|
},
|
||||||
|
/// Local similar-track search completed. It uses the same sequence as
|
||||||
|
/// text search so stale pages cannot overwrite a newer request.
|
||||||
|
SimilaritySearchLoaded {
|
||||||
|
seq: u64,
|
||||||
|
result: Result<Vec<crate::similarity::SimilarTrack>, String>,
|
||||||
|
query: Option<crate::similarity::QueryVector>,
|
||||||
|
},
|
||||||
|
/// Federated candidates and diagnostics for a track-seeded search.
|
||||||
|
FedSimilaritySearchLoaded {
|
||||||
|
seq: u64,
|
||||||
|
result: Result<crate::federation::FedSimilaritySearchResults, String>,
|
||||||
|
},
|
||||||
|
SimilarityStatus(crate::similarity::SimilarityStatus),
|
||||||
|
/// `None` is emitted after clearing every stored embedding.
|
||||||
|
SimilarityProfileActivated(Option<String>),
|
||||||
/// Artwork loaded and decoded for the shared art cache.
|
/// Artwork loaded and decoded for the shared art cache.
|
||||||
ArtLoaded {
|
ArtLoaded {
|
||||||
key: String,
|
key: String,
|
||||||
@@ -56,6 +71,9 @@ pub enum AppEvent {
|
|||||||
LocalContentIdsLoaded(Result<Vec<String>, String>),
|
LocalContentIdsLoaded(Result<Vec<String>, String>),
|
||||||
/// Counts and storage footprint of the local library/database.
|
/// Counts and storage footprint of the local library/database.
|
||||||
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
|
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
|
||||||
|
MusicDirectoryValidated(Result<std::path::PathBuf, String>),
|
||||||
|
MusicDirectoryChanged(Result<crate::library::MusicRelocationStats, String>),
|
||||||
|
ListenHistoryLoaded(Result<Vec<crate::library::ListenHistoryEntry>, String>),
|
||||||
/// One content id became available locally while the UI is open.
|
/// One content id became available locally while the UI is open.
|
||||||
LocalContentAvailable {
|
LocalContentAvailable {
|
||||||
content_id: String,
|
content_id: String,
|
||||||
@@ -165,4 +183,14 @@ pub enum AppEvent {
|
|||||||
DevicePlayback(crate::devices::PlaybackSnapshot),
|
DevicePlayback(crate::devices::PlaybackSnapshot),
|
||||||
/// Playback command addressed to this device.
|
/// Playback command addressed to this device.
|
||||||
PlaybackCommand(crate::devices::PlaybackCommand),
|
PlaybackCommand(crate::devices::PlaybackCommand),
|
||||||
|
/// Current lifecycle/status of the federation Jam.
|
||||||
|
JamStatus(crate::jam::JamStatus),
|
||||||
|
/// Host playback snapshot received by a Jam participant.
|
||||||
|
JamPlayback(crate::devices::PlaybackSnapshot),
|
||||||
|
/// Participant command accepted by this Jam host.
|
||||||
|
JamCommand(crate::devices::PlaybackCommand),
|
||||||
|
/// Result of creating/regenerating a Jam capability.
|
||||||
|
JamInvite(Result<String, String>),
|
||||||
|
/// Result of joining a Jam capability.
|
||||||
|
JamJoined(Result<String, String>),
|
||||||
}
|
}
|
||||||
|
|||||||
+688
-46
File diff suppressed because it is too large
Load Diff
+242
-8
@@ -82,7 +82,7 @@ pub(crate) fn connected_device_rows(state: &AppState) -> Vec<ConnectedDevicePopu
|
|||||||
is_self: true,
|
is_self: true,
|
||||||
online: true,
|
online: true,
|
||||||
section: DevicePresenceSection::Online,
|
section: DevicePresenceSection::Online,
|
||||||
active: state.device_playback.role == crate::app::state::DevicePlaybackRole::Active,
|
active: state.device_playback.is_audio_owner(),
|
||||||
playing: state.player.playing,
|
playing: state.player.playing,
|
||||||
paused: state.player.paused,
|
paused: state.player.paused,
|
||||||
queue_len: state.player.queue.len(),
|
queue_len: state.player.queue.len(),
|
||||||
@@ -115,6 +115,39 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
Popup::ConfirmDelete { target, label } => {
|
Popup::ConfirmDelete { target, label } => {
|
||||||
handle_confirm_delete(state, runtime, target, label, key);
|
handle_confirm_delete(state, runtime, target, label, key);
|
||||||
}
|
}
|
||||||
|
Popup::SimilarityPrivacyConsent { enable_federation } => match key.code {
|
||||||
|
KeyCode::Enter | KeyCode::Char('y') => {
|
||||||
|
state.similarity.settings.federation_consent = true;
|
||||||
|
state.similarity.settings.enabled = true;
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::SimilarityApplySettings,
|
||||||
|
);
|
||||||
|
if enable_federation {
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::FedApplySettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {
|
||||||
|
if enable_federation {
|
||||||
|
state.federation.settings.enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.popup = Some(Popup::SimilarityPrivacyConsent { enable_federation });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Popup::ConfirmClearEmbeddings => match key.code {
|
||||||
|
KeyCode::Enter | KeyCode::Char('y') => {
|
||||||
|
super::perform_effect(state, runtime, crate::app::update::Effect::SimilarityClear)
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {}
|
||||||
|
_ => state.popup = Some(Popup::ConfirmClearEmbeddings),
|
||||||
|
},
|
||||||
Popup::LibraryFilters { cursor } => handle_library_filters(state, runtime, cursor, key),
|
Popup::LibraryFilters { cursor } => handle_library_filters(state, runtime, cursor, key),
|
||||||
Popup::TrackInfo {
|
Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
@@ -136,17 +169,52 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
||||||
_ => state.popup = Some(Popup::FedText { title, text }),
|
_ => state.popup = Some(Popup::FedText { title, text }),
|
||||||
},
|
},
|
||||||
Popup::FedCopyText { title, text, help } => match key.code {
|
Popup::FedCopyText {
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
help,
|
||||||
|
mut cursor,
|
||||||
|
} => match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => {}
|
KeyCode::Esc | KeyCode::Char('q') => {}
|
||||||
|
KeyCode::Left | KeyCode::Right | KeyCode::Tab | KeyCode::BackTab => {
|
||||||
|
cursor = usize::from(cursor == 0);
|
||||||
|
state.popup = Some(Popup::FedCopyText {
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
help,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
KeyCode::Enter if cursor == 1 => state.popup = Some(Popup::PlainText { text }),
|
||||||
|
KeyCode::Char('p') => state.popup = Some(Popup::PlainText { text }),
|
||||||
KeyCode::Enter | KeyCode::Char('c') => match copy_to_clipboard(&text) {
|
KeyCode::Enter | KeyCode::Char('c') => match copy_to_clipboard(&text) {
|
||||||
Ok(()) => state.status_message = Some("copied to clipboard".to_string()),
|
Ok(()) => state.status_message = Some("copied to clipboard".to_string()),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
state.status_message = Some(format!("copy failed: {err}"));
|
state.status_message = Some(format!("copy failed: {err}"));
|
||||||
state.popup = Some(Popup::FedCopyText { title, text, help });
|
state.popup = Some(Popup::FedCopyText {
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
help,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_ => state.popup = Some(Popup::FedCopyText { title, text, help }),
|
_ => {
|
||||||
|
state.popup = Some(Popup::FedCopyText {
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
help,
|
||||||
|
cursor,
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
Popup::PlainText { text } => match key.code {
|
||||||
|
KeyCode::Esc => {}
|
||||||
|
_ => state.popup = Some(Popup::PlainText { text }),
|
||||||
|
},
|
||||||
|
Popup::ConfirmMusicDirectory { path } => {
|
||||||
|
handle_music_directory_confirmation(state, runtime, path, key)
|
||||||
|
}
|
||||||
Popup::FederationStatusDetails {
|
Popup::FederationStatusDetails {
|
||||||
focus,
|
focus,
|
||||||
status_cursor,
|
status_cursor,
|
||||||
@@ -196,9 +264,28 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
Popup::ConnectedDevices { cursor } => {
|
Popup::ConnectedDevices { cursor } => {
|
||||||
handle_connected_devices(state, runtime, cursor, key);
|
handle_connected_devices(state, runtime, cursor, key);
|
||||||
}
|
}
|
||||||
|
Popup::ListenHistory { cursor } => handle_listen_history(state, cursor, key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_listen_history(state: &mut AppState, cursor: usize, key: KeyEvent) {
|
||||||
|
let len = match state.listen_history.as_ref() {
|
||||||
|
Some(crate::app::state::Loadable::Ready(entries)) => entries.len(),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let cursor = match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => return,
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => cursor.saturating_sub(1),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => (cursor + 1).min(len.saturating_sub(1)),
|
||||||
|
KeyCode::PageUp => cursor.saturating_sub(10),
|
||||||
|
KeyCode::PageDown => (cursor + 10).min(len.saturating_sub(1)),
|
||||||
|
KeyCode::Home | KeyCode::Char('g') => 0,
|
||||||
|
KeyCode::End | KeyCode::Char('G') => len.saturating_sub(1),
|
||||||
|
_ => cursor,
|
||||||
|
};
|
||||||
|
state.popup = Some(Popup::ListenHistory { cursor });
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_federation_status_details(
|
fn handle_federation_status_details(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
mut parent: FederationStatusPopupState,
|
mut parent: FederationStatusPopupState,
|
||||||
@@ -367,6 +454,32 @@ fn handle_connected_devices(
|
|||||||
let cursor = cursor.min(last);
|
let cursor = cursor.min(last);
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => {}
|
KeyCode::Esc | KeyCode::Char('q') => {}
|
||||||
|
KeyCode::Char('h') => {
|
||||||
|
super::perform_effect(state, runtime, crate::app::update::Effect::JamCreate);
|
||||||
|
}
|
||||||
|
KeyCode::Char('J') => {
|
||||||
|
state.popup = Some(Popup::FedInput {
|
||||||
|
field: FedInputField::JamInvite,
|
||||||
|
input: crate::app::input::LineEdit::default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
KeyCode::Char('l') if state.jam.role != crate::jam::JamRole::None => {
|
||||||
|
super::perform_effect(state, runtime, crate::app::update::Effect::JamLeave);
|
||||||
|
}
|
||||||
|
KeyCode::Char('c') => {
|
||||||
|
if let Some(invite) = state.jam.invite.as_deref() {
|
||||||
|
match copy_to_clipboard(invite) {
|
||||||
|
Ok(()) => state.status_message = Some("Jam invite copied".into()),
|
||||||
|
Err(error) => {
|
||||||
|
state.status_message = Some(format!("clipboard: {error}"));
|
||||||
|
state.popup = Some(Popup::ConnectedDevices { cursor });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.status_message = Some("only the Jam host has an invite".into());
|
||||||
|
state.popup = Some(Popup::ConnectedDevices { cursor });
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
state.popup = Some(Popup::ConnectedDevices {
|
state.popup = Some(Popup::ConnectedDevices {
|
||||||
cursor: cursor.saturating_sub(1),
|
cursor: cursor.saturating_sub(1),
|
||||||
@@ -378,6 +491,12 @@ fn handle_connected_devices(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
|
if state.device_playback.role == crate::app::state::DevicePlaybackRole::Jam {
|
||||||
|
state.status_message =
|
||||||
|
Some("leave the current Jam before switching personal devices".into());
|
||||||
|
state.popup = Some(Popup::ConnectedDevices { cursor });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if cursor == 0 {
|
if cursor == 0 {
|
||||||
super::transfer_active_to_this_device(state, runtime);
|
super::transfer_active_to_this_device(state, runtime);
|
||||||
state.status_message = Some("active playback moved to this device".into());
|
state.status_message = Some("active playback moved to this device".into());
|
||||||
@@ -451,6 +570,58 @@ fn handle_fed_input(
|
|||||||
!state.federation.settings.network_id.is_empty();
|
!state.federation.settings.network_id.is_empty();
|
||||||
super::fed_apply_settings(state, runtime);
|
super::fed_apply_settings(state, runtime);
|
||||||
}
|
}
|
||||||
|
FedInputField::MusicDirectory => {
|
||||||
|
if value.is_empty() {
|
||||||
|
state.status_message = Some("music directory is empty".into());
|
||||||
|
} else {
|
||||||
|
super::validate_music_directory(state, runtime, value.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FedInputField::SimilarityWorkers => match value.parse::<usize>() {
|
||||||
|
Ok(workers @ 1..=16) => {
|
||||||
|
state.similarity.settings.workers = workers;
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::SimilarityApplySettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.status_message =
|
||||||
|
Some("similarity workers must be a number from 1 to 16".into());
|
||||||
|
state.popup = Some(Popup::FedInput { field, input });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
FedInputField::SimilarityMinimumScore => match value.parse::<f32>() {
|
||||||
|
Ok(score) if score.is_finite() && (0.0..=1.0).contains(&score) => {
|
||||||
|
state.similarity.settings.minimum_score = score;
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::SimilarityApplySettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.status_message =
|
||||||
|
Some("minimum similarity must be a number from 0.00 to 1.00".into());
|
||||||
|
state.popup = Some(Popup::FedInput { field, input });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
FedInputField::SimilarityMaxTracksPerArtist => match value.parse::<usize>() {
|
||||||
|
Ok(limit @ 1..=50) => {
|
||||||
|
state.similarity.settings.max_tracks_per_artist = limit;
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::SimilarityApplySettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.status_message =
|
||||||
|
Some("tracks per artist must be a number from 1 to 50".into());
|
||||||
|
state.popup = Some(Popup::FedInput { field, input });
|
||||||
|
}
|
||||||
|
},
|
||||||
FedInputField::ConnectTicket => {
|
FedInputField::ConnectTicket => {
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
state.status_message = Some("ticket is empty".into());
|
state.status_message = Some("ticket is empty".into());
|
||||||
@@ -480,6 +651,17 @@ fn handle_fed_input(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
FedInputField::JamInvite => {
|
||||||
|
if value.is_empty() {
|
||||||
|
state.status_message = Some("Jam invite is empty".into());
|
||||||
|
} else {
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::JamJoin(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -489,6 +671,31 @@ fn handle_fed_input(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_music_directory_confirmation(
|
||||||
|
state: &mut AppState,
|
||||||
|
runtime: &mut Runtime,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
key: KeyEvent,
|
||||||
|
) {
|
||||||
|
let move_existing = match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => return,
|
||||||
|
KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => true,
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('N') => false,
|
||||||
|
_ => {
|
||||||
|
state.popup = Some(Popup::ConfirmMusicDirectory { path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::ChangeMusicDirectory {
|
||||||
|
path,
|
||||||
|
move_existing,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_device_pairing(
|
fn handle_device_pairing(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
runtime: &Runtime,
|
runtime: &Runtime,
|
||||||
@@ -872,10 +1079,15 @@ fn handle_track_info(
|
|||||||
KeyCode::Char('c') => {
|
KeyCode::Char('c') => {
|
||||||
if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) {
|
if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) {
|
||||||
match crate::share::track_share_link(track) {
|
match crate::share::track_share_link(track) {
|
||||||
Some(link) => match copy_to_clipboard(&link) {
|
Some(link) => {
|
||||||
Ok(()) => state.status_message = Some("frid link copied".into()),
|
state.popup = Some(Popup::FedCopyText {
|
||||||
Err(err) => state.status_message = Some(format!("copy failed: {err}")),
|
title: "Track share link".into(),
|
||||||
},
|
text: link,
|
||||||
|
help: "Copy the link with a clipboard helper, or show it as one clean terminal line for mouse selection.".into(),
|
||||||
|
cursor: 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
None => state.status_message = Some("no content id for this track yet".into()),
|
None => state.status_message = Some("no content id for this track yet".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -885,6 +1097,28 @@ fn handle_track_info(
|
|||||||
scroll,
|
scroll,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !state.similarity.settings.enabled {
|
||||||
|
state.status_message = Some("enable Similarity search in Settings first".into());
|
||||||
|
state.popup = Some(Popup::TrackInfo {
|
||||||
|
tracks,
|
||||||
|
cursor,
|
||||||
|
scroll,
|
||||||
|
});
|
||||||
|
} else if track.id < 0 || track.file_path.is_empty() {
|
||||||
|
state.status_message = Some("similarity search starts from a local track".into());
|
||||||
|
state.popup = Some(Popup::TrackInfo {
|
||||||
|
tracks,
|
||||||
|
cursor,
|
||||||
|
scroll,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
super::cmdline::schedule_similarity_search(state, runtime, track);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
state.popup = Some(Popup::TrackInfo {
|
state.popup = Some(Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
|
|||||||
+332
-1
@@ -389,6 +389,7 @@ mod tests {
|
|||||||
featured_tracks: Vec::new(),
|
featured_tracks: Vec::new(),
|
||||||
};
|
};
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
|
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
|
||||||
state.artist_fed_views.insert(
|
state.artist_fed_views.insert(
|
||||||
detail.id,
|
detail.id,
|
||||||
Loadable::Ready(crate::federation::FedArtistCard {
|
Loadable::Ready(crate::federation::FedArtistCard {
|
||||||
@@ -508,6 +509,8 @@ pub enum TrackSelectionScope {
|
|||||||
Release(i64),
|
Release(i64),
|
||||||
Playlist(i64),
|
Playlist(i64),
|
||||||
Queue,
|
Queue,
|
||||||
|
/// The unified local + federated similar-track result list.
|
||||||
|
SimilaritySearch,
|
||||||
/// The federated section of the search results (its tracks).
|
/// The federated section of the search results (its tracks).
|
||||||
FedSearch,
|
FedSearch,
|
||||||
/// The tracklist of the open federated release view.
|
/// The tracklist of the open federated release view.
|
||||||
@@ -686,6 +689,10 @@ pub enum Popup {
|
|||||||
},
|
},
|
||||||
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
|
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
|
||||||
ConfirmDelete { target: DeleteTarget, label: String },
|
ConfirmDelete { target: DeleteTarget, label: String },
|
||||||
|
/// Enabling network similarity reveals an embedding query to peers.
|
||||||
|
SimilarityPrivacyConsent { enable_federation: bool },
|
||||||
|
/// Derived data is safe to recreate but potentially expensive.
|
||||||
|
ConfirmClearEmbeddings,
|
||||||
/// Library-home filters. Cursor is kept for the next filters added here.
|
/// Library-home filters. Cursor is kept for the next filters added here.
|
||||||
LibraryFilters { cursor: usize },
|
LibraryFilters { cursor: usize },
|
||||||
/// Track metadata viewer; left/right switch between selected tracks.
|
/// Track metadata viewer; left/right switch between selected tracks.
|
||||||
@@ -716,7 +723,16 @@ pub enum Popup {
|
|||||||
title: String,
|
title: String,
|
||||||
text: String,
|
text: String,
|
||||||
help: String,
|
help: String,
|
||||||
|
cursor: usize,
|
||||||
},
|
},
|
||||||
|
/// A copy-friendly terminal screen containing exactly one logical line.
|
||||||
|
/// The app loop temporarily leaves the alternate screen while this is
|
||||||
|
/// active so terminal selection does not acquire TUI borders or hard
|
||||||
|
/// line breaks.
|
||||||
|
PlainText { text: String },
|
||||||
|
/// The destination is already write-tested; Enter/y migrates managed
|
||||||
|
/// files, while n changes only the destination for future downloads.
|
||||||
|
ConfirmMusicDirectory { path: std::path::PathBuf },
|
||||||
/// Incoming trusted-device pairing request.
|
/// Incoming trusted-device pairing request.
|
||||||
DevicePairing {
|
DevicePairing {
|
||||||
request_id: String,
|
request_id: String,
|
||||||
@@ -732,6 +748,8 @@ pub enum Popup {
|
|||||||
ConfirmDeviceLeave,
|
ConfirmDeviceLeave,
|
||||||
/// Connected playback devices and their current role/status.
|
/// Connected playback devices and their current role/status.
|
||||||
ConnectedDevices { cursor: usize },
|
ConnectedDevices { cursor: usize },
|
||||||
|
/// Qualified listening history from every trusted device.
|
||||||
|
ListenHistory { cursor: usize },
|
||||||
/// Full federation, transport and device status details.
|
/// Full federation, transport and device status details.
|
||||||
FederationStatusDetails {
|
FederationStatusDetails {
|
||||||
focus: StatusDetailFocus,
|
focus: StatusDetailFocus,
|
||||||
@@ -799,24 +817,46 @@ impl StatusDetailFocus {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum FedInputField {
|
pub enum FedInputField {
|
||||||
|
MusicDirectory,
|
||||||
|
SimilarityWorkers,
|
||||||
|
SimilarityMinimumScore,
|
||||||
|
SimilarityMaxTracksPerArtist,
|
||||||
NetworkId,
|
NetworkId,
|
||||||
ConnectTicket,
|
ConnectTicket,
|
||||||
DeviceName,
|
DeviceName,
|
||||||
ConnectInvite,
|
ConnectInvite,
|
||||||
|
JamInvite,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FedInputField {
|
impl FedInputField {
|
||||||
pub fn title(self) -> &'static str {
|
pub fn title(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
FedInputField::MusicDirectory => "Music save directory",
|
||||||
|
FedInputField::SimilarityWorkers => "Similarity background workers",
|
||||||
|
FedInputField::SimilarityMinimumScore => "Minimum similarity score",
|
||||||
|
FedInputField::SimilarityMaxTracksPerArtist => "Tracks per artist",
|
||||||
FedInputField::NetworkId => "Network ID",
|
FedInputField::NetworkId => "Network ID",
|
||||||
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
|
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
|
||||||
FedInputField::DeviceName => "Device name",
|
FedInputField::DeviceName => "Device name",
|
||||||
FedInputField::ConnectInvite => "Connect device (paste frid://i invite)",
|
FedInputField::ConnectInvite => "Connect device (paste frid://i invite)",
|
||||||
|
FedInputField::JamInvite => "Join Jam (paste frid://j invite)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn help(self) -> &'static str {
|
pub fn help(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
FedInputField::MusicDirectory => {
|
||||||
|
"Federated tracks saved to your library use this directory. The directory is checked for write access before anything changes."
|
||||||
|
}
|
||||||
|
FedInputField::SimilarityWorkers => {
|
||||||
|
"Enter the maximum number of tracks processed in parallel, from 1 to 16. The change takes effect immediately."
|
||||||
|
}
|
||||||
|
FedInputField::SimilarityMinimumScore => {
|
||||||
|
"Enter the minimum cosine similarity from 0.00 to 1.00. Lower values show broader matches; higher values hide weak matches. Embeddings are not recalculated."
|
||||||
|
}
|
||||||
|
FedInputField::SimilarityMaxTracksPerArtist => {
|
||||||
|
"Enter how many tracks by one primary artist may appear in similarity results, from 1 to 50. Embeddings are not recalculated."
|
||||||
|
}
|
||||||
FedInputField::NetworkId => {
|
FedInputField::NetworkId => {
|
||||||
"A unique network id. It must match exactly on every client that should see and connect to the same peers."
|
"A unique network id. It must match exactly on every client that should see and connect to the same peers."
|
||||||
}
|
}
|
||||||
@@ -829,6 +869,9 @@ impl FedInputField {
|
|||||||
FedInputField::ConnectInvite => {
|
FedInputField::ConnectInvite => {
|
||||||
"Paste a frid:// invite generated by another client to add this device to its sync group."
|
"Paste a frid:// invite generated by another client to add this device to its sync group."
|
||||||
}
|
}
|
||||||
|
FedInputField::JamInvite => {
|
||||||
|
"Paste a frid://j capability to control the host player for this Jam only."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,6 +887,29 @@ pub enum FedRow {
|
|||||||
Connect,
|
Connect,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SimilarityRow {
|
||||||
|
Toggle,
|
||||||
|
Model,
|
||||||
|
Profile,
|
||||||
|
MinimumScore,
|
||||||
|
MaxTracksPerArtist,
|
||||||
|
Workers,
|
||||||
|
Clear,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimilarityRow {
|
||||||
|
pub const ALL: [SimilarityRow; 7] = [
|
||||||
|
SimilarityRow::Toggle,
|
||||||
|
SimilarityRow::Model,
|
||||||
|
SimilarityRow::Profile,
|
||||||
|
SimilarityRow::MinimumScore,
|
||||||
|
SimilarityRow::MaxTracksPerArtist,
|
||||||
|
SimilarityRow::Workers,
|
||||||
|
SimilarityRow::Clear,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
impl FedRow {
|
impl FedRow {
|
||||||
pub const ALL: [FedRow; 6] = [
|
pub const ALL: [FedRow; 6] = [
|
||||||
FedRow::Toggle,
|
FedRow::Toggle,
|
||||||
@@ -860,6 +926,8 @@ impl FedRow {
|
|||||||
/// the config directory.
|
/// the config directory.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SettingsRow {
|
pub enum SettingsRow {
|
||||||
|
MusicDirectory,
|
||||||
|
Similarity(SimilarityRow),
|
||||||
Federation(FedRow),
|
Federation(FedRow),
|
||||||
StatusDetails,
|
StatusDetails,
|
||||||
DeviceName,
|
DeviceName,
|
||||||
@@ -997,7 +1065,8 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = vec![SettingsRow::MusicDirectory];
|
||||||
|
rows.extend(SimilarityRow::ALL.into_iter().map(SettingsRow::Similarity));
|
||||||
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
|
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
|
||||||
rows.push(SettingsRow::DeviceName);
|
rows.push(SettingsRow::DeviceName);
|
||||||
rows.push(SettingsRow::DeviceInvite);
|
rows.push(SettingsRow::DeviceInvite);
|
||||||
@@ -1036,6 +1105,12 @@ pub struct FederationTab {
|
|||||||
pub device_syncing: bool,
|
pub device_syncing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SimilarityTab {
|
||||||
|
pub settings: crate::config::settings::SimilaritySettings,
|
||||||
|
pub status: crate::similarity::SimilarityStatus,
|
||||||
|
}
|
||||||
|
|
||||||
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
||||||
/// through likes, not direct adds).
|
/// through likes, not direct adds).
|
||||||
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
|
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
|
||||||
@@ -1057,9 +1132,180 @@ pub struct Cmdline {
|
|||||||
/// A live command (search) applied effects during this session; Esc
|
/// A live command (search) applied effects during this session; Esc
|
||||||
/// undoes them, Enter keeps them.
|
/// undoes them, Enter keeps them.
|
||||||
pub live: bool,
|
pub live: bool,
|
||||||
|
/// Commands committed during this process, oldest first.
|
||||||
|
pub history: Vec<String>,
|
||||||
|
/// Entry currently recalled with Up/Down. `None` is the editable draft
|
||||||
|
/// after the newest history entry.
|
||||||
|
history_index: Option<usize>,
|
||||||
|
history_draft: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmdline {
|
||||||
|
const HISTORY_LIMIT: usize = 100;
|
||||||
|
|
||||||
|
pub fn begin_history_navigation(&mut self) {
|
||||||
|
self.history_index = None;
|
||||||
|
self.history_draft.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remember(&mut self, value: &str) {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.history.last().is_none_or(|last| last != value) {
|
||||||
|
self.history.push(value.to_string());
|
||||||
|
if self.history.len() > Self::HISTORY_LIMIT {
|
||||||
|
self.history.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.begin_history_navigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn history_previous(&mut self) -> bool {
|
||||||
|
if self.history.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let index = match self.history_index {
|
||||||
|
Some(index) => index.saturating_sub(1),
|
||||||
|
None => {
|
||||||
|
self.history_draft = self.input.as_str().to_string();
|
||||||
|
self.history.len() - 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.history_index = Some(index);
|
||||||
|
self.input = LineEdit::new(self.history[index].clone());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn history_next(&mut self) -> bool {
|
||||||
|
let Some(index) = self.history_index else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if index + 1 < self.history.len() {
|
||||||
|
self.history_index = Some(index + 1);
|
||||||
|
self.input = LineEdit::new(self.history[index + 1].clone());
|
||||||
|
} else {
|
||||||
|
self.history_index = None;
|
||||||
|
self.input = LineEdit::new(std::mem::take(&mut self.history_draft));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod cmdline_history_tests {
|
||||||
|
use super::Cmdline;
|
||||||
|
use crate::app::input::LineEdit;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_walks_oldest_and_restores_the_draft() {
|
||||||
|
let mut cmdline = Cmdline::default();
|
||||||
|
cmdline.remember("volume 20");
|
||||||
|
cmdline.remember("/ambient");
|
||||||
|
cmdline.input = LineEdit::new("unfinished");
|
||||||
|
|
||||||
|
assert!(cmdline.history_previous());
|
||||||
|
assert_eq!(cmdline.input.as_str(), "/ambient");
|
||||||
|
assert!(cmdline.history_previous());
|
||||||
|
assert_eq!(cmdline.input.as_str(), "volume 20");
|
||||||
|
assert!(cmdline.history_previous());
|
||||||
|
assert_eq!(cmdline.input.as_str(), "volume 20");
|
||||||
|
assert!(cmdline.history_next());
|
||||||
|
assert_eq!(cmdline.input.as_str(), "/ambient");
|
||||||
|
assert!(cmdline.history_next());
|
||||||
|
assert_eq!(cmdline.input.as_str(), "unfinished");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_deduplicates_consecutive_commands() {
|
||||||
|
let mut cmdline = Cmdline::default();
|
||||||
|
cmdline.remember("q");
|
||||||
|
cmdline.remember(" q ");
|
||||||
|
assert_eq!(cmdline.history, vec!["q"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live search state driven by the `:/query` command.
|
/// Live search state driven by the `:/query` command.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SimilaritySearchHit {
|
||||||
|
Local {
|
||||||
|
track: TrackItem,
|
||||||
|
score: f32,
|
||||||
|
embedding_signature: [u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES],
|
||||||
|
},
|
||||||
|
Federated {
|
||||||
|
track: crate::federation::FedTrack,
|
||||||
|
score: f32,
|
||||||
|
embedding_signature: Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimilaritySearchHit {
|
||||||
|
pub fn score(&self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Local { score, .. } | Self::Federated { score, .. } => *score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn embedding_signature(
|
||||||
|
&self,
|
||||||
|
) -> Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]> {
|
||||||
|
match self {
|
||||||
|
Self::Local {
|
||||||
|
embedding_signature,
|
||||||
|
..
|
||||||
|
} => Some(*embedding_signature),
|
||||||
|
Self::Federated {
|
||||||
|
embedding_signature,
|
||||||
|
..
|
||||||
|
} => *embedding_signature,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_item(&self) -> TrackItem {
|
||||||
|
match self {
|
||||||
|
Self::Local { track, .. } => track.clone(),
|
||||||
|
Self::Federated { track, .. } => crate::federation::pending_track(track),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn federated_track(&self) -> Option<&crate::federation::FedTrack> {
|
||||||
|
match self {
|
||||||
|
Self::Federated { track, .. } => Some(track),
|
||||||
|
Self::Local { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn primary_artist_key(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Local { track, .. } => track
|
||||||
|
.artists
|
||||||
|
.first()
|
||||||
|
.map(|artist| music_dht::normalize_name(&artist.name))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Self::Federated { track, .. } => track
|
||||||
|
.artist_names
|
||||||
|
.first()
|
||||||
|
.map(|artist| music_dht::normalize_name(artist))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn content_key(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Local { track, .. } => track
|
||||||
|
.content_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("local:{}", track.id)),
|
||||||
|
Self::Federated { track, .. } => track
|
||||||
|
.content_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("remote:{}:{}", track.owner, track.item_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct SearchState {
|
pub struct SearchState {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
@@ -1072,6 +1318,62 @@ pub struct SearchState {
|
|||||||
/// from the artist names of matching tracks.
|
/// from the artist names of matching tracks.
|
||||||
pub fed_artists: Vec<crate::federation::FedArtistHit>,
|
pub fed_artists: Vec<crate::federation::FedArtistHit>,
|
||||||
pub fed_loading: bool,
|
pub fed_loading: bool,
|
||||||
|
/// Present only for a track-seeded search; text-search refreshes must not
|
||||||
|
/// replace this page with a title query.
|
||||||
|
pub similarity_source: Option<i64>,
|
||||||
|
/// The source is pinned at row zero; candidates below it are globally
|
||||||
|
/// ranked across the local library and federation.
|
||||||
|
pub similarity_source_track: Option<TrackItem>,
|
||||||
|
pub similarity_tracks: Vec<SimilaritySearchHit>,
|
||||||
|
pub similarity_stats: Option<crate::federation::SimilaritySearchStats>,
|
||||||
|
pub similarity_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchState {
|
||||||
|
pub fn similarity_len(&self) -> usize {
|
||||||
|
usize::from(self.similarity_source_track.is_some()) + self.similarity_tracks.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_track(&self, index: usize) -> Option<TrackItem> {
|
||||||
|
if index == 0 {
|
||||||
|
return self.similarity_source_track.clone();
|
||||||
|
}
|
||||||
|
self.similarity_tracks
|
||||||
|
.get(index.checked_sub(1)?)
|
||||||
|
.map(SimilaritySearchHit::track_item)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_fed_track(&self, index: usize) -> Option<&crate::federation::FedTrack> {
|
||||||
|
self.similarity_tracks
|
||||||
|
.get(index.checked_sub(1)?)?
|
||||||
|
.federated_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_key(&self, index: usize) -> Option<String> {
|
||||||
|
if index == 0 {
|
||||||
|
return self
|
||||||
|
.similarity_source_track
|
||||||
|
.as_ref()
|
||||||
|
.map(|track| format!("source:{}", track.id));
|
||||||
|
}
|
||||||
|
self.similarity_tracks
|
||||||
|
.get(index.checked_sub(1)?)
|
||||||
|
.map(SimilaritySearchHit::content_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_index_for_key(&self, key: &str) -> Option<usize> {
|
||||||
|
if self
|
||||||
|
.similarity_source_track
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|track| key == format!("source:{}", track.id))
|
||||||
|
{
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
self.similarity_tracks
|
||||||
|
.iter()
|
||||||
|
.position(|hit| hit.content_key() == key)
|
||||||
|
.map(|index| index + 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@@ -1162,6 +1464,9 @@ impl RepeatMode {
|
|||||||
pub struct PlayerBar {
|
pub struct PlayerBar {
|
||||||
pub queue: Vec<TrackItem>,
|
pub queue: Vec<TrackItem>,
|
||||||
pub queue_pos: usize,
|
pub queue_pos: usize,
|
||||||
|
/// Exclusive end of the contiguous "play next" block built by
|
||||||
|
/// sequential `a` actions. New `a` additions are inserted here.
|
||||||
|
pub play_next_end: Option<usize>,
|
||||||
pub current: Option<TrackItem>,
|
pub current: Option<TrackItem>,
|
||||||
/// A track is loaded (playing or paused); false = stopped.
|
/// A track is loaded (playing or paused); false = stopped.
|
||||||
pub playing: bool,
|
pub playing: bool,
|
||||||
@@ -1170,6 +1475,8 @@ pub struct PlayerBar {
|
|||||||
pub audio_analysis: crate::player::AudioAnalysisSnapshot,
|
pub audio_analysis: crate::player::AudioAnalysisSnapshot,
|
||||||
/// Epoch seconds when the current track started (for history reports).
|
/// Epoch seconds when the current track started (for history reports).
|
||||||
pub track_started_at: Option<i64>,
|
pub track_started_at: Option<i64>,
|
||||||
|
/// Stable id reused for every report of the current playback session.
|
||||||
|
pub listen_id: Option<String>,
|
||||||
/// Queue index already enqueued in the audio thread for gapless play.
|
/// Queue index already enqueued in the audio thread for gapless play.
|
||||||
pub prefetched_pos: Option<usize>,
|
pub prefetched_pos: Option<usize>,
|
||||||
pub volume: u8,
|
pub volume: u8,
|
||||||
@@ -1185,12 +1492,14 @@ impl Default for PlayerBar {
|
|||||||
Self {
|
Self {
|
||||||
queue: Vec::new(),
|
queue: Vec::new(),
|
||||||
queue_pos: 0,
|
queue_pos: 0,
|
||||||
|
play_next_end: None,
|
||||||
current: None,
|
current: None,
|
||||||
playing: false,
|
playing: false,
|
||||||
paused: false,
|
paused: false,
|
||||||
position_secs: 0.0,
|
position_secs: 0.0,
|
||||||
audio_analysis: crate::player::AudioAnalysisSnapshot::default(),
|
audio_analysis: crate::player::AudioAnalysisSnapshot::default(),
|
||||||
track_started_at: None,
|
track_started_at: None,
|
||||||
|
listen_id: None,
|
||||||
prefetched_pos: None,
|
prefetched_pos: None,
|
||||||
original_order: None,
|
original_order: None,
|
||||||
volume: 80,
|
volume: 80,
|
||||||
@@ -1205,6 +1514,7 @@ pub enum DevicePlaybackRole {
|
|||||||
#[default]
|
#[default]
|
||||||
Active,
|
Active,
|
||||||
Control,
|
Control,
|
||||||
|
Jam,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DevicePlaybackRole {
|
impl DevicePlaybackRole {
|
||||||
@@ -1212,6 +1522,7 @@ impl DevicePlaybackRole {
|
|||||||
match self {
|
match self {
|
||||||
DevicePlaybackRole::Active => "active",
|
DevicePlaybackRole::Active => "active",
|
||||||
DevicePlaybackRole::Control => "control",
|
DevicePlaybackRole::Control => "control",
|
||||||
|
DevicePlaybackRole::Jam => "jam",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1227,11 +1538,25 @@ pub struct DevicePlaybackState {
|
|||||||
pub local_idle_since_ms: Option<i64>,
|
pub local_idle_since_ms: Option<i64>,
|
||||||
pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>,
|
pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>,
|
||||||
pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>,
|
pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>,
|
||||||
|
pub jam_host: bool,
|
||||||
|
/// A freshly started TUI owns playback by protocol. The first active
|
||||||
|
/// snapshot discovered during startup is imported and handed off here.
|
||||||
|
pub startup_takeover_pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DevicePlaybackState {
|
impl DevicePlaybackState {
|
||||||
pub fn is_control(&self) -> bool {
|
pub fn is_control(&self) -> bool {
|
||||||
self.role == DevicePlaybackRole::Control
|
self.role == DevicePlaybackRole::Control
|
||||||
|
|| (self.role == DevicePlaybackRole::Jam && !self.jam_host)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_personal_control(&self) -> bool {
|
||||||
|
self.role == DevicePlaybackRole::Control
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_audio_owner(&self) -> bool {
|
||||||
|
self.role == DevicePlaybackRole::Active
|
||||||
|
|| (self.role == DevicePlaybackRole::Jam && self.jam_host)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn active_label(&self) -> String {
|
pub fn active_label(&self) -> String {
|
||||||
@@ -1257,8 +1582,12 @@ pub struct AppState {
|
|||||||
pub status_message: Option<String>,
|
pub status_message: Option<String>,
|
||||||
pub spinner_frame: usize,
|
pub spinner_frame: usize,
|
||||||
pub settings_cursor: usize,
|
pub settings_cursor: usize,
|
||||||
|
/// Root for music permanently downloaded from federation peers.
|
||||||
|
pub music_dir: std::path::PathBuf,
|
||||||
|
pub music_dir_changing: bool,
|
||||||
pub player: PlayerBar,
|
pub player: PlayerBar,
|
||||||
pub device_playback: DevicePlaybackState,
|
pub device_playback: DevicePlaybackState,
|
||||||
|
pub jam: crate::jam::JamStatus,
|
||||||
pub visualizer: crate::visualizer::VisualizerState,
|
pub visualizer: crate::visualizer::VisualizerState,
|
||||||
pub global: GlobalTab,
|
pub global: GlobalTab,
|
||||||
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
|
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
|
||||||
@@ -1277,9 +1606,11 @@ pub struct AppState {
|
|||||||
pub likes_loaded: bool,
|
pub likes_loaded: bool,
|
||||||
pub local_content_ids_loaded: bool,
|
pub local_content_ids_loaded: bool,
|
||||||
pub local_library_stats: Option<Loadable<crate::library::LocalLibraryStats>>,
|
pub local_library_stats: Option<Loadable<crate::library::LocalLibraryStats>>,
|
||||||
|
pub listen_history: Option<Loadable<Vec<crate::library::ListenHistoryEntry>>>,
|
||||||
pub logs: LogsTab,
|
pub logs: LogsTab,
|
||||||
pub queue_tab: QueueTab,
|
pub queue_tab: QueueTab,
|
||||||
pub federation: FederationTab,
|
pub federation: FederationTab,
|
||||||
|
pub similarity: SimilarityTab,
|
||||||
/// The one federated artist card being viewed (name + loading state);
|
/// The one federated artist card being viewed (name + loading state);
|
||||||
/// opening another card replaces it.
|
/// opening another card replaces it.
|
||||||
pub fed_artist_view: Option<(String, Loadable<crate::federation::FedArtistCard>)>,
|
pub fed_artist_view: Option<(String, Loadable<crate::federation::FedArtistCard>)>,
|
||||||
|
|||||||
+345
-43
@@ -48,10 +48,24 @@ pub enum Effect {
|
|||||||
},
|
},
|
||||||
/// Queue/options changed without a direct audio engine action.
|
/// Queue/options changed without a direct audio engine action.
|
||||||
PlaybackQueueChanged,
|
PlaybackQueueChanged,
|
||||||
|
/// Queue order changed. A decoded gapless-next source must be discarded
|
||||||
|
/// by restarting the current source at its current position.
|
||||||
|
QueueOrderChanged {
|
||||||
|
restart_current: bool,
|
||||||
|
},
|
||||||
/// Persist and apply a Local / My / Global source-mode change.
|
/// Persist and apply a Local / My / Global source-mode change.
|
||||||
SourceModeChanged,
|
SourceModeChanged,
|
||||||
|
/// Switch the permanent federation-download root, optionally relocating
|
||||||
|
/// files managed under the previous root first.
|
||||||
|
ChangeMusicDirectory {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
move_existing: bool,
|
||||||
|
},
|
||||||
/// Persist the federation settings and start/stop the node.
|
/// Persist the federation settings and start/stop the node.
|
||||||
FedApplySettings,
|
FedApplySettings,
|
||||||
|
/// Persist/apply embedding model, profile, worker or enable changes.
|
||||||
|
SimilarityApplySettings,
|
||||||
|
SimilarityClear,
|
||||||
/// Force an immediate library publish into the DHT.
|
/// Force an immediate library publish into the DHT.
|
||||||
FedSyncNow,
|
FedSyncNow,
|
||||||
/// Fetch this peer's ticket and show it in a popup.
|
/// Fetch this peer's ticket and show it in a popup.
|
||||||
@@ -68,6 +82,12 @@ pub enum Effect {
|
|||||||
DeviceRevoke(String),
|
DeviceRevoke(String),
|
||||||
/// Leave the current personal-device group after publishing self-revoke.
|
/// Leave the current personal-device group after publishing self-revoke.
|
||||||
DeviceLeaveGroup,
|
DeviceLeaveGroup,
|
||||||
|
/// Create or regenerate the runtime Jam capability.
|
||||||
|
JamCreate,
|
||||||
|
/// Join a federation Jam by capability.
|
||||||
|
JamJoin(String),
|
||||||
|
/// Leave the current Jam without changing personal-device state.
|
||||||
|
JamLeave,
|
||||||
/// Assemble the federated artist card (fan-out to the owning peers).
|
/// Assemble the federated artist card (fan-out to the owning peers).
|
||||||
FedOpenArtist(String),
|
FedOpenArtist(String),
|
||||||
/// Download federated tracks into the local library, one by one.
|
/// Download federated tracks into the local library, one by one.
|
||||||
@@ -82,6 +102,8 @@ pub enum Effect {
|
|||||||
OpenVisualizerEditor {
|
OpenVisualizerEditor {
|
||||||
path: std::path::PathBuf,
|
path: std::path::PathBuf,
|
||||||
},
|
},
|
||||||
|
/// Load qualified listening history without blocking the UI thread.
|
||||||
|
LoadListenHistory,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||||
@@ -114,6 +136,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
state.popup = Some(super::state::Popup::ConnectedDevices { cursor: 0 });
|
state.popup = Some(super::state::Popup::ConnectedDevices { cursor: 0 });
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
Action::OpenListenHistory => {
|
||||||
|
state.popup = Some(super::state::Popup::ListenHistory { cursor: 0 });
|
||||||
|
state.listen_history = Some(Loadable::Loading);
|
||||||
|
Some(Effect::LoadListenHistory)
|
||||||
|
}
|
||||||
Action::NextTab => {
|
Action::NextTab => {
|
||||||
switch_tab(state, state.active_tab.next());
|
switch_tab(state, state.active_tab.next());
|
||||||
None
|
None
|
||||||
@@ -167,6 +194,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
Action::ToggleShuffle => {
|
Action::ToggleShuffle => {
|
||||||
state.player.shuffle = !state.player.shuffle;
|
state.player.shuffle = !state.player.shuffle;
|
||||||
|
state.player.play_next_end = None;
|
||||||
// Shuffle physically reorders the unplayed tail, so the Queue
|
// Shuffle physically reorders the unplayed tail, so the Queue
|
||||||
// tab always shows the real upcoming order; turning it off
|
// tab always shows the real upcoming order; turning it off
|
||||||
// restores the original ordering.
|
// restores the original ordering.
|
||||||
@@ -262,6 +290,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
Action::OpenCommandLine => {
|
Action::OpenCommandLine => {
|
||||||
state.cmdline.active = true;
|
state.cmdline.active = true;
|
||||||
state.cmdline.input.clear();
|
state.cmdline.input.clear();
|
||||||
|
state.cmdline.begin_history_navigation();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Action::OpenSearch => {
|
Action::OpenSearch => {
|
||||||
@@ -270,6 +299,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
state.cmdline.active = true;
|
state.cmdline.active = true;
|
||||||
state.cmdline.input = crate::app::input::LineEdit::new("/");
|
state.cmdline.input = crate::app::input::LineEdit::new("/");
|
||||||
state.cmdline.live = true;
|
state.cmdline.live = true;
|
||||||
|
state.cmdline.begin_history_navigation();
|
||||||
state.search = SearchState::default();
|
state.search = SearchState::default();
|
||||||
state.active_tab = Tab::Global;
|
state.active_tab = Tab::Global;
|
||||||
if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
|
if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
|
||||||
@@ -352,12 +382,14 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
open_track_info(state, tracks, "no track selected")
|
open_track_info(state, tracks, "no track selected")
|
||||||
}
|
}
|
||||||
Action::OpenCurrentTrackInfo => {
|
Action::OpenCurrentTrackInfo => {
|
||||||
let tracks = state.player.current.clone().into_iter().collect();
|
let tracks = current_track_for_info(state).into_iter().collect();
|
||||||
open_track_info(state, tracks, "nothing playing")
|
open_track_info(state, tracks, "nothing playing")
|
||||||
}
|
}
|
||||||
Action::RemoveFromQueue => remove_selected_from_queue(state),
|
Action::RemoveFromQueue => remove_selected_from_queue(state),
|
||||||
Action::QueueAddNext => queue_add(state, true),
|
Action::QueueAddNext => queue_add(state, true),
|
||||||
Action::QueueAddLast => queue_add(state, false),
|
Action::QueueAddLast => queue_add(state, false),
|
||||||
|
Action::MoveQueueUp => move_selected_queue(state, true),
|
||||||
|
Action::MoveQueueDown => move_selected_queue(state, false),
|
||||||
Action::GoToRelease => {
|
Action::GoToRelease => {
|
||||||
let track = selected_track(state).or_else(|| state.player.current.clone());
|
let track = selected_track(state).or_else(|| state.player.current.clone());
|
||||||
match track {
|
match track {
|
||||||
@@ -405,6 +437,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
let had_tracks = !state.player.queue.is_empty();
|
let had_tracks = !state.player.queue.is_empty();
|
||||||
state.player.queue.clear();
|
state.player.queue.clear();
|
||||||
state.player.queue_pos = 0;
|
state.player.queue_pos = 0;
|
||||||
|
state.player.play_next_end = None;
|
||||||
state.player.current = None;
|
state.player.current = None;
|
||||||
state.player.playing = false;
|
state.player.playing = false;
|
||||||
state.player.paused = false;
|
state.player.paused = false;
|
||||||
@@ -427,6 +460,31 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prefer the queue's current entry because it may have been enriched or
|
||||||
|
/// replaced with a durable local track after streaming started. The player
|
||||||
|
/// keeps its original lightweight entry while that stream is active.
|
||||||
|
fn current_track_for_info(state: &AppState) -> Option<TrackItem> {
|
||||||
|
let current = state.player.current.as_ref()?;
|
||||||
|
let current_key = super::state::track_key(current);
|
||||||
|
let queued_at_position = state.player.queue.get(state.player.queue_pos);
|
||||||
|
|
||||||
|
queued_at_position
|
||||||
|
.filter(|queued| {
|
||||||
|
super::state::track_key(queued) == current_key
|
||||||
|
|| queued.id == current.id
|
||||||
|
|| current.is_fed_pending()
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
state
|
||||||
|
.player
|
||||||
|
.queue
|
||||||
|
.iter()
|
||||||
|
.find(|queued| super::state::track_key(queued) == current_key)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| Some(current.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
fn track_info_needs_fed_metadata(track: &TrackItem) -> bool {
|
fn track_info_needs_fed_metadata(track: &TrackItem) -> bool {
|
||||||
track.fed.is_some()
|
track.fed.is_some()
|
||||||
&& (track.featured_artists.is_empty()
|
&& (track.featured_artists.is_empty()
|
||||||
@@ -847,6 +905,9 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
|
|||||||
TrackSelectionScope::Queue => {
|
TrackSelectionScope::Queue => {
|
||||||
state.queue_tab.cursor = value;
|
state.queue_tab.cursor = value;
|
||||||
}
|
}
|
||||||
|
TrackSelectionScope::SimilaritySearch => {
|
||||||
|
set_view_cursor(state, value);
|
||||||
|
}
|
||||||
TrackSelectionScope::FedSearch => {
|
TrackSelectionScope::FedSearch => {
|
||||||
let base = state.search.results.as_ref().map_or(0, |r| r.len())
|
let base = state.search.results.as_ref().map_or(0, |r| r.len())
|
||||||
+ state.search.fed_artists.len();
|
+ state.search.fed_artists.len();
|
||||||
@@ -894,6 +955,14 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
|||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
GlobalView::Search { cursor } => {
|
GlobalView::Search { cursor } => {
|
||||||
|
if state.search.similarity_source.is_some() {
|
||||||
|
let len = state.search.similarity_len();
|
||||||
|
return (*cursor < len).then_some((
|
||||||
|
TrackSelectionScope::SimilaritySearch,
|
||||||
|
*cursor,
|
||||||
|
len,
|
||||||
|
));
|
||||||
|
}
|
||||||
// Only the federated tracks section is selectable here.
|
// Only the federated tracks section is selectable here.
|
||||||
let base = state.search.results.as_ref().map_or(0, |r| r.len())
|
let base = state.search.results.as_ref().map_or(0, |r| r.len())
|
||||||
+ state.search.fed_artists.len();
|
+ state.search.fed_artists.len();
|
||||||
@@ -986,6 +1055,20 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, V
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
|
pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
|
||||||
|
if state.active_tab == Tab::Global
|
||||||
|
&& state.search.similarity_source.is_some()
|
||||||
|
&& let Some(GlobalView::Search { cursor }) = state.global.stack.last()
|
||||||
|
{
|
||||||
|
let len = state.search.similarity_len();
|
||||||
|
let indices = state
|
||||||
|
.track_selection
|
||||||
|
.indices(&TrackSelectionScope::SimilaritySearch, len)
|
||||||
|
.unwrap_or_else(|| vec![(*cursor).min(len.saturating_sub(1))]);
|
||||||
|
return indices
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|index| state.search.similarity_track(index))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
// Federated contexts produce queueable placeholders that behave like
|
// Federated contexts produce queueable placeholders that behave like
|
||||||
// regular tracks (queue, info, playback-on-demand).
|
// regular tracks (queue, info, playback-on-demand).
|
||||||
{
|
{
|
||||||
@@ -1017,6 +1100,68 @@ fn selected_queue_indices(state: &AppState) -> Vec<usize> {
|
|||||||
.unwrap_or_else(|| vec![state.queue_tab.cursor.min(state.player.queue.len() - 1)])
|
.unwrap_or_else(|| vec![state.queue_tab.cursor.min(state.player.queue.len() - 1)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn move_selected_queue(state: &mut AppState, up: bool) -> Option<Effect> {
|
||||||
|
let indices = selected_queue_indices(state);
|
||||||
|
let Some(&start) = indices.first() else {
|
||||||
|
state.status_message = Some("queue is empty".into());
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let end = *indices.last().unwrap_or(&start);
|
||||||
|
let len = state.player.queue.len();
|
||||||
|
if (up && start == 0) || (!up && end + 1 >= len) {
|
||||||
|
state.status_message = Some(if up {
|
||||||
|
"selection is already at the top of the queue".into()
|
||||||
|
} else {
|
||||||
|
"selection is already at the bottom of the queue".into()
|
||||||
|
});
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_current = state.player.queue_pos;
|
||||||
|
let new_current = if up {
|
||||||
|
let displaced = state.player.queue.remove(start - 1);
|
||||||
|
state.player.queue.insert(end, displaced);
|
||||||
|
if old_current == start - 1 {
|
||||||
|
end
|
||||||
|
} else if (start..=end).contains(&old_current) {
|
||||||
|
old_current - 1
|
||||||
|
} else {
|
||||||
|
old_current
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let displaced = state.player.queue.remove(end + 1);
|
||||||
|
state.player.queue.insert(start, displaced);
|
||||||
|
if old_current == end + 1 {
|
||||||
|
start
|
||||||
|
} else if (start..=end).contains(&old_current) {
|
||||||
|
old_current + 1
|
||||||
|
} else {
|
||||||
|
old_current
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let delta: isize = if up { -1 } else { 1 };
|
||||||
|
state.player.queue_pos = new_current;
|
||||||
|
state.queue_tab.cursor = (state.queue_tab.cursor as isize + delta) as usize;
|
||||||
|
if state
|
||||||
|
.track_selection
|
||||||
|
.is_active_for(&TrackSelectionScope::Queue)
|
||||||
|
{
|
||||||
|
state.track_selection.anchor = (state.track_selection.anchor as isize + delta) as usize;
|
||||||
|
state.track_selection.cursor = (state.track_selection.cursor as isize + delta) as usize;
|
||||||
|
}
|
||||||
|
// An explicit manual order supersedes both the temporary play-next block
|
||||||
|
// and a saved pre-shuffle order.
|
||||||
|
state.player.play_next_end = None;
|
||||||
|
state.player.original_order = None;
|
||||||
|
let restart_current = state.player.prefetched_pos.take().is_some();
|
||||||
|
state.status_message = Some(format!(
|
||||||
|
"moved {} track(s) {}",
|
||||||
|
indices.len(),
|
||||||
|
if up { "up" } else { "down" }
|
||||||
|
));
|
||||||
|
Some(Effect::QueueOrderChanged { restart_current })
|
||||||
|
}
|
||||||
|
|
||||||
fn remove_selected_from_queue(state: &mut AppState) -> Option<Effect> {
|
fn remove_selected_from_queue(state: &mut AppState) -> Option<Effect> {
|
||||||
let indices = selected_queue_indices(state);
|
let indices = selected_queue_indices(state);
|
||||||
if indices.is_empty() {
|
if indices.is_empty() {
|
||||||
@@ -1068,6 +1213,10 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|index| **index < old_queue_pos)
|
.filter(|index| **index < old_queue_pos)
|
||||||
.count();
|
.count();
|
||||||
|
if let Some(end) = state.player.play_next_end {
|
||||||
|
let removed_before_end = unique.iter().filter(|index| **index < end).count();
|
||||||
|
state.player.play_next_end = Some(end.saturating_sub(removed_before_end));
|
||||||
|
}
|
||||||
let was_loaded = state.player.playing;
|
let was_loaded = state.player.playing;
|
||||||
let was_paused = state.player.paused;
|
let was_paused = state.player.paused;
|
||||||
|
|
||||||
@@ -1092,7 +1241,9 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
|
|||||||
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
|
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
|
||||||
state.player.position_secs = 0.0;
|
state.player.position_secs = 0.0;
|
||||||
state.player.track_started_at = None;
|
state.player.track_started_at = None;
|
||||||
|
state.player.listen_id = None;
|
||||||
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
||||||
|
normalize_play_next_block(&mut state.player);
|
||||||
return QueueRemovalOutcome {
|
return QueueRemovalOutcome {
|
||||||
restart_paused: was_loaded.then_some(was_paused),
|
restart_paused: was_loaded.then_some(was_paused),
|
||||||
stop: false,
|
stop: false,
|
||||||
@@ -1120,6 +1271,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
|
|||||||
.cloned()
|
.cloned()
|
||||||
});
|
});
|
||||||
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
||||||
|
normalize_play_next_block(&mut state.player);
|
||||||
QueueRemovalOutcome {
|
QueueRemovalOutcome {
|
||||||
restart_paused: None,
|
restart_paused: None,
|
||||||
stop: false,
|
stop: false,
|
||||||
@@ -1149,6 +1301,9 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
GlobalView::Search { cursor } => {
|
GlobalView::Search { cursor } => {
|
||||||
|
if state.search.similarity_source.is_some() {
|
||||||
|
return state.search.similarity_track(*cursor);
|
||||||
|
}
|
||||||
let results = state.search.results.as_ref()?;
|
let results = state.search.results.as_ref()?;
|
||||||
let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?;
|
let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?;
|
||||||
match results.tracks.get(offset) {
|
match results.tracks.get(offset) {
|
||||||
@@ -1253,7 +1408,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
|||||||
if !tracks.is_empty() {
|
if !tracks.is_empty() {
|
||||||
let count = tracks.len();
|
let count = tracks.len();
|
||||||
let title = tracks[0].title.clone();
|
let title = tracks[0].title.clone();
|
||||||
enqueue_tracks(state, tracks, next);
|
let restart_current = enqueue_tracks(state, tracks, next);
|
||||||
state.track_selection.clear();
|
state.track_selection.clear();
|
||||||
state.status_message = Some(if count == 1 && next {
|
state.status_message = Some(if count == 1 && next {
|
||||||
format!("queued next: {title}")
|
format!("queued next: {title}")
|
||||||
@@ -1264,7 +1419,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
|||||||
} else {
|
} else {
|
||||||
format!("queued: {count} tracks")
|
format!("queued: {count} tracks")
|
||||||
});
|
});
|
||||||
return Some(Effect::PlaybackQueueChanged);
|
return Some(Effect::QueueOrderChanged { restart_current });
|
||||||
}
|
}
|
||||||
if let Some(id) = selected_release_id(state) {
|
if let Some(id) = selected_release_id(state) {
|
||||||
return Some(Effect::EnqueueRelease { id, next });
|
return Some(Effect::EnqueueRelease { id, next });
|
||||||
@@ -1358,19 +1513,22 @@ pub(crate) fn track_artist_refs(track: &TrackItem) -> Vec<crate::library::models
|
|||||||
refs
|
refs
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert tracks after the playing one (`next`) or at the end. Keeps the
|
/// Insert tracks into the stable "play next" block (`next`) or at the end.
|
||||||
/// gapless prefetch index pointing at the same track if items shift.
|
/// Returns whether a decoded gapless-next source became stale.
|
||||||
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) {
|
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) -> bool {
|
||||||
let tracks: Vec<_> = tracks
|
let tracks: Vec<_> = tracks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|track| track_allowed_by_source_mode(state, track))
|
.filter(|track| track_allowed_by_source_mode(state, track))
|
||||||
.collect();
|
.collect();
|
||||||
let player = &mut state.player;
|
let player = &mut state.player;
|
||||||
if tracks.is_empty() {
|
if tracks.is_empty() {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
let insert_at = if next && !player.queue.is_empty() {
|
let insert_at = if next && !player.queue.is_empty() {
|
||||||
(player.queue_pos + 1).min(player.queue.len())
|
player
|
||||||
|
.play_next_end
|
||||||
|
.filter(|end| *end >= player.queue_pos.saturating_add(1) && *end <= player.queue.len())
|
||||||
|
.unwrap_or_else(|| (player.queue_pos + 1).min(player.queue.len()))
|
||||||
} else if next {
|
} else if next {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -1380,14 +1538,28 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
|
|||||||
for (offset, track) in tracks.into_iter().enumerate() {
|
for (offset, track) in tracks.into_iter().enumerate() {
|
||||||
player.queue.insert(insert_at + offset, track);
|
player.queue.insert(insert_at + offset, track);
|
||||||
}
|
}
|
||||||
if let Some(prefetched) = &mut player.prefetched_pos
|
let restart_current = player
|
||||||
&& insert_at <= *prefetched
|
.prefetched_pos
|
||||||
{
|
.is_some_and(|prefetched| insert_at <= prefetched);
|
||||||
*prefetched += count;
|
if restart_current {
|
||||||
|
player.prefetched_pos = None;
|
||||||
}
|
}
|
||||||
if insert_at <= player.queue_pos && player.current.is_some() {
|
if insert_at <= player.queue_pos && player.current.is_some() {
|
||||||
player.queue_pos += count;
|
player.queue_pos += count;
|
||||||
}
|
}
|
||||||
|
if next {
|
||||||
|
player.play_next_end = Some(insert_at + count);
|
||||||
|
}
|
||||||
|
restart_current
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_play_next_block(player: &mut super::state::PlayerBar) {
|
||||||
|
if player
|
||||||
|
.play_next_end
|
||||||
|
.is_some_and(|end| end <= player.queue_pos || end > player.queue.len())
|
||||||
|
{
|
||||||
|
player.play_next_end = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manual queue navigation (n / p); the tail is pre-shuffled when shuffle
|
/// Manual queue navigation (n / p); the tail is pre-shuffled when shuffle
|
||||||
@@ -1412,6 +1584,7 @@ fn queue_step(state: &mut AppState, direction: isize) -> Option<Effect> {
|
|||||||
} else {
|
} else {
|
||||||
player.queue_pos = next as usize;
|
player.queue_pos = next as usize;
|
||||||
}
|
}
|
||||||
|
normalize_play_next_block(player);
|
||||||
Some(Effect::PlayCurrent)
|
Some(Effect::PlayCurrent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1450,9 +1623,11 @@ pub fn advance_after_finish(state: &mut AppState) -> Option<Effect> {
|
|||||||
repeat => {
|
repeat => {
|
||||||
if player.queue_pos + 1 < player.queue.len() {
|
if player.queue_pos + 1 < player.queue.len() {
|
||||||
player.queue_pos += 1;
|
player.queue_pos += 1;
|
||||||
|
normalize_play_next_block(player);
|
||||||
Some(Effect::PlayCurrent)
|
Some(Effect::PlayCurrent)
|
||||||
} else if repeat == super::state::RepeatMode::All {
|
} else if repeat == super::state::RepeatMode::All {
|
||||||
player.queue_pos = 0;
|
player.queue_pos = 0;
|
||||||
|
player.play_next_end = None;
|
||||||
Some(Effect::PlayCurrent)
|
Some(Effect::PlayCurrent)
|
||||||
} else {
|
} else {
|
||||||
player.playing = false;
|
player.playing = false;
|
||||||
@@ -1730,10 +1905,13 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
refresh_track_selection_cursor(state);
|
refresh_track_selection_cursor(state);
|
||||||
}
|
}
|
||||||
Some(GlobalView::Search { cursor }) => {
|
Some(GlobalView::Search { cursor }) => {
|
||||||
// Local results plus the federated section below them.
|
let total = if state.search.similarity_source.is_some() {
|
||||||
let total = (state.search.results.as_ref().map_or(0, |r| r.len())
|
state.search.similarity_len()
|
||||||
+ state.search.fed_artists.len()
|
} else {
|
||||||
+ state.search.fed_tracks.len()) as isize;
|
state.search.results.as_ref().map_or(0, |r| r.len())
|
||||||
|
+ state.search.fed_artists.len()
|
||||||
|
+ state.search.fed_tracks.len()
|
||||||
|
} as isize;
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1853,9 +2031,13 @@ fn current_view_len(state: &AppState) -> usize {
|
|||||||
_ => 0,
|
_ => 0,
|
||||||
},
|
},
|
||||||
Some(GlobalView::Search { .. }) => {
|
Some(GlobalView::Search { .. }) => {
|
||||||
state.search.results.as_ref().map_or(0, |r| r.len())
|
if state.search.similarity_source.is_some() {
|
||||||
+ state.search.fed_artists.len()
|
state.search.similarity_len()
|
||||||
+ state.search.fed_tracks.len()
|
} else {
|
||||||
|
state.search.results.as_ref().map_or(0, |r| r.len())
|
||||||
|
+ state.search.fed_artists.len()
|
||||||
|
+ state.search.fed_tracks.len()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(GlobalView::FedArtist { .. }) => fed_card_len(state),
|
Some(GlobalView::FedArtist { .. }) => fed_card_len(state),
|
||||||
Some(GlobalView::FedRelease { index, .. }) => fed_card_release(state, *index)
|
Some(GlobalView::FedRelease { index, .. }) => fed_card_release(state, *index)
|
||||||
@@ -2062,6 +2244,7 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
state.player.queue_pos = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
state.player.queue_pos = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
||||||
|
normalize_play_next_block(&mut state.player);
|
||||||
return Some(Effect::PlayCurrent);
|
return Some(Effect::PlayCurrent);
|
||||||
}
|
}
|
||||||
if state.active_tab != Tab::Global {
|
if state.active_tab != Tab::Global {
|
||||||
@@ -2144,31 +2327,47 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
},
|
},
|
||||||
_ => Outcome::Nothing,
|
_ => Outcome::Nothing,
|
||||||
},
|
},
|
||||||
Some(GlobalView::Search { cursor }) => match &state.search.results {
|
Some(GlobalView::Search { cursor }) => {
|
||||||
Some(results) => {
|
if state.search.similarity_source.is_some() {
|
||||||
let artists = results.artists.len();
|
let tracks = (0..state.search.similarity_len())
|
||||||
let releases = results.releases.len();
|
.filter_map(|index| state.search.similarity_track(index))
|
||||||
if cursor < artists {
|
.collect::<Vec<_>>();
|
||||||
Outcome::Push(GlobalView::Artist {
|
if tracks.is_empty() {
|
||||||
id: results.artists[cursor].id,
|
Outcome::Nothing
|
||||||
cursor: 0,
|
|
||||||
})
|
|
||||||
} else if cursor < artists + releases {
|
|
||||||
Outcome::Push(GlobalView::Release {
|
|
||||||
id: results.releases[cursor - artists].id,
|
|
||||||
cursor: 0,
|
|
||||||
})
|
|
||||||
} else if results.tracks.get(cursor - artists - releases).is_some() {
|
|
||||||
Outcome::Play {
|
|
||||||
tracks: results.tracks.clone(),
|
|
||||||
start: cursor - artists - releases,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
fed_outcome(state, cursor - artists - releases - results.tracks.len())
|
Outcome::Play {
|
||||||
|
start: cursor.min(tracks.len() - 1),
|
||||||
|
tracks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match &state.search.results {
|
||||||
|
Some(results) => {
|
||||||
|
let artists = results.artists.len();
|
||||||
|
let releases = results.releases.len();
|
||||||
|
if cursor < artists {
|
||||||
|
Outcome::Push(GlobalView::Artist {
|
||||||
|
id: results.artists[cursor].id,
|
||||||
|
cursor: 0,
|
||||||
|
})
|
||||||
|
} else if cursor < artists + releases {
|
||||||
|
Outcome::Push(GlobalView::Release {
|
||||||
|
id: results.releases[cursor - artists].id,
|
||||||
|
cursor: 0,
|
||||||
|
})
|
||||||
|
} else if results.tracks.get(cursor - artists - releases).is_some() {
|
||||||
|
Outcome::Play {
|
||||||
|
tracks: results.tracks.clone(),
|
||||||
|
start: cursor - artists - releases,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fed_outcome(state, cursor - artists - releases - results.tracks.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => fed_outcome(state, cursor),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => fed_outcome(state, cursor),
|
}
|
||||||
},
|
|
||||||
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
|
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
|
||||||
Some((_, Loadable::Ready(card))) => {
|
Some((_, Loadable::Ready(card))) => {
|
||||||
let release_indices = fed_artist_visible_release_indices(state, card);
|
let release_indices = fed_artist_visible_release_indices(state, card);
|
||||||
@@ -2344,6 +2543,20 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
|
|||||||
if state.active_tab != Tab::Global {
|
if state.active_tab != Tab::Global {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
if state.search.similarity_source.is_some()
|
||||||
|
&& let Some(GlobalView::Search { cursor }) = state.global.stack.last()
|
||||||
|
{
|
||||||
|
let scope = TrackSelectionScope::SimilaritySearch;
|
||||||
|
let len = state.search.similarity_len();
|
||||||
|
let indices = state
|
||||||
|
.track_selection
|
||||||
|
.indices(&scope, len)
|
||||||
|
.unwrap_or_else(|| vec![(*cursor).min(len.saturating_sub(1))]);
|
||||||
|
return indices
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|index| state.search.similarity_fed_track(index).cloned())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
// An active Shift-V range in a federated scope.
|
// An active Shift-V range in a federated scope.
|
||||||
if let Some(scope) = state.track_selection.scope.clone() {
|
if let Some(scope) = state.track_selection.scope.clone() {
|
||||||
match scope {
|
match scope {
|
||||||
@@ -2600,8 +2813,86 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
|
|||||||
/// Enter on Settings: toggle switches, open text inputs, run
|
/// Enter on Settings: toggle switches, open text inputs, run
|
||||||
/// one-shot operations. The heavy lifting happens in perform_effect().
|
/// one-shot operations. The heavy lifting happens in perform_effect().
|
||||||
fn federation_select(state: &mut AppState) -> Option<Effect> {
|
fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||||
use super::state::{FedInputField, FedRow, Popup, SettingsRow};
|
use super::state::{FedInputField, FedRow, Popup, SettingsRow, SimilarityRow};
|
||||||
match settings_rows(state).get(state.settings_cursor).copied()? {
|
match settings_rows(state).get(state.settings_cursor).copied()? {
|
||||||
|
SettingsRow::MusicDirectory => {
|
||||||
|
if state.music_dir_changing {
|
||||||
|
state.status_message = Some("music directory change is already running".into());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
state.popup = Some(Popup::FedInput {
|
||||||
|
field: FedInputField::MusicDirectory,
|
||||||
|
input: crate::app::input::LineEdit::new(
|
||||||
|
state.music_dir.to_string_lossy().into_owned(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::Toggle) => {
|
||||||
|
if !state.similarity.settings.enabled
|
||||||
|
&& state.federation.settings.enabled
|
||||||
|
&& !state.similarity.settings.federation_consent
|
||||||
|
{
|
||||||
|
state.popup = Some(Popup::SimilarityPrivacyConsent {
|
||||||
|
enable_federation: false,
|
||||||
|
});
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
state.similarity.settings.enabled = !state.similarity.settings.enabled;
|
||||||
|
Some(Effect::SimilarityApplySettings)
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::Model) => {
|
||||||
|
let models = crate::similarity::MODELS;
|
||||||
|
let current = models
|
||||||
|
.iter()
|
||||||
|
.position(|model| model.id == state.similarity.settings.model)
|
||||||
|
.unwrap_or(0);
|
||||||
|
state.similarity.settings.model = models[(current + 1) % models.len()].id.to_string();
|
||||||
|
Some(Effect::SimilarityApplySettings)
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::Profile) => {
|
||||||
|
let profile = state.similarity.settings.profile.clone();
|
||||||
|
let text =
|
||||||
|
crate::similarity::profile_details(&profile, &state.similarity.settings.model)
|
||||||
|
.unwrap_or_else(|| format!("Unknown preprocessing profile: {profile}"));
|
||||||
|
state.popup = Some(Popup::FedText {
|
||||||
|
title: format!("Preprocessing profile: {profile}"),
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::MinimumScore) => {
|
||||||
|
state.popup = Some(Popup::FedInput {
|
||||||
|
field: FedInputField::SimilarityMinimumScore,
|
||||||
|
input: crate::app::input::LineEdit::new(format!(
|
||||||
|
"{:.2}",
|
||||||
|
state.similarity.settings.minimum_score
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::MaxTracksPerArtist) => {
|
||||||
|
state.popup = Some(Popup::FedInput {
|
||||||
|
field: FedInputField::SimilarityMaxTracksPerArtist,
|
||||||
|
input: crate::app::input::LineEdit::new(
|
||||||
|
state.similarity.settings.max_tracks_per_artist.to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::Workers) => {
|
||||||
|
state.popup = Some(Popup::FedInput {
|
||||||
|
field: FedInputField::SimilarityWorkers,
|
||||||
|
input: crate::app::input::LineEdit::new(
|
||||||
|
state.similarity.settings.workers.to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::Clear) => {
|
||||||
|
state.popup = Some(Popup::ConfirmClearEmbeddings);
|
||||||
|
None
|
||||||
|
}
|
||||||
SettingsRow::Federation(FedRow::Toggle) => {
|
SettingsRow::Federation(FedRow::Toggle) => {
|
||||||
let settings = &mut state.federation.settings;
|
let settings = &mut state.federation.settings;
|
||||||
if !settings.enabled && settings.network_id.trim().is_empty() {
|
if !settings.enabled && settings.network_id.trim().is_empty() {
|
||||||
@@ -2611,7 +2902,17 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
|||||||
});
|
});
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
settings.enabled = !settings.enabled;
|
let enabling = !settings.enabled;
|
||||||
|
settings.enabled = enabling;
|
||||||
|
if enabling
|
||||||
|
&& state.similarity.settings.enabled
|
||||||
|
&& !state.similarity.settings.federation_consent
|
||||||
|
{
|
||||||
|
state.popup = Some(Popup::SimilarityPrivacyConsent {
|
||||||
|
enable_federation: true,
|
||||||
|
});
|
||||||
|
return None;
|
||||||
|
}
|
||||||
Some(Effect::FedApplySettings)
|
Some(Effect::FedApplySettings)
|
||||||
}
|
}
|
||||||
SettingsRow::Federation(FedRow::NetworkId) => {
|
SettingsRow::Federation(FedRow::NetworkId) => {
|
||||||
@@ -2775,6 +3076,7 @@ fn require_connected_devices_enabled(state: &mut AppState) -> bool {
|
|||||||
pub(super) fn on_new_queue(state: &mut AppState) {
|
pub(super) fn on_new_queue(state: &mut AppState) {
|
||||||
let player = &mut state.player;
|
let player = &mut state.player;
|
||||||
player.original_order = None;
|
player.original_order = None;
|
||||||
|
player.play_next_end = None;
|
||||||
if player.shuffle && !player.queue.is_empty() {
|
if player.shuffle && !player.queue.is_empty() {
|
||||||
player.original_order = Some(player.queue.iter().map(track_key).collect());
|
player.original_order = Some(player.queue.iter().map(track_key).collect());
|
||||||
shuffle_range(player, (player.queue_pos + 1).min(player.queue.len()));
|
shuffle_range(player, (player.queue_pos + 1).min(player.queue.len()));
|
||||||
|
|||||||
+100
-7
@@ -16,6 +16,23 @@ fn with_artists(n: usize) -> AppState {
|
|||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listening_history_popup_requests_a_background_load() {
|
||||||
|
let mut state = AppState::default();
|
||||||
|
assert_eq!(
|
||||||
|
update(&mut state, Action::OpenListenHistory),
|
||||||
|
Some(Effect::LoadListenHistory)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
state.popup,
|
||||||
|
Some(crate::app::state::Popup::ListenHistory { cursor: 0 })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
state.listen_history,
|
||||||
|
Some(crate::app::state::Loadable::Loading)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
fn test_track(id: i64) -> TrackItem {
|
fn test_track(id: i64) -> TrackItem {
|
||||||
TrackItem {
|
TrackItem {
|
||||||
id,
|
id,
|
||||||
@@ -141,25 +158,25 @@ fn source_mode_cycles_on_library_playlists_and_queue_tabs() {
|
|||||||
update(&mut state, Action::CycleSourceMode),
|
update(&mut state, Action::CycleSourceMode),
|
||||||
Some(Effect::SourceModeChanged)
|
Some(Effect::SourceModeChanged)
|
||||||
);
|
);
|
||||||
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::My);
|
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
|
||||||
|
|
||||||
state.active_tab = Tab::Playlists;
|
state.active_tab = Tab::Playlists;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
update(&mut state, Action::CycleSourceMode),
|
update(&mut state, Action::CycleSourceMode),
|
||||||
Some(Effect::SourceModeChanged)
|
Some(Effect::SourceModeChanged)
|
||||||
);
|
);
|
||||||
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global);
|
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::My);
|
||||||
|
|
||||||
state.active_tab = Tab::Queue;
|
state.active_tab = Tab::Queue;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
update(&mut state, Action::CycleSourceMode),
|
update(&mut state, Action::CycleSourceMode),
|
||||||
Some(Effect::SourceModeChanged)
|
Some(Effect::SourceModeChanged)
|
||||||
);
|
);
|
||||||
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
|
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global);
|
||||||
|
|
||||||
state.active_tab = Tab::Federation;
|
state.active_tab = Tab::Federation;
|
||||||
assert_eq!(update(&mut state, Action::CycleSourceMode), None);
|
assert_eq!(update(&mut state, Action::CycleSourceMode), None);
|
||||||
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
|
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -440,6 +457,7 @@ fn local_mode_hides_pending_federation_tracks_from_playlists_and_playback() {
|
|||||||
active_tab: Tab::Playlists,
|
active_tab: Tab::Playlists,
|
||||||
..AppState::default()
|
..AppState::default()
|
||||||
};
|
};
|
||||||
|
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
|
||||||
state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 });
|
state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 });
|
||||||
state.playlist_views.insert(
|
state.playlist_views.insert(
|
||||||
7,
|
7,
|
||||||
@@ -495,6 +513,7 @@ fn network_modes_show_pending_federation_playlist_tracks() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn local_mode_rejects_async_federation_queue_additions() {
|
fn local_mode_rejects_async_federation_queue_additions() {
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
|
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
|
||||||
|
|
||||||
enqueue_tracks(
|
enqueue_tracks(
|
||||||
&mut state,
|
&mut state,
|
||||||
@@ -552,9 +571,15 @@ fn current_track_info_uses_now_playing_track() {
|
|||||||
active_tab: Tab::Queue,
|
active_tab: Tab::Queue,
|
||||||
..AppState::default()
|
..AppState::default()
|
||||||
};
|
};
|
||||||
state.player.queue = vec![test_track(1), test_track(2)];
|
let mut complete = test_track(2);
|
||||||
|
complete.audio_format = Some("flac".into());
|
||||||
|
complete.audio_bitrate = Some(921);
|
||||||
|
state.player.queue = vec![test_track(1), complete];
|
||||||
|
state.player.queue_pos = 1;
|
||||||
state.queue_tab.cursor = 0;
|
state.queue_tab.cursor = 0;
|
||||||
state.player.current = Some(test_track(2));
|
let mut lightweight = test_track(2);
|
||||||
|
lightweight.file_path.clear();
|
||||||
|
state.player.current = Some(lightweight);
|
||||||
|
|
||||||
assert_eq!(update(&mut state, Action::OpenCurrentTrackInfo), None);
|
assert_eq!(update(&mut state, Action::OpenCurrentTrackInfo), None);
|
||||||
match &state.popup {
|
match &state.popup {
|
||||||
@@ -563,6 +588,9 @@ fn current_track_info_uses_now_playing_track() {
|
|||||||
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
|
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
|
||||||
vec![2]
|
vec![2]
|
||||||
);
|
);
|
||||||
|
assert_eq!(tracks[0].file_path, "/s/2");
|
||||||
|
assert_eq!(tracks[0].audio_format.as_deref(), Some("flac"));
|
||||||
|
assert_eq!(tracks[0].audio_bitrate, Some(921));
|
||||||
}
|
}
|
||||||
other => panic!("expected track info popup, got {other:?}"),
|
other => panic!("expected track info popup, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -715,13 +743,78 @@ fn artist_top_track_selection_queues_all_selected_tracks() {
|
|||||||
update(&mut state, Action::MoveDown);
|
update(&mut state, Action::MoveDown);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
update(&mut state, Action::QueueAddLast),
|
update(&mut state, Action::QueueAddLast),
|
||||||
Some(Effect::PlaybackQueueChanged),
|
Some(Effect::QueueOrderChanged {
|
||||||
|
restart_current: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
||||||
assert_eq!(queued, vec![1, 2]);
|
assert_eq!(queued, vec![1, 2]);
|
||||||
assert!(!state.track_selection.is_active());
|
assert!(!state.track_selection.is_active());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequential_queue_next_additions_keep_their_order_as_one_block() {
|
||||||
|
let mut state = AppState::default();
|
||||||
|
state.player.queue = (10..=13).map(test_track).collect();
|
||||||
|
state.player.queue_pos = 0;
|
||||||
|
state.player.current = Some(test_track(10));
|
||||||
|
|
||||||
|
assert!(!enqueue_tracks(&mut state, vec![test_track(1)], true));
|
||||||
|
assert!(!enqueue_tracks(&mut state, vec![test_track(2)], true));
|
||||||
|
assert!(!enqueue_tracks(&mut state, vec![test_track(3)], true));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.player
|
||||||
|
.queue
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![10, 1, 2, 3, 11, 12, 13]
|
||||||
|
);
|
||||||
|
assert_eq!(state.player.play_next_end, Some(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_selection_moves_as_a_group_and_preserves_current_track() {
|
||||||
|
let mut state = AppState {
|
||||||
|
active_tab: Tab::Queue,
|
||||||
|
..AppState::default()
|
||||||
|
};
|
||||||
|
state.player.queue = (1..=5).map(test_track).collect();
|
||||||
|
state.player.queue_pos = 0;
|
||||||
|
state.player.current = Some(test_track(1));
|
||||||
|
state.queue_tab.cursor = 2;
|
||||||
|
state.track_selection.start(TrackSelectionScope::Queue, 2);
|
||||||
|
state
|
||||||
|
.track_selection
|
||||||
|
.set_cursor(TrackSelectionScope::Queue, 3);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
update(&mut state, Action::MoveQueueUp),
|
||||||
|
Some(Effect::QueueOrderChanged {
|
||||||
|
restart_current: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.player
|
||||||
|
.queue
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![1, 3, 4, 2, 5]
|
||||||
|
);
|
||||||
|
assert_eq!(state.player.queue_pos, 0);
|
||||||
|
assert_eq!(state.queue_tab.cursor, 1);
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.track_selection
|
||||||
|
.indices(&TrackSelectionScope::Queue, 5),
|
||||||
|
Some(vec![1, 2])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn removing_current_queue_track_requests_paused_restart() {
|
fn removing_current_queue_track_requests_paused_restart() {
|
||||||
let mut state = AppState {
|
let mut state = AppState {
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ command = "QueueAddNext"
|
|||||||
key_sequence = "shift-a"
|
key_sequence = "shift-a"
|
||||||
command = "QueueAddLast"
|
command = "QueueAddLast"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "alt-k"
|
||||||
|
command = "MoveQueueUp"
|
||||||
|
context = "queue"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "alt-j"
|
||||||
|
command = "MoveQueueDown"
|
||||||
|
context = "queue"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "shift-c"
|
key_sequence = "shift-c"
|
||||||
command = "OpenConnectedDevices"
|
command = "OpenConnectedDevices"
|
||||||
@@ -197,6 +207,10 @@ command = "CycleRepeat"
|
|||||||
key_sequence = "shift-l"
|
key_sequence = "shift-l"
|
||||||
command = "ToggleVisualizer"
|
command = "ToggleVisualizer"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "shift-h"
|
||||||
|
command = "OpenListenHistory"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "x"
|
key_sequence = "x"
|
||||||
command = "ToggleLike"
|
command = "ToggleLike"
|
||||||
|
|||||||
@@ -596,4 +596,13 @@ mod tests {
|
|||||||
KeyResolution::Action(Action::SeekForward { seconds: 10 })
|
KeyResolution::Action(Action::SeekForward { seconds: 10 })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_listening_history_key_resolves() {
|
||||||
|
let mut km = keymap_from(DEFAULT_KEYMAP);
|
||||||
|
assert_eq!(
|
||||||
|
km.resolve(key!(shift - h), KeyContext::Library),
|
||||||
|
KeyResolution::Action(Action::OpenListenHistory)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-3
@@ -1,12 +1,13 @@
|
|||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum LibrarySourceMode {
|
pub enum LibrarySourceMode {
|
||||||
#[default]
|
|
||||||
Local,
|
Local,
|
||||||
My,
|
My,
|
||||||
|
#[default]
|
||||||
Global,
|
Global,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,12 +45,61 @@ pub struct LibraryFilters {
|
|||||||
pub source_mode: LibrarySourceMode,
|
pub source_mode: LibrarySourceMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SimilaritySettings {
|
||||||
|
/// Local embedding/search master switch. Network participation follows
|
||||||
|
/// federation and additionally requires the explicit privacy consent.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_similarity_model")]
|
||||||
|
pub model: String,
|
||||||
|
#[serde(default = "default_similarity_profile")]
|
||||||
|
pub profile: String,
|
||||||
|
#[serde(default = "default_similarity_workers")]
|
||||||
|
pub workers: usize,
|
||||||
|
/// Requester-side cosine score floor. This is search policy, not part of
|
||||||
|
/// the embedding profile, so changing it never invalidates vectors.
|
||||||
|
#[serde(default = "default_similarity_minimum_score")]
|
||||||
|
pub minimum_score: f32,
|
||||||
|
/// Requester-side diversity cap applied independently to local and
|
||||||
|
/// federated candidates.
|
||||||
|
#[serde(default = "default_similarity_max_tracks_per_artist")]
|
||||||
|
pub max_tracks_per_artist: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub federation_consent: bool,
|
||||||
|
/// Exact fingerprint of the last fully usable profile. Keeping this
|
||||||
|
/// separate from the selected target lets an old index serve searches
|
||||||
|
/// while a newly selected model/profile is being calculated.
|
||||||
|
#[serde(default)]
|
||||||
|
pub active_profile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SimilaritySettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
model: default_similarity_model(),
|
||||||
|
profile: default_similarity_profile(),
|
||||||
|
workers: default_similarity_workers(),
|
||||||
|
minimum_score: default_similarity_minimum_score(),
|
||||||
|
max_tracks_per_artist: default_similarity_max_tracks_per_artist(),
|
||||||
|
federation_consent: false,
|
||||||
|
active_profile: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
#[serde(default = "default_volume")]
|
#[serde(default = "default_volume")]
|
||||||
pub volume: u8,
|
pub volume: u8,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub library: LibraryFilters,
|
pub library: LibraryFilters,
|
||||||
|
/// Root used for music materialized from federation peers.
|
||||||
|
#[serde(default = "default_music_dir")]
|
||||||
|
pub music_dir: PathBuf,
|
||||||
|
#[serde(default)]
|
||||||
|
pub similarity: SimilaritySettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -57,6 +107,8 @@ impl Default for AppSettings {
|
|||||||
Self {
|
Self {
|
||||||
volume: default_volume(),
|
volume: default_volume(),
|
||||||
library: LibraryFilters::default(),
|
library: LibraryFilters::default(),
|
||||||
|
music_dir: default_music_dir(),
|
||||||
|
similarity: SimilaritySettings::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,6 +116,21 @@ impl Default for AppSettings {
|
|||||||
impl AppSettings {
|
impl AppSettings {
|
||||||
pub fn normalized(mut self) -> Self {
|
pub fn normalized(mut self) -> Self {
|
||||||
self.volume = self.volume.min(100);
|
self.volume = self.volume.min(100);
|
||||||
|
if self.music_dir.as_os_str().is_empty() {
|
||||||
|
self.music_dir = default_music_dir();
|
||||||
|
}
|
||||||
|
if self.similarity.model.trim().is_empty() {
|
||||||
|
self.similarity.model = default_similarity_model();
|
||||||
|
}
|
||||||
|
if self.similarity.profile.trim().is_empty() {
|
||||||
|
self.similarity.profile = default_similarity_profile();
|
||||||
|
}
|
||||||
|
self.similarity.workers = self.similarity.workers.clamp(1, 16);
|
||||||
|
if !self.similarity.minimum_score.is_finite() {
|
||||||
|
self.similarity.minimum_score = default_similarity_minimum_score();
|
||||||
|
}
|
||||||
|
self.similarity.minimum_score = self.similarity.minimum_score.clamp(0.0, 1.0);
|
||||||
|
self.similarity.max_tracks_per_artist = self.similarity.max_tracks_per_artist.clamp(1, 50);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +139,36 @@ fn default_volume() -> u8 {
|
|||||||
80
|
80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_similarity_model() -> String {
|
||||||
|
"discogs-effnet-bsdynamic-1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_similarity_profile() -> String {
|
||||||
|
"furumi-full-track-v1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_similarity_workers() -> usize {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|count| (count.get() / 2).clamp(1, 4))
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_similarity_minimum_score() -> f32 {
|
||||||
|
0.70
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_similarity_max_tracks_per_artist() -> usize {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The historical permanent-download location, kept as the default for
|
||||||
|
/// backward compatibility with existing installations.
|
||||||
|
pub fn default_music_dir() -> PathBuf {
|
||||||
|
crate::config::project_dirs()
|
||||||
|
.map(|dirs| dirs.data_dir().join("federation-media"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("federation-media"))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load() -> (AppSettings, Option<String>) {
|
pub fn load() -> (AppSettings, Option<String>) {
|
||||||
let Some(path) = settings_path() else {
|
let Some(path) = settings_path() else {
|
||||||
return (AppSettings::default(), None);
|
return (AppSettings::default(), None);
|
||||||
@@ -128,6 +225,23 @@ hide_featured_only = true
|
|||||||
|
|
||||||
assert_eq!(settings.volume, 100);
|
assert_eq!(settings.volume, 100);
|
||||||
assert!(settings.library.hide_featured_only);
|
assert!(settings.library.hide_featured_only);
|
||||||
assert_eq!(settings.library.source_mode, LibrarySourceMode::Local);
|
assert_eq!(settings.library.source_mode, LibrarySourceMode::Global);
|
||||||
|
assert_eq!(settings.music_dir, default_music_dir());
|
||||||
|
assert_eq!(settings.similarity.minimum_score, 0.70);
|
||||||
|
assert_eq!(settings.similarity.max_tracks_per_artist, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similarity_search_policy_is_normalized_without_changing_the_profile() {
|
||||||
|
let mut settings = AppSettings::default();
|
||||||
|
let profile = settings.similarity.profile.clone();
|
||||||
|
settings.similarity.minimum_score = f32::NAN;
|
||||||
|
settings.similarity.max_tracks_per_artist = 0;
|
||||||
|
|
||||||
|
let settings = settings.normalized();
|
||||||
|
|
||||||
|
assert_eq!(settings.similarity.minimum_score, 0.70);
|
||||||
|
assert_eq!(settings.similarity.max_tracks_per_artist, 1);
|
||||||
|
assert_eq!(settings.similarity.profile, profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-24
@@ -12,6 +12,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
|
use music_dht::device_sync::{ListenEvent, ListenTrackMetadata};
|
||||||
use music_dht::{ByteStream, MusicDhtService, NetworkId, PeerTicket, SecretKey, StreamAcceptor};
|
use music_dht::{ByteStream, MusicDhtService, NetworkId, PeerTicket, SecretKey, StreamAcceptor};
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -21,15 +22,18 @@ use crate::app::event::AppEvent;
|
|||||||
use crate::library::Library;
|
use crate::library::Library;
|
||||||
use crate::library::models::{ArtistRef, TrackItem};
|
use crate::library::models::{ArtistRef, TrackItem};
|
||||||
|
|
||||||
pub const SYNC_ALPN: &[u8] = b"furumi/sync/1";
|
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
|
||||||
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
const PROTOCOL_VERSION: u16 = 1;
|
pub const PROTOCOL_VERSION: u16 = 2;
|
||||||
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
|
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
|
||||||
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
|
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
|
||||||
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
|
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||||
const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
|
const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
const SYNC_INTERVAL: Duration = Duration::from_secs(2);
|
const SYNC_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
const MAX_LINE: usize = 8 * 1024 * 1024;
|
const MAX_LINE: usize = 8 * 1024 * 1024;
|
||||||
|
/// Playback control is ephemeral. Keeping old full-queue commands in a new
|
||||||
|
/// peer's catch-up batch can make the initial pairing frame arbitrarily large.
|
||||||
|
const PLAYBACK_COMMAND_TTL_MS: i64 = 5 * 60 * 1_000;
|
||||||
const MAX_OPS_PER_BATCH: usize = 1000;
|
const MAX_OPS_PER_BATCH: usize = 1000;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -366,6 +370,9 @@ pub enum SyncOpPayload {
|
|||||||
target_device_id: String,
|
target_device_id: String,
|
||||||
command: PlaybackCommand,
|
command: PlaybackCommand,
|
||||||
},
|
},
|
||||||
|
ListenRecorded {
|
||||||
|
event: ListenEvent,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncOpPayload {
|
impl SyncOpPayload {
|
||||||
@@ -637,6 +644,10 @@ impl DeviceSync {
|
|||||||
Ok((identity.device_id, identity.name))
|
Ok((identity.device_id, identity.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn new_listen_id(&self) -> String {
|
||||||
|
format!("{}-{}", now_ms(), random_hex(12))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn publish_playback(&self, mut snapshot: PlaybackSnapshot) {
|
pub fn publish_playback(&self, mut snapshot: PlaybackSnapshot) {
|
||||||
if snapshot.updated_at_ms <= 0 {
|
if snapshot.updated_at_ms <= 0 {
|
||||||
snapshot.updated_at_ms = now_ms();
|
snapshot.updated_at_ms = now_ms();
|
||||||
@@ -1073,6 +1084,52 @@ impl DeviceSync {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn record_listen(&self, event: ListenEvent) -> Result<()> {
|
||||||
|
if !event.should_record() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.record_local_op(SyncOpPayload::ListenRecorded { event })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn listen_event_for_track(
|
||||||
|
&self,
|
||||||
|
listen_id: String,
|
||||||
|
track: &TrackItem,
|
||||||
|
started_at_ms: i64,
|
||||||
|
listened_ms: i64,
|
||||||
|
ended_reason: music_dht::device_sync::ListenEndReason,
|
||||||
|
) -> Option<ListenEvent> {
|
||||||
|
let content_id = track
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.or_else(|| track.fed.as_ref()?.content_id.as_deref())
|
||||||
|
.and_then(music_dht::normalize_content_id)?;
|
||||||
|
Some(ListenEvent {
|
||||||
|
listen_id,
|
||||||
|
content_id,
|
||||||
|
started_at_ms,
|
||||||
|
listened_ms,
|
||||||
|
track_duration_ms: (track.duration_seconds > 0.0)
|
||||||
|
.then_some((track.duration_seconds * 1_000.0).round() as i64),
|
||||||
|
ended_reason,
|
||||||
|
track: ListenTrackMetadata {
|
||||||
|
title: track.title.clone(),
|
||||||
|
artist_names: track
|
||||||
|
.artists
|
||||||
|
.iter()
|
||||||
|
.map(|artist| artist.name.clone())
|
||||||
|
.collect(),
|
||||||
|
featured_artist_names: track
|
||||||
|
.featured_artists
|
||||||
|
.iter()
|
||||||
|
.map(|artist| artist.name.clone())
|
||||||
|
.collect(),
|
||||||
|
release_title: (!track.release_title.trim().is_empty())
|
||||||
|
.then(|| track.release_title.clone()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn record_playlist_created(&self, playlist_id: i64, title: &str) -> Result<()> {
|
pub fn record_playlist_created(&self, playlist_id: i64, title: &str) -> Result<()> {
|
||||||
let playlist_id = self.library.ensure_playlist_sync_id(playlist_id)?;
|
let playlist_id = self.library.ensure_playlist_sync_id(playlist_id)?;
|
||||||
self.record_local_op(SyncOpPayload::PlaylistCreated {
|
self.record_local_op(SyncOpPayload::PlaylistCreated {
|
||||||
@@ -1195,8 +1252,7 @@ impl DeviceSync {
|
|||||||
device: &StoredDevice,
|
device: &StoredDevice,
|
||||||
transport_stats: Arc<crate::federation::TransportStats>,
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
let peer = resolve_device_peer(&service, device).await?;
|
||||||
let peer = service.connect(ticket).await?;
|
|
||||||
let own_ticket = service.ticket().await?.to_string();
|
let own_ticket = service.ticket().await?.to_string();
|
||||||
let identity = self.ensure_identity()?;
|
let identity = self.ensure_identity()?;
|
||||||
let profile = self.own_profile(&own_ticket)?;
|
let profile = self.own_profile(&own_ticket)?;
|
||||||
@@ -1584,6 +1640,9 @@ impl DeviceSync {
|
|||||||
self.apply_playback_command(target_device_id, command, &op.op_id)?;
|
self.apply_playback_command(target_device_id, command, &op.op_id)?;
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
SyncOpPayload::ListenRecorded { event } => self
|
||||||
|
.library
|
||||||
|
.apply_listen_event(event, &op.origin_device_id)?,
|
||||||
};
|
};
|
||||||
Ok(changed)
|
Ok(changed)
|
||||||
}
|
}
|
||||||
@@ -2117,25 +2176,39 @@ impl DeviceSync {
|
|||||||
LEFT JOIN sync_peer_acks a
|
LEFT JOIN sync_peer_acks a
|
||||||
ON a.peer_device_id = ?1 AND a.origin_device_id = o.origin_device_id
|
ON a.peer_device_id = ?1 AND a.origin_device_id = o.origin_device_id
|
||||||
WHERE o.seq > COALESCE(a.max_seq, 0)
|
WHERE o.seq > COALESCE(a.max_seq, 0)
|
||||||
ORDER BY o.hlc_ms, o.op_id
|
AND (
|
||||||
LIMIT ?2",
|
o.kind != 'playback_command'
|
||||||
)?;
|
OR (
|
||||||
let rows = stmt.query_map(params![peer_device_id, MAX_OPS_PER_BATCH as i64], |row| {
|
o.hlc_ms >= ?2
|
||||||
let payload_json: String = row.get(4)?;
|
AND json_extract(o.payload_json, '$.target_device_id') = ?1
|
||||||
Ok(SyncOpWire {
|
|
||||||
op_id: row.get(0)?,
|
|
||||||
origin_device_id: row.get(1)?,
|
|
||||||
seq: row.get(2)?,
|
|
||||||
hlc_ms: row.get(3)?,
|
|
||||||
payload: serde_json::from_str(&payload_json).map_err(|err| {
|
|
||||||
rusqlite::Error::FromSqlConversionFailure(
|
|
||||||
4,
|
|
||||||
rusqlite::types::Type::Text,
|
|
||||||
Box::new(err),
|
|
||||||
)
|
)
|
||||||
})?,
|
)
|
||||||
})
|
ORDER BY o.hlc_ms, o.op_id
|
||||||
})?;
|
LIMIT ?3",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(
|
||||||
|
params![
|
||||||
|
peer_device_id,
|
||||||
|
now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS),
|
||||||
|
MAX_OPS_PER_BATCH as i64
|
||||||
|
],
|
||||||
|
|row| {
|
||||||
|
let payload_json: String = row.get(4)?;
|
||||||
|
Ok(SyncOpWire {
|
||||||
|
op_id: row.get(0)?,
|
||||||
|
origin_device_id: row.get(1)?,
|
||||||
|
seq: row.get(2)?,
|
||||||
|
hlc_ms: row.get(3)?,
|
||||||
|
payload: serde_json::from_str(&payload_json).map_err(|err| {
|
||||||
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
4,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(err),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)?;
|
||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2323,7 +2396,7 @@ impl DeviceSync {
|
|||||||
let own = self.ensure_identity()?.device_id;
|
let own = self.ensure_identity()?.device_id;
|
||||||
let conn = lock(&self.conn);
|
let conn = lock(&self.conn);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT device_id, endpoint_ticket
|
"SELECT device_id, endpoint_id, endpoint_ticket
|
||||||
FROM sync_devices
|
FROM sync_devices
|
||||||
WHERE trusted_at_ms IS NOT NULL
|
WHERE trusted_at_ms IS NOT NULL
|
||||||
AND revoked_at_ms IS NULL
|
AND revoked_at_ms IS NULL
|
||||||
@@ -2333,7 +2406,8 @@ impl DeviceSync {
|
|||||||
let rows = stmt.query_map([own], |row| {
|
let rows = stmt.query_map([own], |row| {
|
||||||
Ok(StoredDevice {
|
Ok(StoredDevice {
|
||||||
device_id: row.get(0)?,
|
device_id: row.get(0)?,
|
||||||
endpoint_ticket: row.get(1)?,
|
endpoint_id: row.get(1)?,
|
||||||
|
endpoint_ticket: row.get(2)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
@@ -2585,9 +2659,29 @@ impl DeviceSync {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct StoredDevice {
|
struct StoredDevice {
|
||||||
device_id: String,
|
device_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
endpoint_ticket: String,
|
endpoint_ticket: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_device_peer(
|
||||||
|
service: &MusicDhtService,
|
||||||
|
device: &StoredDevice,
|
||||||
|
) -> Result<music_dht::EndpointId> {
|
||||||
|
if let Ok(peer) = device.endpoint_id.parse::<music_dht::EndpointId>()
|
||||||
|
&& (service.connected_peers().contains(&peer)
|
||||||
|
|| service
|
||||||
|
.known_peers()
|
||||||
|
.iter()
|
||||||
|
.any(|contact| contact.peer_id == peer))
|
||||||
|
{
|
||||||
|
// The live DHT contact carries a ticket for the current schema. The
|
||||||
|
// persisted device ticket may predate a schema upgrade.
|
||||||
|
return Ok(peer);
|
||||||
|
}
|
||||||
|
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
||||||
|
service.connect(ticket).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn serve_peers(
|
pub async fn serve_peers(
|
||||||
mut acceptor: StreamAcceptor,
|
mut acceptor: StreamAcceptor,
|
||||||
sync: Arc<DeviceSync>,
|
sync: Arc<DeviceSync>,
|
||||||
@@ -3229,6 +3323,7 @@ fn payload_kind(payload: &SyncOpPayload) -> &'static str {
|
|||||||
SyncOpPayload::DeviceTrusted { .. } => "device_trusted",
|
SyncOpPayload::DeviceTrusted { .. } => "device_trusted",
|
||||||
SyncOpPayload::DeviceRevoked { .. } => "device_revoked",
|
SyncOpPayload::DeviceRevoked { .. } => "device_revoked",
|
||||||
SyncOpPayload::PlaybackCommand { .. } => "playback_command",
|
SyncOpPayload::PlaybackCommand { .. } => "playback_command",
|
||||||
|
SyncOpPayload::ListenRecorded { .. } => "listen_recorded",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,58 @@ fn playback_command_is_targeted_and_deduplicated() {
|
|||||||
assert!(rx.try_recv().is_err());
|
assert!(rx.try_recv().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_commands_are_caught_up_only_by_their_target_while_fresh() {
|
||||||
|
let sync = test_sync();
|
||||||
|
let command = PlaybackCommand::SetState {
|
||||||
|
state: PlaybackStateWire {
|
||||||
|
queue: Vec::new(),
|
||||||
|
queue_pos: 0,
|
||||||
|
playing: false,
|
||||||
|
paused: false,
|
||||||
|
idle_since_ms: None,
|
||||||
|
position_secs: 0.0,
|
||||||
|
volume: 42,
|
||||||
|
shuffle: false,
|
||||||
|
repeat: PlaybackRepeat::Off,
|
||||||
|
},
|
||||||
|
seek: false,
|
||||||
|
};
|
||||||
|
sync.record_playback_command("dev_target", command.clone())
|
||||||
|
.unwrap();
|
||||||
|
sync.record_playback_command("dev_other", command).unwrap();
|
||||||
|
|
||||||
|
let target_ops = sync.ops_for_peer("dev_target").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
target_ops
|
||||||
|
.iter()
|
||||||
|
.filter(|op| matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sync.ops_for_peer("dev_unknown")
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|op| !matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
|
||||||
|
);
|
||||||
|
|
||||||
|
lock(&sync.conn)
|
||||||
|
.execute(
|
||||||
|
"UPDATE sync_ops
|
||||||
|
SET hlc_ms = ?1
|
||||||
|
WHERE kind = 'playback_command'",
|
||||||
|
[now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS + 1)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
sync.ops_for_peer("dev_target")
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|op| !matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn newer_device_trust_reactivates_revoked_device() {
|
fn newer_device_trust_reactivates_revoked_device() {
|
||||||
let sync = test_sync();
|
let sync = test_sync();
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ use crate::library::Library;
|
|||||||
|
|
||||||
/// ALPN of the audio streaming protocol (shared with furumi-fd).
|
/// ALPN of the audio streaming protocol (shared with furumi-fd).
|
||||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||||
|
/// Version of the audio transfer stream protocol.
|
||||||
|
pub const AUDIO_PROTOCOL_VERSION: u16 = 1;
|
||||||
|
|
||||||
/// Maximum size of a JSON protocol line (request or response header).
|
/// Maximum size of a JSON protocol line (request or response header).
|
||||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! Informational publication and observation of protocol versions.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
pub use music_dht::capabilities::CAPABILITIES_ALPN;
|
||||||
|
use music_dht::capabilities::{
|
||||||
|
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, read_message,
|
||||||
|
write_message,
|
||||||
|
};
|
||||||
|
use music_dht::{ByteStream, EndpointId, MusicDhtService, StreamAcceptor};
|
||||||
|
|
||||||
|
const PROBE_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ProtocolVersions {
|
||||||
|
pub local: BTreeMap<String, u16>,
|
||||||
|
pub observed: BTreeMap<String, u16>,
|
||||||
|
pub observed_peers: usize,
|
||||||
|
pub newer: Vec<NewerProtocol>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NewerProtocol {
|
||||||
|
pub id: String,
|
||||||
|
pub local: u16,
|
||||||
|
pub observed: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolVersions {
|
||||||
|
pub fn snapshot(observed: &ObservedVersions) -> Self {
|
||||||
|
let local = local_manifest().protocols;
|
||||||
|
let observed_versions = lock(&observed.versions).clone();
|
||||||
|
let newer = observed_versions
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, remote)| {
|
||||||
|
let local_version = local.get(id)?;
|
||||||
|
(*remote > *local_version).then(|| NewerProtocol {
|
||||||
|
id: id.clone(),
|
||||||
|
local: *local_version,
|
||||||
|
observed: *remote,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Self {
|
||||||
|
local,
|
||||||
|
observed: observed_versions,
|
||||||
|
observed_peers: lock(&observed.peers).len(),
|
||||||
|
newer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ObservedVersions {
|
||||||
|
versions: Mutex<BTreeMap<String, u16>>,
|
||||||
|
peers: Mutex<BTreeMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_manifest() -> CapabilityManifest {
|
||||||
|
CapabilityManifest::frid("furumi", env!("CARGO_PKG_VERSION"))
|
||||||
|
.with_protocol("audio", super::audio::AUDIO_PROTOCOL_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(mut acceptor: StreamAcceptor) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(error) = serve_one(stream).await {
|
||||||
|
tracing::debug!("capability stream failed: {error:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_one(mut stream: ByteStream) -> Result<()> {
|
||||||
|
let response = match read_message(&mut stream).await? {
|
||||||
|
CapabilityMessage::Get {
|
||||||
|
version: CAPABILITIES_PROTOCOL_VERSION,
|
||||||
|
} => CapabilityMessage::Manifest {
|
||||||
|
manifest: local_manifest(),
|
||||||
|
},
|
||||||
|
CapabilityMessage::Get { version } => CapabilityMessage::Error {
|
||||||
|
message: format!("unsupported capability protocol {version}"),
|
||||||
|
},
|
||||||
|
_ => CapabilityMessage::Error {
|
||||||
|
message: "expected capability request".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
write_message(&mut stream, &response).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn probe_loop(service: Arc<MusicDhtService>, observed: Arc<ObservedVersions>) {
|
||||||
|
let mut interval = tokio::time::interval(PROBE_INTERVAL);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let peers = service
|
||||||
|
.connected_peers()
|
||||||
|
.into_iter()
|
||||||
|
.chain(
|
||||||
|
service
|
||||||
|
.known_peers()
|
||||||
|
.into_iter()
|
||||||
|
.map(|contact| contact.peer_id),
|
||||||
|
)
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
for peer in peers {
|
||||||
|
if let Err(error) = probe_peer(&service, peer, &observed).await {
|
||||||
|
tracing::trace!(%peer, "peer capability probe unavailable: {error:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn probe_peer(
|
||||||
|
service: &MusicDhtService,
|
||||||
|
peer: EndpointId,
|
||||||
|
observed: &ObservedVersions,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut stream = service.open_stream(peer, CAPABILITIES_ALPN).await?;
|
||||||
|
write_message(
|
||||||
|
&mut stream,
|
||||||
|
&CapabilityMessage::Get {
|
||||||
|
version: CAPABILITIES_PROTOCOL_VERSION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let response = tokio::time::timeout(Duration::from_secs(5), read_message(&mut stream))
|
||||||
|
.await
|
||||||
|
.context("capability request timed out")??;
|
||||||
|
let CapabilityMessage::Manifest { manifest } = response else {
|
||||||
|
anyhow::bail!("peer did not return a capability manifest");
|
||||||
|
};
|
||||||
|
manifest.validate()?;
|
||||||
|
{
|
||||||
|
let mut versions = lock(&observed.versions);
|
||||||
|
for (id, version) in manifest.protocols {
|
||||||
|
versions
|
||||||
|
.entry(id)
|
||||||
|
.and_modify(|current| *current = (*current).max(version))
|
||||||
|
.or_insert(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lock(&observed.peers).insert(peer.to_string(), manifest.application_version);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||||
|
mutex
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_manifest_lists_every_player_protocol() {
|
||||||
|
let manifest = local_manifest();
|
||||||
|
for id in [
|
||||||
|
"federation_net",
|
||||||
|
"ticket",
|
||||||
|
"rendezvous",
|
||||||
|
"music_dht",
|
||||||
|
"catalog",
|
||||||
|
"audio",
|
||||||
|
"similarity",
|
||||||
|
"similarity_dht",
|
||||||
|
"device_sync",
|
||||||
|
"jam",
|
||||||
|
] {
|
||||||
|
assert!(manifest.protocols.contains_key(id), "missing {id}");
|
||||||
|
}
|
||||||
|
manifest.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_reports_only_strictly_newer_versions() {
|
||||||
|
let observed = ObservedVersions::default();
|
||||||
|
lock(&observed.versions).insert("music_dht".to_string(), 99);
|
||||||
|
lock(&observed.versions).insert("jam".to_string(), crate::jam::PROTOCOL_VERSION);
|
||||||
|
let snapshot = ProtocolVersions::snapshot(&observed);
|
||||||
|
assert_eq!(snapshot.newer.len(), 1);
|
||||||
|
assert_eq!(snapshot.newer[0].id, "music_dht");
|
||||||
|
}
|
||||||
|
}
|
||||||
+353
-51
@@ -13,7 +13,9 @@
|
|||||||
//! the network too).
|
//! the network too).
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
|
mod capabilities;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
|
mod similarity;
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -23,6 +25,8 @@ use std::sync::atomic::{AtomicI64, Ordering};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::similarity_dht::SimilarityDht;
|
||||||
|
use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN;
|
||||||
use music_dht::{
|
use music_dht::{
|
||||||
ByteStream, ByteStreamConnectionStats, EndpointId, ItemKind, ItemSpec, LibraryItem,
|
ByteStream, ByteStreamConnectionStats, EndpointId, ItemKind, ItemSpec, LibraryItem,
|
||||||
MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats, RendezvousConfig,
|
MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats, RendezvousConfig,
|
||||||
@@ -36,7 +40,9 @@ use crate::library::NetworkArtistPreview;
|
|||||||
use crate::library::models::{ArtistRef, TrackItem};
|
use crate::library::models::{ArtistRef, TrackItem};
|
||||||
|
|
||||||
pub use audio::{AUDIO_ALPN, DownloadProgress, StreamingStart, TrackMetadata};
|
pub use audio::{AUDIO_ALPN, DownloadProgress, StreamingStart, TrackMetadata};
|
||||||
|
pub use capabilities::ProtocolVersions;
|
||||||
pub use catalog::{CATALOG_ALPN, FedAppearsOn, FedArtistCard, FedCardTrack, FedRelease};
|
pub use catalog::{CATALOG_ALPN, FedAppearsOn, FedArtistCard, FedCardTrack, FedRelease};
|
||||||
|
pub use similarity::SIMILARITY_ALPN;
|
||||||
|
|
||||||
/// How often the published library is re-synchronized with the local index.
|
/// How often the published library is re-synchronized with the local index.
|
||||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
@@ -332,6 +338,27 @@ pub struct FedSearchResults {
|
|||||||
pub tracks: Vec<FedTrack>,
|
pub tracks: Vec<FedTrack>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScoredFedTrack {
|
||||||
|
pub track: FedTrack,
|
||||||
|
pub score: f32,
|
||||||
|
pub embedding_signature: Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct SimilaritySearchStats {
|
||||||
|
pub tracks: usize,
|
||||||
|
pub artists: usize,
|
||||||
|
pub peers_queried: usize,
|
||||||
|
pub elapsed_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FedSimilaritySearchResults {
|
||||||
|
pub tracks: Vec<ScoredFedTrack>,
|
||||||
|
pub stats: SimilaritySearchStats,
|
||||||
|
}
|
||||||
|
|
||||||
/// A track found through federated search.
|
/// A track found through federated search.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FedTrack {
|
pub struct FedTrack {
|
||||||
@@ -386,6 +413,7 @@ pub struct FedStatus {
|
|||||||
pub last_sync: Option<String>,
|
pub last_sync: Option<String>,
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
pub transport: TransportStatsSnapshot,
|
pub transport: TransportStatsSnapshot,
|
||||||
|
pub protocols: ProtocolVersions,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of preparing a federated track for playback.
|
/// Outcome of preparing a federated track for playback.
|
||||||
@@ -408,6 +436,7 @@ pub struct NetworkLibrarySource {
|
|||||||
|
|
||||||
struct Running {
|
struct Running {
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
|
similarity_dht: Arc<SimilarityDht>,
|
||||||
network_name: String,
|
network_name: String,
|
||||||
network_id: NetworkId,
|
network_id: NetworkId,
|
||||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
@@ -416,15 +445,20 @@ struct Running {
|
|||||||
pub struct Federation {
|
pub struct Federation {
|
||||||
library: Arc<Library>,
|
library: Arc<Library>,
|
||||||
devices: Arc<crate::devices::DeviceSync>,
|
devices: Arc<crate::devices::DeviceSync>,
|
||||||
|
jam: Arc<crate::jam::JamManager>,
|
||||||
|
similarity: Arc<crate::similarity::Manager>,
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
cache_dir: PathBuf,
|
cache_dir: PathBuf,
|
||||||
media_dir: PathBuf,
|
media_dir: std::sync::Mutex<PathBuf>,
|
||||||
|
/// Serializes permanent downloads/card writes with a directory change.
|
||||||
|
media_change: tokio::sync::Mutex<()>,
|
||||||
metadata_cache: std::sync::Mutex<std::collections::HashMap<String, CachedTrackMetadata>>,
|
metadata_cache: std::sync::Mutex<std::collections::HashMap<String, CachedTrackMetadata>>,
|
||||||
settings: std::sync::Mutex<FedSettings>,
|
settings: std::sync::Mutex<FedSettings>,
|
||||||
running: tokio::sync::Mutex<Option<Running>>,
|
running: tokio::sync::Mutex<Option<Running>>,
|
||||||
last_sync: std::sync::Mutex<Option<String>>,
|
last_sync: std::sync::Mutex<Option<String>>,
|
||||||
last_error: std::sync::Mutex<Option<String>>,
|
last_error: std::sync::Mutex<Option<String>>,
|
||||||
transport_stats: Arc<TransportStats>,
|
transport_stats: Arc<TransportStats>,
|
||||||
|
observed_protocols: Arc<capabilities::ObservedVersions>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -515,7 +549,13 @@ async fn dht_record_payload_bytes(data_dir: PathBuf, now_ms: u64) -> Result<u64>
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Federation {
|
impl Federation {
|
||||||
pub fn new(library: Arc<Library>, devices: Arc<crate::devices::DeviceSync>) -> Arc<Self> {
|
pub fn new(
|
||||||
|
library: Arc<Library>,
|
||||||
|
devices: Arc<crate::devices::DeviceSync>,
|
||||||
|
jam: Arc<crate::jam::JamManager>,
|
||||||
|
similarity: Arc<crate::similarity::Manager>,
|
||||||
|
media_dir: PathBuf,
|
||||||
|
) -> Arc<Self> {
|
||||||
let dirs = crate::config::project_dirs();
|
let dirs = crate::config::project_dirs();
|
||||||
let data_dir = dirs
|
let data_dir = dirs
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -525,10 +565,6 @@ impl Federation {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|d| d.cache_dir().join("fedcache"))
|
.map(|d| d.cache_dir().join("fedcache"))
|
||||||
.unwrap_or_else(|| PathBuf::from("fedcache"));
|
.unwrap_or_else(|| PathBuf::from("fedcache"));
|
||||||
let media_dir = dirs
|
|
||||||
.as_ref()
|
|
||||||
.map(|d| d.data_dir().join("federation-media"))
|
|
||||||
.unwrap_or_else(|| PathBuf::from("federation-media"));
|
|
||||||
let initial_error = [&data_dir, &cache_dir, &media_dir]
|
let initial_error = [&data_dir, &cache_dir, &media_dir]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find_map(|dir| {
|
.find_map(|dir| {
|
||||||
@@ -536,18 +572,23 @@ impl Federation {
|
|||||||
.err()
|
.err()
|
||||||
.map(|err| format!("cannot create {}: {err}", dir.display()))
|
.map(|err| format!("cannot create {}: {err}", dir.display()))
|
||||||
});
|
});
|
||||||
|
let media_dir = std::fs::canonicalize(&media_dir).unwrap_or(media_dir);
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
library,
|
library,
|
||||||
devices,
|
devices,
|
||||||
|
jam,
|
||||||
|
similarity,
|
||||||
data_dir,
|
data_dir,
|
||||||
cache_dir,
|
cache_dir,
|
||||||
media_dir,
|
media_dir: std::sync::Mutex::new(media_dir),
|
||||||
|
media_change: tokio::sync::Mutex::new(()),
|
||||||
metadata_cache: std::sync::Mutex::new(Default::default()),
|
metadata_cache: std::sync::Mutex::new(Default::default()),
|
||||||
settings: std::sync::Mutex::new(load_settings()),
|
settings: std::sync::Mutex::new(load_settings()),
|
||||||
running: tokio::sync::Mutex::new(None),
|
running: tokio::sync::Mutex::new(None),
|
||||||
last_sync: std::sync::Mutex::new(None),
|
last_sync: std::sync::Mutex::new(None),
|
||||||
last_error: std::sync::Mutex::new(initial_error),
|
last_error: std::sync::Mutex::new(initial_error),
|
||||||
transport_stats: Arc::new(TransportStats::default()),
|
transport_stats: Arc::new(TransportStats::default()),
|
||||||
|
observed_protocols: Arc::new(capabilities::ObservedVersions::default()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +596,61 @@ impl Federation {
|
|||||||
lock(&self.settings).clone()
|
lock(&self.settings).clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn media_dir(&self) -> PathBuf {
|
||||||
|
lock(&self.media_dir).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches the permanent-download root while excluding concurrent
|
||||||
|
/// downloads. Validation runs again here because permissions may have
|
||||||
|
/// changed after the confirmation popup was shown.
|
||||||
|
pub async fn change_media_dir(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
requested: PathBuf,
|
||||||
|
move_existing: bool,
|
||||||
|
) -> Result<crate::library::MusicRelocationStats> {
|
||||||
|
let _guard = self.media_change.lock().await;
|
||||||
|
let old = self.media_dir();
|
||||||
|
let library = Arc::clone(&self.library);
|
||||||
|
let requested_for_task = requested.clone();
|
||||||
|
let (path, stats) = tokio::task::spawn_blocking(move || -> Result<_> {
|
||||||
|
let path = Library::validate_music_directory(&requested_for_task)?;
|
||||||
|
if path == std::fs::canonicalize(&old).unwrap_or(old.clone()) {
|
||||||
|
return Ok((path, crate::library::MusicRelocationStats::default()));
|
||||||
|
}
|
||||||
|
let stats = if move_existing && old.exists() {
|
||||||
|
library.relocate_managed_music(&old, &path)?
|
||||||
|
} else {
|
||||||
|
crate::library::MusicRelocationStats::default()
|
||||||
|
};
|
||||||
|
Ok((path, stats))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("music directory task failed")??;
|
||||||
|
*lock(&self.media_dir) = path;
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_jam(&self) -> Result<String> {
|
||||||
|
let service = {
|
||||||
|
let running = self.running.lock().await;
|
||||||
|
Arc::clone(
|
||||||
|
&running
|
||||||
|
.as_ref()
|
||||||
|
.context("enable federation before creating a Jam")?
|
||||||
|
.service,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let (device_id, device_name) = self.devices.identity_summary()?;
|
||||||
|
self.jam
|
||||||
|
.create_or_regenerate(service, device_id, device_name)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join_jam(&self, invite: &str) -> Result<()> {
|
||||||
|
let (device_id, device_name) = self.devices.identity_summary()?;
|
||||||
|
self.jam.join(invite, device_id, device_name)
|
||||||
|
}
|
||||||
|
|
||||||
fn cached_metadata_snapshot(&self) -> Vec<CachedTrackMetadata> {
|
fn cached_metadata_snapshot(&self) -> Vec<CachedTrackMetadata> {
|
||||||
lock(&self.metadata_cache).values().cloned().collect()
|
lock(&self.metadata_cache).values().cloned().collect()
|
||||||
}
|
}
|
||||||
@@ -625,8 +721,17 @@ impl Federation {
|
|||||||
.stream_protocol(AUDIO_ALPN)
|
.stream_protocol(AUDIO_ALPN)
|
||||||
// ...and browse each other's per-artist catalogs over this one.
|
// ...and browse each other's per-artist catalogs over this one.
|
||||||
.stream_protocol(CATALOG_ALPN)
|
.stream_protocol(CATALOG_ALPN)
|
||||||
|
// Anonymous, bounded direct embedding queries have their own
|
||||||
|
// versioned contract and survive catalog-schema upgrades.
|
||||||
|
.schema_independent_stream_protocol(SIMILARITY_ALPN)
|
||||||
// Personal-device sync (likes, playlists, trusted devices).
|
// Personal-device sync (likes, playlists, trusted devices).
|
||||||
.stream_protocol(crate::devices::SYNC_ALPN)
|
.stream_protocol(crate::devices::SYNC_ALPN)
|
||||||
|
// Capability-scoped shared playback control.
|
||||||
|
.stream_protocol(crate::jam::JAM_ALPN)
|
||||||
|
// Signed LSH summaries form their own upgrade-safe DHT overlay.
|
||||||
|
.schema_independent_stream_protocol(SIMILARITY_DHT_ALPN)
|
||||||
|
// Informational application/protocol versions.
|
||||||
|
.schema_independent_stream_protocol(capabilities::CAPABILITIES_ALPN)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||||
let (service, mut events) = MusicDhtService::start(config)
|
let (service, mut events) = MusicDhtService::start(config)
|
||||||
@@ -639,6 +744,25 @@ impl Federation {
|
|||||||
"federation started"
|
"federation started"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let similarity_dht = SimilarityDht::open(
|
||||||
|
Arc::clone(&service),
|
||||||
|
self.data_dir.join("similarity-routing.sqlite3"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to start the similarity DHT: {err}"))?;
|
||||||
|
let similarity_dht_acceptor = service
|
||||||
|
.stream_acceptor(SIMILARITY_DHT_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take similarity DHT acceptor: {err}"))?;
|
||||||
|
let similarity_dht_serve_task =
|
||||||
|
tokio::spawn(Arc::clone(&similarity_dht).serve(similarity_dht_acceptor));
|
||||||
|
let similarity_dht_maintenance_task =
|
||||||
|
tokio::spawn(Arc::clone(&similarity_dht).maintenance());
|
||||||
|
let similarity_dht_sync_task = tokio::spawn(similarity_route_sync_loop(
|
||||||
|
Arc::clone(&similarity_dht),
|
||||||
|
Arc::clone(&self.similarity),
|
||||||
|
Arc::clone(&self.library),
|
||||||
|
));
|
||||||
|
|
||||||
// Drain DHT events into the log; the channel is bounded.
|
// Drain DHT events into the log; the channel is bounded.
|
||||||
let event_task = tokio::spawn(async move {
|
let event_task = tokio::spawn(async move {
|
||||||
while let Some(event) = events.recv().await {
|
while let Some(event) = events.recv().await {
|
||||||
@@ -675,6 +799,15 @@ impl Federation {
|
|||||||
service.endpoint_id(),
|
service.endpoint_id(),
|
||||||
Arc::clone(&self.transport_stats),
|
Arc::clone(&self.transport_stats),
|
||||||
));
|
));
|
||||||
|
let similarity_acceptor = service
|
||||||
|
.stream_acceptor(SIMILARITY_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take the similarity acceptor: {err}"))?;
|
||||||
|
let similarity_task = tokio::spawn(similarity::serve_peers(
|
||||||
|
similarity_acceptor,
|
||||||
|
Arc::clone(&self.similarity),
|
||||||
|
service.endpoint_id(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
));
|
||||||
let sync_acceptor = service
|
let sync_acceptor = service
|
||||||
.stream_acceptor(crate::devices::SYNC_ALPN)
|
.stream_acceptor(crate::devices::SYNC_ALPN)
|
||||||
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
|
||||||
@@ -690,9 +823,27 @@ impl Federation {
|
|||||||
let device_tick_task = tokio::spawn(async move {
|
let device_tick_task = tokio::spawn(async move {
|
||||||
crate::devices::sync_loop(device_sync, device_service, device_transport).await;
|
crate::devices::sync_loop(device_sync, device_service, device_transport).await;
|
||||||
});
|
});
|
||||||
|
let jam_acceptor = service
|
||||||
|
.stream_acceptor(crate::jam::JAM_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take the Jam acceptor: {err}"))?;
|
||||||
|
let jam_serve_task =
|
||||||
|
tokio::spawn(crate::jam::serve_peers(jam_acceptor, Arc::clone(&self.jam)));
|
||||||
|
let jam_poll_task = tokio::spawn(crate::jam::poll_loop(
|
||||||
|
Arc::clone(&self.jam),
|
||||||
|
Arc::clone(&service),
|
||||||
|
));
|
||||||
|
let capabilities_acceptor = service
|
||||||
|
.stream_acceptor(capabilities::CAPABILITIES_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take capabilities acceptor: {err}"))?;
|
||||||
|
let capabilities_serve_task = tokio::spawn(capabilities::serve(capabilities_acceptor));
|
||||||
|
let capabilities_probe_task = tokio::spawn(capabilities::probe_loop(
|
||||||
|
Arc::clone(&service),
|
||||||
|
Arc::clone(&self.observed_protocols),
|
||||||
|
));
|
||||||
|
|
||||||
*guard = Some(Running {
|
*guard = Some(Running {
|
||||||
service,
|
service,
|
||||||
|
similarity_dht,
|
||||||
network_name,
|
network_name,
|
||||||
network_id,
|
network_id,
|
||||||
tasks: vec![
|
tasks: vec![
|
||||||
@@ -700,8 +851,16 @@ impl Federation {
|
|||||||
sync_task,
|
sync_task,
|
||||||
audio_task,
|
audio_task,
|
||||||
catalog_task,
|
catalog_task,
|
||||||
|
similarity_task,
|
||||||
device_sync_task,
|
device_sync_task,
|
||||||
device_tick_task,
|
device_tick_task,
|
||||||
|
jam_serve_task,
|
||||||
|
jam_poll_task,
|
||||||
|
capabilities_serve_task,
|
||||||
|
capabilities_probe_task,
|
||||||
|
similarity_dht_serve_task,
|
||||||
|
similarity_dht_maintenance_task,
|
||||||
|
similarity_dht_sync_task,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
self.set_error(None);
|
self.set_error(None);
|
||||||
@@ -726,6 +885,20 @@ impl Federation {
|
|||||||
.context("federation is not running")
|
.context("federation is not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn similarity_services(&self) -> Result<(Arc<MusicDhtService>, Arc<SimilarityDht>)> {
|
||||||
|
self.running
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.map(|running| {
|
||||||
|
(
|
||||||
|
Arc::clone(&running.service),
|
||||||
|
Arc::clone(&running.similarity_dht),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.context("federation is not running")
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_connected_devices_enabled(&self) -> Result<()> {
|
fn ensure_connected_devices_enabled(&self) -> Result<()> {
|
||||||
let settings = self.settings();
|
let settings = self.settings();
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
@@ -840,6 +1013,7 @@ impl Federation {
|
|||||||
network: settings.network_id,
|
network: settings.network_id,
|
||||||
last_sync: lock(&self.last_sync).clone(),
|
last_sync: lock(&self.last_sync).clone(),
|
||||||
last_error: lock(&self.last_error).clone(),
|
last_error: lock(&self.last_error).clone(),
|
||||||
|
protocols: ProtocolVersions::snapshot(&self.observed_protocols),
|
||||||
..FedStatus::default()
|
..FedStatus::default()
|
||||||
};
|
};
|
||||||
if let Some(running) = guard.as_ref() {
|
if let Some(running) = guard.as_ref() {
|
||||||
@@ -1054,6 +1228,30 @@ impl Federation {
|
|||||||
Ok(FedSearchResults { artists, tracks })
|
Ok(FedSearchResults { artists, tracks })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounded fan-out to known peers using the exact model/profile
|
||||||
|
/// fingerprint carried with the query. No DHT records are written.
|
||||||
|
pub async fn search_similar(
|
||||||
|
&self,
|
||||||
|
query: crate::similarity::QueryVector,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<FedSimilaritySearchResults> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
self.similarity.network_allowed(),
|
||||||
|
"similarity federation has no consent"
|
||||||
|
);
|
||||||
|
let (service, similarity_dht) = self.similarity_services().await?;
|
||||||
|
let settings = self.similarity.settings();
|
||||||
|
similarity::search(
|
||||||
|
service,
|
||||||
|
similarity_dht,
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
settings.minimum_score,
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves a share-link content id to one playable federated track.
|
/// Resolves a share-link content id to one playable federated track.
|
||||||
///
|
///
|
||||||
/// Resolution order: the in-session metadata cache, the DHT content key,
|
/// Resolution order: the in-session metadata cache, the DHT content key,
|
||||||
@@ -1345,29 +1543,21 @@ impl Federation {
|
|||||||
Ok(peer.to_string())
|
Ok(peer.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Directory for streamed (never library-imported) card artwork.
|
|
||||||
fn art_cache_dir(&self) -> PathBuf {
|
|
||||||
self.cache_dir.join("art")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a cached-or-streamed image for the card: the artist image
|
/// Returns a cached-or-streamed image for the card: the artist image
|
||||||
/// (`release: None`) or a release cover. Peers are tried in order until
|
/// (`release: None`) or a release cover. Peers are tried in order until
|
||||||
/// one answers with an image; the result lands in the art cache and its
|
/// one answers with an image; the result is stored beside the artist or
|
||||||
/// local path is returned.
|
/// release in the permanent music tree and its local path is returned.
|
||||||
pub async fn card_image(
|
pub async fn card_image(
|
||||||
&self,
|
&self,
|
||||||
owners: &[String],
|
owners: &[String],
|
||||||
artist: &str,
|
artist: &str,
|
||||||
release: Option<&str>,
|
release: Option<&str>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let dir = self.art_cache_dir();
|
let _guard = self.media_change.lock().await;
|
||||||
|
let dir = music_art_dir(&self.media_dir(), artist, release);
|
||||||
let stem = match release {
|
let stem = match release {
|
||||||
Some(release) => format!(
|
Some(_) => "cover".to_string(),
|
||||||
"cover-{}-{}",
|
None => "artist".to_string(),
|
||||||
sanitize_file_stem(artist),
|
|
||||||
sanitize_file_stem(release)
|
|
||||||
),
|
|
||||||
None => format!("artist-{}", sanitize_file_stem(artist)),
|
|
||||||
};
|
};
|
||||||
// Reuse a previously streamed copy of any known image type.
|
// Reuse a previously streamed copy of any known image type.
|
||||||
for extension in ["jpg", "png", "webp", "gif", "bmp"] {
|
for extension in ["jpg", "png", "webp", "gif", "bmp"] {
|
||||||
@@ -1431,7 +1621,7 @@ impl Federation {
|
|||||||
F: FnMut(DownloadProgress) + Send,
|
F: FnMut(DownloadProgress) + Send,
|
||||||
{
|
{
|
||||||
let save = self.settings().save_on_listen;
|
let save = self.settings().save_on_listen;
|
||||||
self.fetch_playable_with_progress(fed, save, false, progress, None)
|
self.fetch_playable_with_progress(fed, save, save, progress, None)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1446,7 +1636,7 @@ impl Federation {
|
|||||||
S: FnMut(StreamingStart) + Send,
|
S: FnMut(StreamingStart) + Send,
|
||||||
{
|
{
|
||||||
let save = self.settings().save_on_listen;
|
let save = self.settings().save_on_listen;
|
||||||
self.fetch_playable_with_progress(fed, save, false, progress, Some(&mut stream_start))
|
self.fetch_playable_with_progress(fed, save, save, progress, Some(&mut stream_start))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1500,6 +1690,11 @@ impl Federation {
|
|||||||
where
|
where
|
||||||
F: FnMut(DownloadProgress) + Send,
|
F: FnMut(DownloadProgress) + Send,
|
||||||
{
|
{
|
||||||
|
let _media_guard = if save {
|
||||||
|
Some(self.media_change.lock().await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let mut fed = fed.clone();
|
let mut fed = fed.clone();
|
||||||
let mut tried_content_lookup = false;
|
let mut tried_content_lookup = false;
|
||||||
|
|
||||||
@@ -1578,8 +1773,10 @@ impl Federation {
|
|||||||
}
|
}
|
||||||
Err(_) => anyhow::bail!("malformed owner id '{}'", fed.owner),
|
Err(_) => anyhow::bail!("malformed owner id '{}'", fed.owner),
|
||||||
};
|
};
|
||||||
|
let media_dir;
|
||||||
let dir = if save {
|
let dir = if save {
|
||||||
&self.media_dir
|
media_dir = music_release_dir(&self.media_dir(), &fed);
|
||||||
|
&media_dir
|
||||||
} else {
|
} else {
|
||||||
&self.cache_dir
|
&self.cache_dir
|
||||||
};
|
};
|
||||||
@@ -1640,8 +1837,14 @@ impl Federation {
|
|||||||
}
|
}
|
||||||
// Same for the cover: the peer's library cover wins over an
|
// Same for the cover: the peer's library cover wins over an
|
||||||
// embedded picture; embedded art stays as the fallback.
|
// embedded picture; embedded art stays as the fallback.
|
||||||
if import_cover.is_some() {
|
if let Some((bytes, extension)) = &import_cover {
|
||||||
import.cover = import_cover;
|
match save_release_cover(&import_path, bytes, extension) {
|
||||||
|
Ok(()) => import.cover = None,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(%err, "saving the release cover beside its music failed");
|
||||||
|
import.cover = import_cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let (track_id, _) = crate::library::import::upsert_track(&library, &import)?;
|
let (track_id, _) = crate::library::import::upsert_track(&library, &import)?;
|
||||||
// A like that referenced the federated track moves onto the
|
// A like that referenced the federated track moves onto the
|
||||||
@@ -1653,7 +1856,13 @@ impl Federation {
|
|||||||
// created (or still image-less) main artist.
|
// created (or still image-less) main artist.
|
||||||
if let (Some((bytes, extension)), Some(artist_name)) =
|
if let (Some((bytes, extension)), Some(artist_name)) =
|
||||||
(&artist_image, import.artists.first())
|
(&artist_image, import.artists.first())
|
||||||
&& let Err(err) = save_artist_image(&library, artist_name, bytes, extension)
|
&& let Err(err) = save_artist_image(
|
||||||
|
&library,
|
||||||
|
artist_name,
|
||||||
|
import_path.parent().and_then(Path::parent),
|
||||||
|
bytes,
|
||||||
|
extension,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
tracing::warn!(%err, "saving the artist image failed");
|
tracing::warn!(%err, "saving the artist image failed");
|
||||||
}
|
}
|
||||||
@@ -1880,20 +2089,18 @@ impl Federation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a received artist image into the covers directory and attaches it
|
/// Writes a received artist image into the artist's music directory and
|
||||||
/// to the artist unless one is already set.
|
/// attaches it to the artist unless one is already set.
|
||||||
fn save_artist_image(
|
fn save_artist_image(
|
||||||
library: &Library,
|
library: &Library,
|
||||||
artist_name: &str,
|
artist_name: &str,
|
||||||
|
artist_dir: Option<&Path>,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
extension: &str,
|
extension: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let covers_dir = library.covers_dir();
|
let artist_dir = artist_dir.context("downloaded track has no artist directory")?;
|
||||||
std::fs::create_dir_all(covers_dir)?;
|
std::fs::create_dir_all(artist_dir)?;
|
||||||
let path = covers_dir.join(format!(
|
let path = artist_dir.join(format!("artist.{extension}"));
|
||||||
"artist-{}.{extension}",
|
|
||||||
sanitize_file_stem(artist_name)
|
|
||||||
));
|
|
||||||
// Write only if the artist actually lacks an image, to avoid litter.
|
// Write only if the artist actually lacks an image, to avoid litter.
|
||||||
if library.artist_image_missing(artist_name)? {
|
if library.artist_image_missing(artist_name)? {
|
||||||
std::fs::write(&path, bytes)?;
|
std::fs::write(&path, bytes)?;
|
||||||
@@ -1902,6 +2109,16 @@ fn save_artist_image(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn save_release_cover(audio_path: &Path, bytes: &[u8], extension: &str) -> Result<()> {
|
||||||
|
let directory = audio_path
|
||||||
|
.parent()
|
||||||
|
.context("downloaded track has no release directory")?;
|
||||||
|
std::fs::create_dir_all(directory)?;
|
||||||
|
let path = directory.join(format!("cover.{extension}"));
|
||||||
|
std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Overlays the peer-supplied metadata onto tag-derived import data. Every
|
/// Overlays the peer-supplied metadata onto tag-derived import data. Every
|
||||||
/// non-empty peer field wins; file tags only fill the gaps.
|
/// non-empty peer field wins; file tags only fill the gaps.
|
||||||
fn apply_remote_metadata(import: &mut crate::library::import::TrackImport, meta: &TrackMetadata) {
|
fn apply_remote_metadata(import: &mut crate::library::import::TrackImport, meta: &TrackMetadata) {
|
||||||
@@ -2421,6 +2638,77 @@ fn sort_fed_appearances(appearances: &mut [FedAppearsOn]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn similarity_route_sync_loop(
|
||||||
|
routing: Arc<SimilarityDht>,
|
||||||
|
similarity: Arc<crate::similarity::Manager>,
|
||||||
|
library: Arc<Library>,
|
||||||
|
) {
|
||||||
|
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
let mut published_marker: Option<(String, blake3::Hash)> = None;
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if !similarity.network_allowed() {
|
||||||
|
if published_marker.take().is_some() {
|
||||||
|
routing.clear_local_signatures();
|
||||||
|
tracing::info!("local similarity DHT publication disabled");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let status = similarity.status();
|
||||||
|
let Some(profile_id) = status.active_profile else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if status.phase != crate::similarity::Phase::Ready {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let library = Arc::clone(&library);
|
||||||
|
let profile_for_task = profile_id.clone();
|
||||||
|
let loaded = tokio::task::spawn_blocking(move || {
|
||||||
|
let signatures = library.similarity_routing_signatures(&profile_for_task)?;
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
for signature in &signatures {
|
||||||
|
hasher.update(signature);
|
||||||
|
}
|
||||||
|
Ok::<_, anyhow::Error>((signatures, hasher.finalize()))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let (signatures, fingerprint) = match loaded {
|
||||||
|
Ok(Ok(loaded)) => loaded,
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
tracing::warn!(%error, %profile_id, "similarity routing signatures unavailable");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "similarity routing signature task failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let marker = (profile_id.clone(), fingerprint);
|
||||||
|
if published_marker.as_ref() == Some(&marker) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match routing
|
||||||
|
.sync_local_signatures(profile_id.clone(), signatures)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(stats) => {
|
||||||
|
tracing::info!(
|
||||||
|
profile = %profile_id,
|
||||||
|
records = stats.records,
|
||||||
|
keys = stats.keys,
|
||||||
|
remote_nodes = stats.remote_nodes,
|
||||||
|
"local similarity DHT index synchronized"
|
||||||
|
);
|
||||||
|
published_marker = Some(marker);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, %profile_id, "similarity DHT synchronization failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn stop_running(running: Option<Running>) {
|
async fn stop_running(running: Option<Running>) {
|
||||||
let Some(running) = running else { return };
|
let Some(running) = running else { return };
|
||||||
for task in &running.tasks {
|
for task in &running.tasks {
|
||||||
@@ -2488,29 +2776,43 @@ fn collect_specs(library: &Library) -> Result<Vec<ItemSpec>> {
|
|||||||
Ok(specs)
|
Ok(specs)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_file_stem(value: &str) -> String {
|
fn music_artist_dir(root: &Path, artist: &str) -> PathBuf {
|
||||||
let cleaned: String = value
|
let artist = if artist.trim().is_empty() {
|
||||||
.chars()
|
"Unknown Artist"
|
||||||
.map(|c| match c {
|
} else {
|
||||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
artist
|
||||||
c if c.is_control() => '_',
|
};
|
||||||
c => c,
|
root.join(crate::library::storage_name(artist, "Unknown Artist"))
|
||||||
})
|
}
|
||||||
.collect();
|
|
||||||
let trimmed = cleaned.trim().trim_matches('.');
|
fn music_art_dir(root: &Path, artist: &str, release: Option<&str>) -> PathBuf {
|
||||||
let mut stem: String = trimmed.chars().take(120).collect();
|
let artist_dir = music_artist_dir(root, artist);
|
||||||
if stem.is_empty() {
|
match release.filter(|release| !release.trim().is_empty()) {
|
||||||
stem.push_str("track");
|
Some(release) => artist_dir.join(crate::library::storage_name(release, "Unknown Release")),
|
||||||
|
None => artist_dir,
|
||||||
}
|
}
|
||||||
stem
|
}
|
||||||
|
|
||||||
|
fn music_release_dir(root: &Path, fed: &FedTrack) -> PathBuf {
|
||||||
|
let artist = fed
|
||||||
|
.artist_names
|
||||||
|
.first()
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("Unknown Artist");
|
||||||
|
let release = fed
|
||||||
|
.release_title
|
||||||
|
.as_deref()
|
||||||
|
.filter(|release| !release.trim().is_empty())
|
||||||
|
.unwrap_or("Unknown Release");
|
||||||
|
music_art_dir(root, artist, Some(release))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn download_stem(fed: &FedTrack) -> String {
|
fn download_stem(fed: &FedTrack) -> String {
|
||||||
let artists = fed.artist_line();
|
let artists = fed.artist_line();
|
||||||
if artists.is_empty() {
|
if artists.is_empty() {
|
||||||
sanitize_file_stem(&fed.title)
|
crate::library::storage_name(&fed.title, "track")
|
||||||
} else {
|
} else {
|
||||||
sanitize_file_stem(&format!("{artists} - {}", fed.title))
|
crate::library::storage_name(&format!("{artists} - {}", fed.title), "track")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
//! Furumi policy and local-index adapter for the shared similarity protocol.
|
||||||
|
//!
|
||||||
|
//! `music_dht::similarity` owns the versioned wire contract and framing. This
|
||||||
|
//! module owns application policy: consent, peer fan-out, local index access,
|
||||||
|
//! result conversion, deduplication, and ranking limits.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use futures_util::stream::{self, StreamExt as _};
|
||||||
|
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
||||||
|
use music_dht::similarity_dht::SimilarityDht;
|
||||||
|
use music_dht::{
|
||||||
|
ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::federation::{
|
||||||
|
FedSimilaritySearchResults, FedTrack, ScoredFedTrack, SimilaritySearchStats, TransportStats,
|
||||||
|
};
|
||||||
|
use crate::similarity::{Manager, QueryVector};
|
||||||
|
|
||||||
|
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||||
|
|
||||||
|
const INITIAL_QUERY_PEERS: usize = 16;
|
||||||
|
const MAX_QUERY_PEERS: usize = 48;
|
||||||
|
const QUERY_CONCURRENCY: usize = 8;
|
||||||
|
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const ROUTING_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||||
|
|
||||||
|
pub async fn serve_peers(
|
||||||
|
mut acceptor: StreamAcceptor,
|
||||||
|
similarity: Arc<Manager>,
|
||||||
|
own: EndpointId,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
let similarity = Arc::clone(&similarity);
|
||||||
|
let transport = Arc::clone(&transport);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let peer = stream.peer_id;
|
||||||
|
if let Err(err) = serve_one(stream, similarity, own, transport).await {
|
||||||
|
tracing::warn!(peer = %peer, "similarity request failed: {err:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_one(
|
||||||
|
mut stream: ByteStream,
|
||||||
|
similarity: Arc<Manager>,
|
||||||
|
own: EndpointId,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Result<()> {
|
||||||
|
super::record_stream_transport(&transport, "similarity", "inbound", "open", &stream);
|
||||||
|
let request = wire::read_request(&mut stream).await?;
|
||||||
|
let response = if !similarity.network_allowed() {
|
||||||
|
SimilarityResponse::refused("similarity federation is disabled or has no privacy consent")?
|
||||||
|
} else {
|
||||||
|
let profile = request.profile_id;
|
||||||
|
let vector = request.vector;
|
||||||
|
let limit = request.limit;
|
||||||
|
let matches = tokio::task::spawn_blocking(move || {
|
||||||
|
similarity.search_vector_for_peer(&profile, &vector, limit)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("local similarity task failed")
|
||||||
|
.and_then(|result| result);
|
||||||
|
match matches {
|
||||||
|
Ok(matches) => {
|
||||||
|
let hits = matches
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|found| {
|
||||||
|
let track = found.track;
|
||||||
|
let hit = SimilarityHit {
|
||||||
|
score: found.score,
|
||||||
|
item_id: super::audio::hex_encode(
|
||||||
|
ItemId::derive(
|
||||||
|
&own,
|
||||||
|
ItemKind::Track,
|
||||||
|
&format!("track:{}", track.id),
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
),
|
||||||
|
title: track.title,
|
||||||
|
artist_names: track
|
||||||
|
.artists
|
||||||
|
.into_iter()
|
||||||
|
.map(|artist| artist.name)
|
||||||
|
.collect(),
|
||||||
|
featured_artist_names: track
|
||||||
|
.featured_artists
|
||||||
|
.into_iter()
|
||||||
|
.map(|artist| artist.name)
|
||||||
|
.collect(),
|
||||||
|
year: track.release_year,
|
||||||
|
duration_seconds: Some(track.duration_seconds.round() as i64),
|
||||||
|
content_id: track.content_id,
|
||||||
|
release_title: Some(track.release_title),
|
||||||
|
track_number: track.track_number,
|
||||||
|
disc_number: track.disc_number,
|
||||||
|
embedding_signature: Some(found.embedding_signature),
|
||||||
|
};
|
||||||
|
match hit.validate() {
|
||||||
|
Ok(()) => Some(hit),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::debug!(%err, "invalid local similarity metadata skipped");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
SimilarityResponse::success(hits)?
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
SimilarityResponse::refused(format!("similarity query is unavailable: {err:#}"))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
wire::write_response(&mut stream, &response).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = stream.send.stopped().await;
|
||||||
|
super::record_stream_transport(&transport, "similarity", "inbound", "done", &stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
routing: Arc<SimilarityDht>,
|
||||||
|
query: QueryVector,
|
||||||
|
limit: usize,
|
||||||
|
minimum_score: f32,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Result<FedSimilaritySearchResults> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let own = service.endpoint_id();
|
||||||
|
let routed = match tokio::time::timeout(
|
||||||
|
ROUTING_TIMEOUT,
|
||||||
|
routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(peers)) => peers,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!("similarity DHT lookup timed out; using known peers");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
tracing::debug!(%error, "similarity DHT lookup unavailable; using known peers");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut peers: Vec<QueryPeer> = routed
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|ticket| {
|
||||||
|
let owner = ticket.endpoint_id();
|
||||||
|
(owner != own && seen.insert(owner)).then_some(QueryPeer {
|
||||||
|
owner,
|
||||||
|
ticket: Some(ticket),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for peer in service
|
||||||
|
.connected_peers()
|
||||||
|
.into_iter()
|
||||||
|
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
||||||
|
{
|
||||||
|
if peer != own && seen.insert(peer) {
|
||||||
|
peers.push(QueryPeer {
|
||||||
|
owner: peer,
|
||||||
|
ticket: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if peers.len() >= MAX_QUERY_PEERS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let query_signature = wire::embedding_signature(&query.vector)?;
|
||||||
|
let request = Arc::new(SimilarityRequest::new(
|
||||||
|
query.profile_id,
|
||||||
|
query.vector,
|
||||||
|
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
||||||
|
)?);
|
||||||
|
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
let initial = peers.len().min(INITIAL_QUERY_PEERS);
|
||||||
|
let responses = query_peers(
|
||||||
|
Arc::clone(&service),
|
||||||
|
&peers[..initial],
|
||||||
|
Arc::clone(&request),
|
||||||
|
Arc::clone(&transport),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut successful = 0usize;
|
||||||
|
let mut peers_queried = initial;
|
||||||
|
for response in responses {
|
||||||
|
match response {
|
||||||
|
Ok(peer_hits) => {
|
||||||
|
successful += 1;
|
||||||
|
hits.extend(peer_hits);
|
||||||
|
}
|
||||||
|
Err(err) => tracing::debug!(%err, "similarity peer query skipped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if initial < peers.len() && (hits.len() < limit || successful < initial.min(4)) {
|
||||||
|
peers_queried = peers.len();
|
||||||
|
for response in query_peers(
|
||||||
|
Arc::clone(&service),
|
||||||
|
&peers[initial..],
|
||||||
|
Arc::clone(&request),
|
||||||
|
Arc::clone(&transport),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
match response {
|
||||||
|
Ok(peer_hits) => hits.extend(peer_hits),
|
||||||
|
Err(err) => tracing::debug!(%err, "fallback similarity peer query skipped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||||
|
let mut dedup = HashSet::new();
|
||||||
|
let mut embedding_signatures = vec![query_signature];
|
||||||
|
let mut tracks = Vec::new();
|
||||||
|
for (track, score, embedding_signature) in hits {
|
||||||
|
if score < minimum_score {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if query
|
||||||
|
.source_content_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|source| track.content_id.as_deref() == Some(source))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = track
|
||||||
|
.content_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}:{}", track.owner, track.item_id));
|
||||||
|
if !dedup.insert(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if embedding_signature.is_some_and(|candidate| {
|
||||||
|
embedding_signatures.iter().any(|existing| {
|
||||||
|
wire::signature_distance(&candidate, existing)
|
||||||
|
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(signature) = embedding_signature {
|
||||||
|
embedding_signatures.push(signature);
|
||||||
|
}
|
||||||
|
tracks.push(ScoredFedTrack {
|
||||||
|
track,
|
||||||
|
score,
|
||||||
|
embedding_signature,
|
||||||
|
});
|
||||||
|
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let artists = tracks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|hit| hit.track.artist_names.first())
|
||||||
|
.map(|name| music_dht::normalize_name(name))
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.len();
|
||||||
|
let elapsed_ms = started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
|
||||||
|
Ok(FedSimilaritySearchResults {
|
||||||
|
stats: SimilaritySearchStats {
|
||||||
|
tracks: tracks.len(),
|
||||||
|
artists,
|
||||||
|
peers_queried,
|
||||||
|
elapsed_ms,
|
||||||
|
},
|
||||||
|
tracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerHits = Vec<(
|
||||||
|
FedTrack,
|
||||||
|
f32,
|
||||||
|
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
||||||
|
)>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct QueryPeer {
|
||||||
|
owner: EndpointId,
|
||||||
|
ticket: Option<PeerTicket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_peers(
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
peers: &[QueryPeer],
|
||||||
|
request: Arc<SimilarityRequest>,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Vec<Result<PeerHits>> {
|
||||||
|
stream::iter(peers.iter().cloned().map(|peer| {
|
||||||
|
let service = Arc::clone(&service);
|
||||||
|
let request = Arc::clone(&request);
|
||||||
|
let transport = Arc::clone(&transport);
|
||||||
|
async move {
|
||||||
|
tokio::time::timeout(
|
||||||
|
QUERY_TIMEOUT,
|
||||||
|
query_peer(service, peer, &request, transport),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.buffer_unordered(QUERY_CONCURRENCY)
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_peer(
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
peer: QueryPeer,
|
||||||
|
request: &SimilarityRequest,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Result<PeerHits> {
|
||||||
|
let owner = peer.owner;
|
||||||
|
let mut stream = match peer.ticket {
|
||||||
|
Some(ticket) => service.open_stream_to(&ticket, SIMILARITY_ALPN).await,
|
||||||
|
None => service.open_stream(owner, SIMILARITY_ALPN).await,
|
||||||
|
}
|
||||||
|
.map_err(|err| anyhow::anyhow!("cannot reach similarity peer: {err}"))?;
|
||||||
|
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
|
||||||
|
let response = wire::exchange(&mut stream, request).await?;
|
||||||
|
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
||||||
|
anyhow::ensure!(
|
||||||
|
response.ok,
|
||||||
|
"peer refused similarity query: {}",
|
||||||
|
response.error.unwrap_or_default()
|
||||||
|
);
|
||||||
|
Ok(response
|
||||||
|
.hits
|
||||||
|
.into_iter()
|
||||||
|
.map(|hit| {
|
||||||
|
let score = hit.score;
|
||||||
|
let embedding_signature = hit.embedding_signature;
|
||||||
|
(
|
||||||
|
FedTrack {
|
||||||
|
item_id: hit.item_id,
|
||||||
|
owner: owner.to_string(),
|
||||||
|
own: false,
|
||||||
|
title: hit.title,
|
||||||
|
artist_names: hit.artist_names,
|
||||||
|
featured_artist_names: hit.featured_artist_names,
|
||||||
|
year: hit.year,
|
||||||
|
duration_seconds: hit.duration_seconds,
|
||||||
|
content_id: hit.content_id,
|
||||||
|
release_title: hit.release_title,
|
||||||
|
track_number: hit.track_number,
|
||||||
|
disc_number: hit.disc_number,
|
||||||
|
},
|
||||||
|
score,
|
||||||
|
embedding_signature,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
+728
@@ -0,0 +1,728 @@
|
|||||||
|
//! Capability-based Jam playback control for independent federation peers.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::{ByteStream, MusicDhtService, PeerTicket, StreamAcceptor};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::app::event::AppEvent;
|
||||||
|
use crate::devices::{PlaybackCommand, PlaybackSnapshot};
|
||||||
|
|
||||||
|
pub const JAM_ALPN: &[u8] = b"furumi/jam/1";
|
||||||
|
pub const PROTOCOL_VERSION: u16 = 1;
|
||||||
|
const MAX_LINE: usize = 8 * 1024 * 1024;
|
||||||
|
const MAX_COMMANDS: usize = 128;
|
||||||
|
const PARTICIPANT_TTL_MS: i64 = 30 * 60 * 1_000;
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum JamRole {
|
||||||
|
None,
|
||||||
|
Host,
|
||||||
|
Participant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JamStatus {
|
||||||
|
pub role: JamRole,
|
||||||
|
pub host_name: Option<String>,
|
||||||
|
pub invite: Option<String>,
|
||||||
|
pub participants: Vec<JamParticipant>,
|
||||||
|
pub connected: bool,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for JamStatus {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
role: JamRole::None,
|
||||||
|
host_name: None,
|
||||||
|
invite: None,
|
||||||
|
participants: Vec::new(),
|
||||||
|
connected: false,
|
||||||
|
last_error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct JamParticipant {
|
||||||
|
pub participant_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub last_seen_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct JamInvite {
|
||||||
|
v: u16,
|
||||||
|
#[serde(rename = "t")]
|
||||||
|
ticket: String,
|
||||||
|
#[serde(rename = "j")]
|
||||||
|
jam_id: String,
|
||||||
|
#[serde(rename = "s")]
|
||||||
|
secret: String,
|
||||||
|
#[serde(rename = "d")]
|
||||||
|
host_device_id: String,
|
||||||
|
#[serde(rename = "n")]
|
||||||
|
host_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct JamCommand {
|
||||||
|
command_id: String,
|
||||||
|
participant_id: String,
|
||||||
|
command: PlaybackCommand,
|
||||||
|
sent_at_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
enum WireMessage {
|
||||||
|
Poll {
|
||||||
|
version: u16,
|
||||||
|
jam_id: String,
|
||||||
|
secret: String,
|
||||||
|
participant: JamParticipant,
|
||||||
|
#[serde(default)]
|
||||||
|
commands: Vec<JamCommand>,
|
||||||
|
},
|
||||||
|
Snapshot {
|
||||||
|
accepted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
error: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
acknowledged_command_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
playback: Option<PlaybackSnapshot>,
|
||||||
|
#[serde(default)]
|
||||||
|
participants: Vec<JamParticipant>,
|
||||||
|
host_time_ms: i64,
|
||||||
|
},
|
||||||
|
Leave {
|
||||||
|
version: u16,
|
||||||
|
jam_id: String,
|
||||||
|
secret: String,
|
||||||
|
participant_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct HostState {
|
||||||
|
invite: Option<JamInvite>,
|
||||||
|
invite_uri: Option<String>,
|
||||||
|
playback: Option<PlaybackSnapshot>,
|
||||||
|
participants: HashMap<String, JamParticipant>,
|
||||||
|
seen_commands: HashSet<String>,
|
||||||
|
seen_order: VecDeque<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JoinedState {
|
||||||
|
invite: JamInvite,
|
||||||
|
participant: JamParticipant,
|
||||||
|
pending: VecDeque<JamCommand>,
|
||||||
|
participants: Vec<JamParticipant>,
|
||||||
|
connected: bool,
|
||||||
|
last_error: Option<String>,
|
||||||
|
last_activity_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
host: HostState,
|
||||||
|
joined: Option<JoinedState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JamManager {
|
||||||
|
state: Mutex<State>,
|
||||||
|
event_tx: mpsc::UnboundedSender<AppEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JamManager {
|
||||||
|
pub fn new(event_tx: mpsc::UnboundedSender<AppEvent>) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
state: Mutex::new(State::default()),
|
||||||
|
event_tx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_or_regenerate(
|
||||||
|
&self,
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
host_device_id: String,
|
||||||
|
host_name: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
let invite = JamInvite {
|
||||||
|
v: PROTOCOL_VERSION,
|
||||||
|
ticket: service.ticket().await?.to_string(),
|
||||||
|
jam_id: format!("jam_{}", random_hex(12)),
|
||||||
|
secret: random_hex(32),
|
||||||
|
host_device_id,
|
||||||
|
host_name,
|
||||||
|
};
|
||||||
|
let uri = encode_invite(&invite)?;
|
||||||
|
let mut state = lock(&self.state);
|
||||||
|
state.joined = None;
|
||||||
|
state.host = HostState {
|
||||||
|
invite: Some(invite),
|
||||||
|
invite_uri: Some(uri.clone()),
|
||||||
|
..HostState::default()
|
||||||
|
};
|
||||||
|
Ok(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join(&self, uri: &str, participant_id: String, name: String) -> Result<()> {
|
||||||
|
let invite = parse_invite(uri)?;
|
||||||
|
let mut state = lock(&self.state);
|
||||||
|
state.host = HostState::default();
|
||||||
|
state.joined = Some(JoinedState {
|
||||||
|
invite,
|
||||||
|
participant: JamParticipant {
|
||||||
|
participant_id,
|
||||||
|
name,
|
||||||
|
last_seen_ms: now_ms(),
|
||||||
|
},
|
||||||
|
pending: VecDeque::new(),
|
||||||
|
participants: Vec::new(),
|
||||||
|
connected: false,
|
||||||
|
last_error: None,
|
||||||
|
last_activity_ms: now_ms(),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leave(&self) {
|
||||||
|
let mut state = lock(&self.state);
|
||||||
|
state.joined = None;
|
||||||
|
state.host = HostState::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> JamStatus {
|
||||||
|
let state = lock(&self.state);
|
||||||
|
if let Some(joined) = &state.joined {
|
||||||
|
return JamStatus {
|
||||||
|
role: JamRole::Participant,
|
||||||
|
host_name: Some(joined.invite.host_name.clone()),
|
||||||
|
invite: None,
|
||||||
|
participants: joined.participants.clone(),
|
||||||
|
connected: joined.connected,
|
||||||
|
last_error: joined.last_error.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(invite) = &state.host.invite {
|
||||||
|
return JamStatus {
|
||||||
|
role: JamRole::Host,
|
||||||
|
host_name: Some(invite.host_name.clone()),
|
||||||
|
invite: state.host.invite_uri.clone(),
|
||||||
|
participants: state.host.participants.values().cloned().collect(),
|
||||||
|
connected: true,
|
||||||
|
last_error: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
JamStatus::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publish_host_playback(&self, snapshot: PlaybackSnapshot) {
|
||||||
|
let mut state = lock(&self.state);
|
||||||
|
if state.host.invite.is_some() {
|
||||||
|
state.host.playback = Some(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn submit_command(&self, command: PlaybackCommand) -> Result<()> {
|
||||||
|
let mut state = lock(&self.state);
|
||||||
|
let joined = state
|
||||||
|
.joined
|
||||||
|
.as_mut()
|
||||||
|
.context("this player is not controlling a Jam")?;
|
||||||
|
if joined.pending.len() >= MAX_COMMANDS {
|
||||||
|
joined.pending.pop_front();
|
||||||
|
}
|
||||||
|
joined.pending.push_back(JamCommand {
|
||||||
|
command_id: format!("cmd_{}", random_hex(16)),
|
||||||
|
participant_id: joined.participant.participant_id.clone(),
|
||||||
|
command,
|
||||||
|
sent_at_ms: now_ms(),
|
||||||
|
});
|
||||||
|
joined.last_activity_ms = now_ms();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll_once(&self, service: Arc<MusicDhtService>) -> Result<()> {
|
||||||
|
let (invite, participant, commands) = {
|
||||||
|
let state = lock(&self.state);
|
||||||
|
let joined = state.joined.as_ref().context("not joined")?;
|
||||||
|
(
|
||||||
|
joined.invite.clone(),
|
||||||
|
joined.participant.clone(),
|
||||||
|
joined.pending.iter().cloned().collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let ticket: PeerTicket = invite.ticket.parse().context("invalid Jam host ticket")?;
|
||||||
|
let peer = service.connect(ticket).await?;
|
||||||
|
let mut stream = service.open_stream(peer, JAM_ALPN).await?;
|
||||||
|
write_message(
|
||||||
|
&mut stream,
|
||||||
|
&WireMessage::Poll {
|
||||||
|
version: PROTOCOL_VERSION,
|
||||||
|
jam_id: invite.jam_id.clone(),
|
||||||
|
secret: invite.secret.clone(),
|
||||||
|
participant,
|
||||||
|
commands,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let response = read_message(&mut stream).await?;
|
||||||
|
let WireMessage::Snapshot {
|
||||||
|
accepted,
|
||||||
|
error,
|
||||||
|
acknowledged_command_ids,
|
||||||
|
playback,
|
||||||
|
participants,
|
||||||
|
..
|
||||||
|
} = response
|
||||||
|
else {
|
||||||
|
anyhow::bail!("unexpected Jam response");
|
||||||
|
};
|
||||||
|
anyhow::ensure!(
|
||||||
|
accepted,
|
||||||
|
"{}",
|
||||||
|
error.unwrap_or_else(|| "Jam refused".into())
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let mut state = lock(&self.state);
|
||||||
|
let Some(joined) = state.joined.as_mut() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if joined.invite.jam_id != invite.jam_id {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let acknowledged = acknowledged_command_ids.into_iter().collect::<HashSet<_>>();
|
||||||
|
joined
|
||||||
|
.pending
|
||||||
|
.retain(|command| !acknowledged.contains(&command.command_id));
|
||||||
|
joined.participants = participants;
|
||||||
|
joined.connected = true;
|
||||||
|
joined.last_error = None;
|
||||||
|
joined.participant.last_seen_ms = now_ms();
|
||||||
|
if playback
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|snapshot| snapshot.state.playing && !snapshot.state.paused)
|
||||||
|
{
|
||||||
|
joined.last_activity_ms = now_ms();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(playback) = playback {
|
||||||
|
let _ = self.event_tx.send(AppEvent::JamPlayback(playback));
|
||||||
|
}
|
||||||
|
let _ = self.event_tx.send(AppEvent::JamStatus(self.status()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve_peers(mut acceptor: StreamAcceptor, manager: Arc<JamManager>) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = serve_one(stream, manager).await {
|
||||||
|
tracing::debug!("Jam stream failed: {err:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_one(mut stream: ByteStream, manager: Arc<JamManager>) -> Result<()> {
|
||||||
|
match read_message(&mut stream).await? {
|
||||||
|
WireMessage::Poll {
|
||||||
|
version,
|
||||||
|
jam_id,
|
||||||
|
secret,
|
||||||
|
mut participant,
|
||||||
|
commands,
|
||||||
|
} => {
|
||||||
|
let (accepted, error, acknowledged, playback, participants) = {
|
||||||
|
let mut state = lock(&manager.state);
|
||||||
|
let valid =
|
||||||
|
version == PROTOCOL_VERSION
|
||||||
|
&& state.host.invite.as_ref().is_some_and(|invite| {
|
||||||
|
invite.jam_id == jam_id && invite.secret == secret
|
||||||
|
});
|
||||||
|
if !valid {
|
||||||
|
(
|
||||||
|
false,
|
||||||
|
Some("invalid Jam capability".to_string()),
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let now = now_ms();
|
||||||
|
state.host.participants.retain(|_, row| {
|
||||||
|
now.saturating_sub(row.last_seen_ms) <= PARTICIPANT_TTL_MS
|
||||||
|
});
|
||||||
|
participant.last_seen_ms = now;
|
||||||
|
state
|
||||||
|
.host
|
||||||
|
.participants
|
||||||
|
.insert(participant.participant_id.clone(), participant);
|
||||||
|
let mut acknowledged = Vec::new();
|
||||||
|
for command in commands.into_iter().take(MAX_COMMANDS) {
|
||||||
|
acknowledged.push(command.command_id.clone());
|
||||||
|
if state.host.seen_commands.insert(command.command_id.clone()) {
|
||||||
|
state.host.seen_order.push_back(command.command_id.clone());
|
||||||
|
let _ = manager.event_tx.send(AppEvent::JamCommand(command.command));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while state.host.seen_order.len() > 4096 {
|
||||||
|
if let Some(id) = state.host.seen_order.pop_front() {
|
||||||
|
state.host.seen_commands.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
acknowledged,
|
||||||
|
state.host.playback.clone(),
|
||||||
|
state.host.participants.values().cloned().collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
write_message(
|
||||||
|
&mut stream,
|
||||||
|
&WireMessage::Snapshot {
|
||||||
|
accepted,
|
||||||
|
error,
|
||||||
|
acknowledged_command_ids: acknowledged,
|
||||||
|
playback,
|
||||||
|
participants,
|
||||||
|
host_time_ms: now_ms(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
|
||||||
|
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
||||||
|
}
|
||||||
|
WireMessage::Leave {
|
||||||
|
version,
|
||||||
|
jam_id,
|
||||||
|
secret,
|
||||||
|
participant_id,
|
||||||
|
} => {
|
||||||
|
let mut state = lock(&manager.state);
|
||||||
|
if version == PROTOCOL_VERSION
|
||||||
|
&& state
|
||||||
|
.host
|
||||||
|
.invite
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|invite| invite.jam_id == jam_id && invite.secret == secret)
|
||||||
|
{
|
||||||
|
state.host.participants.remove(&participant_id);
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
||||||
|
}
|
||||||
|
WireMessage::Snapshot { .. } => anyhow::bail!("unexpected Jam snapshot"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn poll_loop(manager: Arc<JamManager>, service: Arc<MusicDhtService>) {
|
||||||
|
let mut interval = tokio::time::interval(POLL_INTERVAL);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if manager.status().role != JamRole::Participant {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let expired = {
|
||||||
|
let state = lock(&manager.state);
|
||||||
|
state.joined.as_ref().is_some_and(|joined| {
|
||||||
|
now_ms().saturating_sub(joined.last_activity_ms) > PARTICIPANT_TTL_MS
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if expired {
|
||||||
|
manager.leave();
|
||||||
|
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
||||||
|
let _ = manager.event_tx.send(AppEvent::StatusMessage(
|
||||||
|
"Jam ended after 30 minutes without playback or commands".into(),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(err) = manager.poll_once(Arc::clone(&service)).await {
|
||||||
|
{
|
||||||
|
let mut state = lock(&manager.state);
|
||||||
|
if let Some(joined) = state.joined.as_mut() {
|
||||||
|
joined.connected = false;
|
||||||
|
joined.last_error = Some(format!("{err:#}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_message(stream: &mut ByteStream, message: &WireMessage) -> Result<()> {
|
||||||
|
let mut bytes = serde_json::to_vec(message)?;
|
||||||
|
anyhow::ensure!(bytes.len() <= MAX_LINE, "Jam message is too large");
|
||||||
|
bytes.push(b'\n');
|
||||||
|
stream.send.write_all(&bytes).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_message(stream: &mut ByteStream) -> Result<WireMessage> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
let mut byte = [0_u8; 1];
|
||||||
|
loop {
|
||||||
|
let read = stream.recv.read(&mut byte).await?;
|
||||||
|
if read.unwrap_or(0) == 0 || byte[0] == b'\n' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bytes.push(byte[0]);
|
||||||
|
anyhow::ensure!(bytes.len() <= MAX_LINE, "Jam message is too large");
|
||||||
|
}
|
||||||
|
anyhow::ensure!(!bytes.is_empty(), "empty Jam message");
|
||||||
|
Ok(serde_json::from_slice(&bytes)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_invite(invite: &JamInvite) -> Result<String> {
|
||||||
|
Ok(format!(
|
||||||
|
"frid://j/{}",
|
||||||
|
base64url_encode(&serde_json::to_vec(invite)?)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_invite(uri: &str) -> Result<JamInvite> {
|
||||||
|
let encoded = uri
|
||||||
|
.trim()
|
||||||
|
.strip_prefix("frid://j/")
|
||||||
|
.context("expected frid://j invite")?;
|
||||||
|
let invite: JamInvite = serde_json::from_slice(&base64url_decode(encoded)?)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
invite.v == PROTOCOL_VERSION,
|
||||||
|
"unsupported Jam invite version"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
!invite.ticket.is_empty()
|
||||||
|
&& !invite.jam_id.is_empty()
|
||||||
|
&& invite.secret.len() >= 16
|
||||||
|
&& !invite.host_device_id.is_empty(),
|
||||||
|
"incomplete Jam invite"
|
||||||
|
);
|
||||||
|
Ok(invite)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_hex(bytes: usize) -> String {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
static COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
|
let seed = format!(
|
||||||
|
"{}:{}:{}",
|
||||||
|
now_ms(),
|
||||||
|
std::process::id(),
|
||||||
|
COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||||
|
);
|
||||||
|
let hash = blake3::hash(seed.as_bytes()).to_hex().to_string();
|
||||||
|
hash[..(bytes * 2).min(hash.len())].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||||
|
mutex
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64url_encode(bytes: &[u8]) -> String {
|
||||||
|
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||||
|
let mut out = String::new();
|
||||||
|
for chunk in bytes.chunks(3) {
|
||||||
|
let b0 = chunk[0];
|
||||||
|
let b1 = chunk.get(1).copied().unwrap_or(0);
|
||||||
|
let b2 = chunk.get(2).copied().unwrap_or(0);
|
||||||
|
out.push(TABLE[(b0 >> 2) as usize] as char);
|
||||||
|
out.push(TABLE[(((b0 & 3) << 4) | (b1 >> 4)) as usize] as char);
|
||||||
|
if chunk.len() > 1 {
|
||||||
|
out.push(TABLE[(((b1 & 15) << 2) | (b2 >> 6)) as usize] as char);
|
||||||
|
}
|
||||||
|
if chunk.len() > 2 {
|
||||||
|
out.push(TABLE[(b2 & 63) as usize] as char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64url_decode(value: &str) -> Result<Vec<u8>> {
|
||||||
|
fn decode(byte: u8) -> Option<u8> {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z' => Some(byte - b'A'),
|
||||||
|
b'a'..=b'z' => Some(byte - b'a' + 26),
|
||||||
|
b'0'..=b'9' => Some(byte - b'0' + 52),
|
||||||
|
b'-' => Some(62),
|
||||||
|
b'_' => Some(63),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
anyhow::ensure!(bytes.len() % 4 != 1, "invalid base64url Jam invite");
|
||||||
|
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let a = decode(bytes[i]).context("invalid base64url Jam invite")?;
|
||||||
|
let b = decode(*bytes.get(i + 1).context("truncated Jam invite")?)
|
||||||
|
.context("invalid base64url Jam invite")?;
|
||||||
|
let c = bytes.get(i + 2).and_then(|byte| decode(*byte));
|
||||||
|
let d = bytes.get(i + 3).and_then(|byte| decode(*byte));
|
||||||
|
out.push((a << 2) | (b >> 4));
|
||||||
|
if let Some(c) = c {
|
||||||
|
out.push((b << 4) | (c >> 2));
|
||||||
|
if let Some(d) = d {
|
||||||
|
out.push((c << 6) | d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invite_round_trips_and_is_separate_from_pairing() {
|
||||||
|
let invite = JamInvite {
|
||||||
|
v: PROTOCOL_VERSION,
|
||||||
|
ticket: "ticket".into(),
|
||||||
|
jam_id: "jam_test".into(),
|
||||||
|
secret: "0123456789abcdef".into(),
|
||||||
|
host_device_id: "dev_host".into(),
|
||||||
|
host_name: "Host".into(),
|
||||||
|
};
|
||||||
|
let uri = encode_invite(&invite).unwrap();
|
||||||
|
assert!(uri.starts_with("frid://j/"));
|
||||||
|
assert_eq!(parse_invite(&uri).unwrap().jam_id, "jam_test");
|
||||||
|
assert!(parse_invite("frid://i/abcd").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn participant_receives_host_state_and_host_receives_command() {
|
||||||
|
let unique = random_hex(8);
|
||||||
|
let host_dir = std::env::temp_dir().join(format!("furumi-jam-host-{unique}"));
|
||||||
|
let guest_dir = std::env::temp_dir().join(format!("furumi-jam-guest-{unique}"));
|
||||||
|
std::fs::create_dir_all(&host_dir).unwrap();
|
||||||
|
std::fs::create_dir_all(&guest_dir).unwrap();
|
||||||
|
let network = music_dht::NetworkId::from_name(&format!("jam-test-{unique}"));
|
||||||
|
let host_config = music_dht::MusicDhtConfig::builder()
|
||||||
|
.data_dir(&host_dir)
|
||||||
|
.network_id(network)
|
||||||
|
.stream_protocol(JAM_ALPN)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let guest_config = music_dht::MusicDhtConfig::builder()
|
||||||
|
.data_dir(&guest_dir)
|
||||||
|
.network_id(network)
|
||||||
|
.stream_protocol(JAM_ALPN)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let (host_service, mut host_events) = music_dht::MusicDhtService::start(host_config)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (guest_service, mut guest_events) = music_dht::MusicDhtService::start(guest_config)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let host_service = Arc::new(host_service);
|
||||||
|
let guest_service = Arc::new(guest_service);
|
||||||
|
let host_drain = tokio::spawn(async move { while host_events.recv().await.is_some() {} });
|
||||||
|
let guest_drain = tokio::spawn(async move { while guest_events.recv().await.is_some() {} });
|
||||||
|
|
||||||
|
let (host_tx, mut host_rx) = mpsc::unbounded_channel();
|
||||||
|
let (guest_tx, mut guest_rx) = mpsc::unbounded_channel();
|
||||||
|
let host = JamManager::new(host_tx);
|
||||||
|
let guest = JamManager::new(guest_tx);
|
||||||
|
let invite = host
|
||||||
|
.create_or_regenerate(Arc::clone(&host_service), "dev_host".into(), "Host".into())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
guest
|
||||||
|
.join(&invite, "dev_guest".into(), "Guest".into())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let playback_state = crate::devices::PlaybackStateWire {
|
||||||
|
queue: Vec::new(),
|
||||||
|
queue_pos: 0,
|
||||||
|
playing: false,
|
||||||
|
paused: false,
|
||||||
|
idle_since_ms: Some(now_ms()),
|
||||||
|
position_secs: 0.0,
|
||||||
|
volume: 73,
|
||||||
|
shuffle: false,
|
||||||
|
repeat: crate::devices::PlaybackRepeat::Off,
|
||||||
|
};
|
||||||
|
host.publish_host_playback(PlaybackSnapshot {
|
||||||
|
device_id: "dev_host".into(),
|
||||||
|
device_name: "Host".into(),
|
||||||
|
active: true,
|
||||||
|
updated_at_ms: now_ms(),
|
||||||
|
state: playback_state.clone(),
|
||||||
|
});
|
||||||
|
let acceptor = host_service.stream_acceptor(JAM_ALPN).unwrap();
|
||||||
|
let server = tokio::spawn(serve_peers(acceptor, Arc::clone(&host)));
|
||||||
|
|
||||||
|
guest.poll_once(Arc::clone(&guest_service)).await.unwrap();
|
||||||
|
let playback = tokio::time::timeout(Duration::from_secs(3), async {
|
||||||
|
loop {
|
||||||
|
if let Some(AppEvent::JamPlayback(snapshot)) = guest_rx.recv().await {
|
||||||
|
break snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(playback.device_id, "dev_host");
|
||||||
|
assert_eq!(playback.state.volume, 73);
|
||||||
|
|
||||||
|
guest
|
||||||
|
.submit_command(PlaybackCommand::SetState {
|
||||||
|
state: crate::devices::PlaybackStateWire {
|
||||||
|
paused: true,
|
||||||
|
..playback_state
|
||||||
|
},
|
||||||
|
seek: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
guest.poll_once(Arc::clone(&guest_service)).await.unwrap();
|
||||||
|
let command = tokio::time::timeout(Duration::from_secs(3), async {
|
||||||
|
loop {
|
||||||
|
if let Some(AppEvent::JamCommand(command)) = host_rx.recv().await {
|
||||||
|
break command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
command,
|
||||||
|
PlaybackCommand::SetState {
|
||||||
|
state: crate::devices::PlaybackStateWire { paused: true, .. },
|
||||||
|
seek: false
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
host_drain.abort();
|
||||||
|
guest_drain.abort();
|
||||||
|
host_service.shutdown().await.unwrap();
|
||||||
|
guest_service.shutdown().await.unwrap();
|
||||||
|
let _ = std::fs::remove_dir_all(host_dir);
|
||||||
|
let _ = std::fs::remove_dir_all(guest_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
+816
-15
@@ -11,6 +11,7 @@ pub mod import;
|
|||||||
pub mod models;
|
pub mod models;
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::io::Write as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@@ -26,6 +27,16 @@ use models::{
|
|||||||
pub const LIKES_PLAYLIST_ID: i64 = -1;
|
pub const LIKES_PLAYLIST_ID: i64 = -1;
|
||||||
const NETWORK_ARTIST_CACHE_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
const NETWORK_ARTIST_CACHE_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListenHistoryEntry {
|
||||||
|
pub listen_id: String,
|
||||||
|
pub content_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub origin_device_id: String,
|
||||||
|
pub started_at_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
const SCHEMA: &str = "
|
const SCHEMA: &str = "
|
||||||
CREATE TABLE IF NOT EXISTS artists (
|
CREATE TABLE IF NOT EXISTS artists (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
@@ -126,10 +137,43 @@ CREATE TABLE IF NOT EXISTS history (
|
|||||||
completed INTEGER NOT NULL DEFAULT 0,
|
completed INTEGER NOT NULL DEFAULT 0,
|
||||||
played_at TEXT NOT NULL DEFAULT (datetime('now'))
|
played_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS listen_events (
|
||||||
|
listen_id TEXT PRIMARY KEY,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
local_track_id INTEGER REFERENCES tracks(id) ON DELETE SET NULL,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
started_at_ms INTEGER NOT NULL,
|
||||||
|
listened_ms INTEGER NOT NULL,
|
||||||
|
track_duration_ms INTEGER,
|
||||||
|
ended_reason TEXT NOT NULL,
|
||||||
|
qualified INTEGER NOT NULL,
|
||||||
|
metadata_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tracks_release ON tracks(release_id);
|
CREATE INDEX IF NOT EXISTS idx_tracks_release ON tracks(release_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id);
|
CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_release_artists_artist ON release_artists(artist_id);
|
CREATE INDEX IF NOT EXISTS idx_release_artists_artist ON release_artists(artist_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_history_track ON history(track_id);
|
CREATE INDEX IF NOT EXISTS idx_history_track ON history(track_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_listen_events_content
|
||||||
|
ON listen_events(content_id, started_at_ms DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_listen_events_local_track
|
||||||
|
ON listen_events(local_track_id, qualified);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS reconcile_listen_events_after_track_insert
|
||||||
|
AFTER INSERT ON tracks
|
||||||
|
WHEN NEW.content_id IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
UPDATE listen_events
|
||||||
|
SET local_track_id = NEW.id
|
||||||
|
WHERE content_id = NEW.content_id;
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS reconcile_listen_events_after_track_content_id
|
||||||
|
AFTER UPDATE OF content_id ON tracks
|
||||||
|
WHEN NEW.content_id IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
UPDATE listen_events
|
||||||
|
SET local_track_id = NEW.id
|
||||||
|
WHERE content_id = NEW.content_id;
|
||||||
|
END;
|
||||||
CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id);
|
CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_fed_playlist_tracks_playlist
|
CREATE INDEX IF NOT EXISTS idx_fed_playlist_tracks_playlist
|
||||||
ON fed_playlist_tracks(playlist_sync_id, position);
|
ON fed_playlist_tracks(playlist_sync_id, position);
|
||||||
@@ -149,6 +193,27 @@ CREATE INDEX IF NOT EXISTS idx_network_artist_cache_kind
|
|||||||
ON network_artist_cache(source_kind, seen_at_ms);
|
ON network_artist_cache(source_kind, seen_at_ms);
|
||||||
CREATE INDEX IF NOT EXISTS idx_network_artist_cache_artist
|
CREATE INDEX IF NOT EXISTS idx_network_artist_cache_artist
|
||||||
ON network_artist_cache(artist_key);
|
ON network_artist_cache(artist_key);
|
||||||
|
CREATE TABLE IF NOT EXISTS similarity_profiles (
|
||||||
|
profile_id TEXT PRIMARY KEY,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
model_version TEXT NOT NULL,
|
||||||
|
model_sha256 TEXT NOT NULL,
|
||||||
|
preprocessing TEXT NOT NULL,
|
||||||
|
dimensions INTEGER NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS track_embeddings (
|
||||||
|
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
profile_id TEXT NOT NULL REFERENCES similarity_profiles(profile_id) ON DELETE CASCADE,
|
||||||
|
dimensions INTEGER NOT NULL,
|
||||||
|
vector BLOB NOT NULL,
|
||||||
|
routing_signature BLOB,
|
||||||
|
source_content_id TEXT,
|
||||||
|
computed_at_ms INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (track_id, profile_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_track_embeddings_profile
|
||||||
|
ON track_embeddings(profile_id, track_id);
|
||||||
";
|
";
|
||||||
|
|
||||||
/// The SELECT column list every TrackItem row is built from; artist lists
|
/// The SELECT column list every TrackItem row is built from; artist lists
|
||||||
@@ -159,7 +224,9 @@ const TRACK_COLUMNS: &str = "
|
|||||||
t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate,
|
t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate,
|
||||||
t.audio_bit_depth, t.file_size_bytes,
|
t.audio_bit_depth, t.file_size_bytes,
|
||||||
t.content_id,
|
t.content_id,
|
||||||
(SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1)
|
((SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1)
|
||||||
|
+ (SELECT COUNT(*) FROM listen_events le
|
||||||
|
WHERE le.local_track_id = t.id AND le.qualified = 1))
|
||||||
";
|
";
|
||||||
|
|
||||||
/// Plain rows handed to the federation for publishing (see
|
/// Plain rows handed to the federation for publishing (see
|
||||||
@@ -225,6 +292,32 @@ pub struct NetworkArtistImageRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Minimal durable-track row used by the background embedding pipeline.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SimilarityTrack {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub content_id: Option<String>,
|
||||||
|
pub duration_seconds: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One validated vector loaded from SQLite for the in-memory exact index.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StoredEmbedding {
|
||||||
|
pub track_id: i64,
|
||||||
|
pub vector: Vec<f32>,
|
||||||
|
pub artist_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct SimilarityStorageStats {
|
||||||
|
pub total_tracks: usize,
|
||||||
|
pub embedded_tracks: usize,
|
||||||
|
pub stored_vectors: usize,
|
||||||
|
pub stored_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
conn: Mutex<Connection>,
|
conn: Mutex<Connection>,
|
||||||
db_path: PathBuf,
|
db_path: PathBuf,
|
||||||
@@ -243,6 +336,12 @@ pub struct LocalLibraryStats {
|
|||||||
pub database_bytes: u64,
|
pub database_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct MusicRelocationStats {
|
||||||
|
pub tracks: usize,
|
||||||
|
pub images: usize,
|
||||||
|
}
|
||||||
|
|
||||||
impl LocalLibraryStats {
|
impl LocalLibraryStats {
|
||||||
pub fn total_bytes(&self) -> u64 {
|
pub fn total_bytes(&self) -> u64 {
|
||||||
self.audio_bytes
|
self.audio_bytes
|
||||||
@@ -285,6 +384,261 @@ impl Library {
|
|||||||
&self.covers_dir
|
&self.covers_dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verifies that `path` can actually be used for durable downloads. The
|
||||||
|
/// returned path is absolute/canonical, so the persisted setting does not
|
||||||
|
/// later depend on Furumi's working directory.
|
||||||
|
pub fn validate_music_directory(path: &Path) -> Result<PathBuf> {
|
||||||
|
anyhow::ensure!(!path.as_os_str().is_empty(), "music directory is empty");
|
||||||
|
std::fs::create_dir_all(path)
|
||||||
|
.with_context(|| format!("creating music directory {}", path.display()))?;
|
||||||
|
let path = std::fs::canonicalize(path)
|
||||||
|
.with_context(|| format!("resolving music directory {}", path.display()))?;
|
||||||
|
anyhow::ensure!(path.is_dir(), "{} is not a directory", path.display());
|
||||||
|
|
||||||
|
let unique = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let probe = path.join(format!(
|
||||||
|
".furumi-write-test-{}-{unique}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let result = (|| -> Result<()> {
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&probe)
|
||||||
|
.with_context(|| format!("no write access to {}", path.display()))?;
|
||||||
|
file.write_all(b"furumi")?;
|
||||||
|
file.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
let _ = std::fs::remove_file(&probe);
|
||||||
|
result?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies Furumi-managed audio and its managed artwork into a portable
|
||||||
|
/// `Artist/Release` tree, atomically switches SQLite paths, then removes
|
||||||
|
/// the old copies. User-imported audio outside `old_root` is untouched.
|
||||||
|
pub fn relocate_managed_music(
|
||||||
|
&self,
|
||||||
|
old_root: &Path,
|
||||||
|
new_root: &Path,
|
||||||
|
) -> Result<MusicRelocationStats> {
|
||||||
|
let new_root = Self::validate_music_directory(new_root)?;
|
||||||
|
let old_root = std::fs::canonicalize(old_root)
|
||||||
|
.with_context(|| format!("resolving old music directory {}", old_root.display()))?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
old_root != new_root,
|
||||||
|
"the new music directory is the current directory"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
!old_root.starts_with(&new_root) && !new_root.starts_with(&old_root),
|
||||||
|
"choose a directory outside the current music directory"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Row {
|
||||||
|
track_id: i64,
|
||||||
|
track_path: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: String,
|
||||||
|
cover_path: Option<String>,
|
||||||
|
artist_id: Option<i64>,
|
||||||
|
artist_name: String,
|
||||||
|
image_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT t.id, t.file_path, r.id, r.title, r.cover_path,
|
||||||
|
a.id, COALESCE(a.name, 'Unknown Artist'), a.image_path
|
||||||
|
FROM tracks t
|
||||||
|
JOIN releases r ON r.id = t.release_id
|
||||||
|
LEFT JOIN track_artists ta
|
||||||
|
ON ta.track_id = t.id AND ta.role = 'main' AND ta.position = 0
|
||||||
|
LEFT JOIN artists a ON a.id = ta.artist_id
|
||||||
|
ORDER BY t.id",
|
||||||
|
)?;
|
||||||
|
statement
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(Row {
|
||||||
|
track_id: row.get(0)?,
|
||||||
|
track_path: row.get(1)?,
|
||||||
|
release_id: row.get(2)?,
|
||||||
|
release_title: row.get(3)?,
|
||||||
|
cover_path: row.get(4)?,
|
||||||
|
artist_id: row.get(5)?,
|
||||||
|
artist_name: row.get(6)?,
|
||||||
|
image_path: row.get(7)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CopyOp {
|
||||||
|
source: PathBuf,
|
||||||
|
destination: PathBuf,
|
||||||
|
}
|
||||||
|
let managed_art =
|
||||||
|
|path: &Path| path.starts_with(&old_root) || path.starts_with(&self.covers_dir);
|
||||||
|
let mut reserved = HashSet::<PathBuf>::new();
|
||||||
|
let mut copies = Vec::<CopyOp>::new();
|
||||||
|
let mut track_updates = Vec::<(i64, String, String)>::new();
|
||||||
|
let mut release_updates = HashMap::<i64, (String, String)>::new();
|
||||||
|
let mut artist_updates = HashMap::<i64, (String, String)>::new();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let source = PathBuf::from(&row.track_path);
|
||||||
|
let source = std::fs::canonicalize(&source).unwrap_or(source);
|
||||||
|
if !source.is_file() || !source.starts_with(&old_root) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let artist_dir = new_root.join(storage_name(&row.artist_name, "Unknown Artist"));
|
||||||
|
let release_dir = artist_dir.join(storage_name(&row.release_title, "Unknown Release"));
|
||||||
|
let filename = source
|
||||||
|
.file_name()
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or_else(|| std::ffi::OsStr::new("track"));
|
||||||
|
let destination =
|
||||||
|
unique_destination(release_dir.join(filename), row.track_id, &mut reserved);
|
||||||
|
copies.push(CopyOp {
|
||||||
|
source: source.clone(),
|
||||||
|
destination: destination.clone(),
|
||||||
|
});
|
||||||
|
track_updates.push((
|
||||||
|
row.track_id,
|
||||||
|
row.track_path,
|
||||||
|
destination.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(cover) = row.cover_path {
|
||||||
|
let cover_source = PathBuf::from(&cover);
|
||||||
|
let cover_source = std::fs::canonicalize(&cover_source).unwrap_or(cover_source);
|
||||||
|
if cover_source.is_file()
|
||||||
|
&& managed_art(&cover_source)
|
||||||
|
&& !release_updates.contains_key(&row.release_id)
|
||||||
|
{
|
||||||
|
let extension = cover_source.extension().unwrap_or_default();
|
||||||
|
let mut destination = release_dir.join("cover");
|
||||||
|
destination.set_extension(extension);
|
||||||
|
let destination =
|
||||||
|
unique_destination(destination, row.release_id, &mut reserved);
|
||||||
|
copies.push(CopyOp {
|
||||||
|
source: cover_source,
|
||||||
|
destination: destination.clone(),
|
||||||
|
});
|
||||||
|
release_updates.insert(
|
||||||
|
row.release_id,
|
||||||
|
(cover, destination.to_string_lossy().into_owned()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(artist_id), Some(image)) = (row.artist_id, row.image_path) {
|
||||||
|
let image_source = PathBuf::from(&image);
|
||||||
|
let image_source = std::fs::canonicalize(&image_source).unwrap_or(image_source);
|
||||||
|
if image_source.is_file()
|
||||||
|
&& managed_art(&image_source)
|
||||||
|
&& !artist_updates.contains_key(&artist_id)
|
||||||
|
{
|
||||||
|
let extension = image_source.extension().unwrap_or_default();
|
||||||
|
let mut destination = artist_dir.join("artist");
|
||||||
|
destination.set_extension(extension);
|
||||||
|
let destination = unique_destination(destination, artist_id, &mut reserved);
|
||||||
|
copies.push(CopyOp {
|
||||||
|
source: image_source,
|
||||||
|
destination: destination.clone(),
|
||||||
|
});
|
||||||
|
artist_updates.insert(
|
||||||
|
artist_id,
|
||||||
|
(image, destination.to_string_lossy().into_owned()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut created = Vec::<PathBuf>::new();
|
||||||
|
let copy_result = (|| -> Result<()> {
|
||||||
|
for op in &copies {
|
||||||
|
let parent = op
|
||||||
|
.destination
|
||||||
|
.parent()
|
||||||
|
.context("music destination has no parent")?;
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
copy_file_exclusive(&op.source, &op.destination)?;
|
||||||
|
created.push(op.destination.clone());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if let Err(error) = copy_result {
|
||||||
|
for path in created.iter().rev() {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
return Err(error.context("copying the existing music library"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let database_result = (|| -> Result<()> {
|
||||||
|
let mut conn = self.lock();
|
||||||
|
let transaction = conn.transaction()?;
|
||||||
|
for (id, old, new) in &track_updates {
|
||||||
|
anyhow::ensure!(
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE tracks SET file_path = ?3 WHERE id = ?1 AND file_path = ?2",
|
||||||
|
params![id, old, new],
|
||||||
|
)? == 1,
|
||||||
|
"track {id} changed while the music library was moving"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (id, (old, new)) in &release_updates {
|
||||||
|
anyhow::ensure!(
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE releases SET cover_path = ?3 WHERE id = ?1 AND cover_path = ?2",
|
||||||
|
params![id, old, new],
|
||||||
|
)? == 1,
|
||||||
|
"release {id} changed while the music library was moving"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (id, (old, new)) in &artist_updates {
|
||||||
|
anyhow::ensure!(
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE artists SET image_path = ?3 WHERE id = ?1 AND image_path = ?2",
|
||||||
|
params![id, old, new],
|
||||||
|
)? == 1,
|
||||||
|
"artist {id} changed while the music library was moving"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
transaction.commit()?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if let Err(error) = database_result {
|
||||||
|
for path in created.iter().rev() {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
return Err(error.context("updating music paths in the library"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut removed = HashSet::new();
|
||||||
|
for op in &copies {
|
||||||
|
if removed.insert(op.source.clone())
|
||||||
|
&& let Err(error) = std::fs::remove_file(&op.source)
|
||||||
|
{
|
||||||
|
// The committed destination is authoritative. A failed old
|
||||||
|
// delete only leaves a recoverable duplicate.
|
||||||
|
tracing::warn!(path = %op.source.display(), %error, "old music copy was not removed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove_empty_directories(&old_root);
|
||||||
|
|
||||||
|
Ok(MusicRelocationStats {
|
||||||
|
tracks: track_updates.len(),
|
||||||
|
images: release_updates.len() + artist_updates.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn local_stats(&self) -> Result<LocalLibraryStats> {
|
pub fn local_stats(&self) -> Result<LocalLibraryStats> {
|
||||||
let (artist_count, release_count, track_count, audio_bytes, tracks_without_size) = {
|
let (artist_count, release_count, track_count, audio_bytes, tracks_without_size) = {
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
@@ -798,9 +1152,14 @@ impl Library {
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
let total_play_count: i64 = conn.query_row(
|
let total_play_count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM history h
|
"SELECT
|
||||||
WHERE h.completed = 1 AND h.track_id IN
|
(SELECT COUNT(*) FROM history h
|
||||||
(SELECT track_id FROM track_artists WHERE artist_id = ?1)",
|
WHERE h.completed = 1 AND h.track_id IN
|
||||||
|
(SELECT track_id FROM track_artists WHERE artist_id = ?1))
|
||||||
|
+
|
||||||
|
(SELECT COUNT(*) FROM listen_events le
|
||||||
|
WHERE le.qualified = 1 AND le.local_track_id IN
|
||||||
|
(SELECT track_id FROM track_artists WHERE artist_id = ?1))",
|
||||||
[id],
|
[id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
@@ -1121,6 +1480,254 @@ impl Library {
|
|||||||
Ok(tracks.pop())
|
Ok(tracks.pop())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Similarity embeddings. SQLite is the canonical store; callers build
|
||||||
|
// replaceable in-memory indexes from these rows.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn ensure_similarity_profile(
|
||||||
|
&self,
|
||||||
|
profile_id: &str,
|
||||||
|
model_id: &str,
|
||||||
|
model_version: &str,
|
||||||
|
model_sha256: &str,
|
||||||
|
preprocessing: &str,
|
||||||
|
dimensions: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
let conn = self.lock();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO similarity_profiles (
|
||||||
|
profile_id, model_id, model_version, model_sha256,
|
||||||
|
preprocessing, dimensions, created_at_ms
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
ON CONFLICT(profile_id) DO NOTHING",
|
||||||
|
params![
|
||||||
|
profile_id,
|
||||||
|
model_id,
|
||||||
|
model_version,
|
||||||
|
model_sha256,
|
||||||
|
preprocessing,
|
||||||
|
dimensions as i64,
|
||||||
|
now_ms_i64(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_similarity_tracks(&self, profile_id: &str) -> Result<Vec<SimilarityTrack>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT t.id, t.title, t.file_path, t.content_id, t.duration_seconds
|
||||||
|
FROM tracks t
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM track_embeddings e
|
||||||
|
WHERE e.track_id = t.id
|
||||||
|
AND e.profile_id = ?1
|
||||||
|
AND e.source_content_id IS t.content_id
|
||||||
|
)
|
||||||
|
ORDER BY t.id",
|
||||||
|
)?;
|
||||||
|
Ok(statement
|
||||||
|
.query_map([profile_id], |row| {
|
||||||
|
Ok(SimilarityTrack {
|
||||||
|
id: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
file_path: row.get(2)?,
|
||||||
|
content_id: row.get(3)?,
|
||||||
|
duration_seconds: row.get(4)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_similarity_embedding(
|
||||||
|
&self,
|
||||||
|
track: &SimilarityTrack,
|
||||||
|
profile_id: &str,
|
||||||
|
vector: &[f32],
|
||||||
|
) -> Result<()> {
|
||||||
|
anyhow::ensure!(!vector.is_empty(), "embedding vector is empty");
|
||||||
|
anyhow::ensure!(
|
||||||
|
vector.iter().all(|value| value.is_finite()),
|
||||||
|
"embedding contains a non-finite value"
|
||||||
|
);
|
||||||
|
let bytes = embedding_to_bytes(vector);
|
||||||
|
let routing_signature = music_dht::similarity_lsh::routing_signature(vector)?;
|
||||||
|
let conn = self.lock();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO track_embeddings (
|
||||||
|
track_id, profile_id, dimensions, vector, routing_signature,
|
||||||
|
source_content_id, computed_at_ms
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
ON CONFLICT(track_id, profile_id) DO UPDATE SET
|
||||||
|
dimensions = excluded.dimensions,
|
||||||
|
vector = excluded.vector,
|
||||||
|
routing_signature = excluded.routing_signature,
|
||||||
|
source_content_id = excluded.source_content_id,
|
||||||
|
computed_at_ms = excluded.computed_at_ms",
|
||||||
|
params![
|
||||||
|
track.id,
|
||||||
|
profile_id,
|
||||||
|
vector.len() as i64,
|
||||||
|
bytes,
|
||||||
|
routing_signature.as_slice(),
|
||||||
|
track.content_id.as_deref(),
|
||||||
|
now_ms_i64(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_embedding(
|
||||||
|
&self,
|
||||||
|
track_id: i64,
|
||||||
|
profile_id: &str,
|
||||||
|
) -> Result<Option<Vec<f32>>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let row = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT e.dimensions, e.vector
|
||||||
|
FROM track_embeddings e
|
||||||
|
JOIN tracks t ON t.id = e.track_id
|
||||||
|
WHERE e.track_id = ?1
|
||||||
|
AND e.profile_id = ?2
|
||||||
|
AND e.source_content_id IS t.content_id",
|
||||||
|
params![track_id, profile_id],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
row.map(|(dimensions, bytes)| embedding_from_bytes(dimensions, &bytes))
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_similarity_index(&self, profile_id: &str) -> Result<Vec<StoredEmbedding>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT e.track_id, e.dimensions, e.vector,
|
||||||
|
COALESCE((
|
||||||
|
SELECT norm(a.name)
|
||||||
|
FROM track_artists ta
|
||||||
|
JOIN artists a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = e.track_id AND ta.role = 'main'
|
||||||
|
ORDER BY ta.position LIMIT 1
|
||||||
|
), '')
|
||||||
|
FROM track_embeddings e
|
||||||
|
JOIN tracks t ON t.id = e.track_id
|
||||||
|
WHERE e.profile_id = ?1
|
||||||
|
AND e.source_content_id IS t.content_id
|
||||||
|
ORDER BY e.track_id",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map([profile_id], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, i64>(1)?,
|
||||||
|
row.get::<_, Vec<u8>>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut embeddings = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let (track_id, dimensions, bytes, artist_key) = row?;
|
||||||
|
embeddings.push(StoredEmbedding {
|
||||||
|
track_id,
|
||||||
|
vector: embedding_from_bytes(dimensions, &bytes)?,
|
||||||
|
artist_key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(embeddings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the compact DHT-routing signatures for every current local
|
||||||
|
/// embedding. Rows created before similarity routing existed are
|
||||||
|
/// backfilled in place from their durable vectors.
|
||||||
|
pub fn similarity_routing_signatures(&self, profile_id: &str) -> Result<Vec<[u8; 32]>> {
|
||||||
|
let mut conn = self.lock();
|
||||||
|
let transaction = conn.transaction()?;
|
||||||
|
let missing = {
|
||||||
|
let mut statement = transaction.prepare(
|
||||||
|
"SELECT e.track_id, e.dimensions, e.vector
|
||||||
|
FROM track_embeddings e
|
||||||
|
JOIN tracks t ON t.id = e.track_id
|
||||||
|
WHERE e.profile_id = ?1
|
||||||
|
AND e.source_content_id IS t.content_id
|
||||||
|
AND (e.routing_signature IS NULL OR length(e.routing_signature) != 32)
|
||||||
|
ORDER BY e.track_id",
|
||||||
|
)?;
|
||||||
|
statement
|
||||||
|
.query_map([profile_id], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, i64>(1)?,
|
||||||
|
row.get::<_, Vec<u8>>(2)?,
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||||
|
};
|
||||||
|
for (track_id, dimensions, vector_bytes) in missing {
|
||||||
|
let vector = embedding_from_bytes(dimensions, &vector_bytes)?;
|
||||||
|
let signature = music_dht::similarity_lsh::routing_signature(&vector)?;
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE track_embeddings
|
||||||
|
SET routing_signature = ?3
|
||||||
|
WHERE track_id = ?1 AND profile_id = ?2",
|
||||||
|
params![track_id, profile_id, signature.as_slice()],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
transaction.commit()?;
|
||||||
|
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT e.routing_signature
|
||||||
|
FROM track_embeddings e
|
||||||
|
JOIN tracks t ON t.id = e.track_id
|
||||||
|
WHERE e.profile_id = ?1
|
||||||
|
AND e.source_content_id IS t.content_id
|
||||||
|
ORDER BY e.track_id",
|
||||||
|
)?;
|
||||||
|
let stored = statement
|
||||||
|
.query_map([profile_id], |row| row.get::<_, Vec<u8>>(0))?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
let signatures = stored
|
||||||
|
.into_iter()
|
||||||
|
.map(|signature| {
|
||||||
|
<[u8; 32]>::try_from(signature)
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid similarity routing signature length"))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
Ok(signatures)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_storage_stats(&self, profile_id: &str) -> Result<SimilarityStorageStats> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let total_tracks = conn.query_row("SELECT COUNT(*) FROM tracks", [], |row| {
|
||||||
|
row.get::<_, i64>(0)
|
||||||
|
})?;
|
||||||
|
let embedded_tracks = conn.query_row(
|
||||||
|
"SELECT COUNT(*)
|
||||||
|
FROM track_embeddings e JOIN tracks t ON t.id = e.track_id
|
||||||
|
WHERE e.profile_id = ?1 AND e.source_content_id IS t.content_id",
|
||||||
|
[profile_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?;
|
||||||
|
let (stored_vectors, stored_bytes) = conn.query_row(
|
||||||
|
"SELECT COUNT(*), COALESCE(SUM(length(vector)), 0) FROM track_embeddings",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||||
|
)?;
|
||||||
|
Ok(SimilarityStorageStats {
|
||||||
|
total_tracks: total_tracks.max(0) as usize,
|
||||||
|
embedded_tracks: embedded_tracks.max(0) as usize,
|
||||||
|
stored_vectors: stored_vectors.max(0) as usize,
|
||||||
|
stored_bytes: stored_bytes.max(0) as u64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_similarity_embeddings(&self) -> Result<()> {
|
||||||
|
let conn = self.lock();
|
||||||
|
conn.execute("DELETE FROM track_embeddings", [])?;
|
||||||
|
conn.execute("DELETE FROM similarity_profiles", [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Playlists & likes
|
// Playlists & likes
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
@@ -2196,20 +2803,88 @@ impl Library {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_history(
|
/// Idempotently materialize a portable trusted-device listen event.
|
||||||
|
pub fn apply_listen_event(
|
||||||
&self,
|
&self,
|
||||||
track_id: i64,
|
event: &music_dht::device_sync::ListenEvent,
|
||||||
started_at: Option<i64>,
|
origin_device_id: &str,
|
||||||
listened_seconds: i32,
|
) -> Result<bool> {
|
||||||
completed: bool,
|
if !event.should_record() || origin_device_id.trim().is_empty() {
|
||||||
) -> Result<()> {
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let content_id = music_dht::normalize_content_id(&event.content_id)
|
||||||
|
.context("invalid listen content id")?;
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
conn.execute(
|
let local_track_id: Option<i64> = conn
|
||||||
"INSERT INTO history (track_id, started_at, listened_seconds, completed)
|
.query_row(
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
"SELECT id FROM tracks WHERE content_id = ?1 LIMIT 1",
|
||||||
params![track_id, started_at, listened_seconds, completed],
|
[&content_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let inserted = conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO listen_events
|
||||||
|
(listen_id, content_id, local_track_id, origin_device_id,
|
||||||
|
started_at_ms, listened_ms, track_duration_ms, ended_reason,
|
||||||
|
qualified, metadata_json)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
params![
|
||||||
|
event.listen_id,
|
||||||
|
content_id,
|
||||||
|
local_track_id,
|
||||||
|
origin_device_id,
|
||||||
|
event.started_at_ms,
|
||||||
|
event.listened_ms,
|
||||||
|
event.track_duration_ms,
|
||||||
|
serde_json::to_string(&event.ended_reason)?,
|
||||||
|
i64::from(event.qualifies_as_play()),
|
||||||
|
serde_json::to_string(&event.track)?,
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(inserted > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most recent qualified listens, including tracks that are not present
|
||||||
|
/// in this device's local library.
|
||||||
|
pub fn listen_history(&self, limit: usize) -> Result<Vec<ListenHistoryEntry>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT listen_id, content_id, origin_device_id, started_at_ms, metadata_json
|
||||||
|
FROM listen_events
|
||||||
|
WHERE qualified = 1
|
||||||
|
ORDER BY started_at_ms DESC, listen_id DESC
|
||||||
|
LIMIT ?1",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([limit.min(i64::MAX as usize) as i64], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, i64>(3)?,
|
||||||
|
row.get::<_, String>(4)?,
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
rows.into_iter()
|
||||||
|
.map(
|
||||||
|
|(listen_id, content_id, origin_device_id, started_at_ms, metadata_json)| {
|
||||||
|
let metadata: music_dht::device_sync::ListenTrackMetadata =
|
||||||
|
serde_json::from_str(&metadata_json)
|
||||||
|
.context("invalid listen history metadata")?;
|
||||||
|
let mut artists = metadata.artist_names;
|
||||||
|
artists.extend(metadata.featured_artist_names);
|
||||||
|
Ok(ListenHistoryEntry {
|
||||||
|
listen_id,
|
||||||
|
content_id,
|
||||||
|
title: metadata.title,
|
||||||
|
artist: artists.join(", "),
|
||||||
|
origin_device_id,
|
||||||
|
started_at_ms,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
@@ -2380,6 +3055,96 @@ impl Library {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn storage_name(value: &str, fallback: &str) -> String {
|
||||||
|
let cleaned: String = value
|
||||||
|
.chars()
|
||||||
|
.map(|character| match character {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
character if character.is_control() => '_',
|
||||||
|
character => character,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let cleaned = cleaned.trim().trim_matches('.');
|
||||||
|
let mut shortened: String = cleaned.chars().take(120).collect();
|
||||||
|
if shortened.is_empty() {
|
||||||
|
return fallback.to_string();
|
||||||
|
}
|
||||||
|
let windows_base = shortened
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_uppercase();
|
||||||
|
let windows_reserved = matches!(windows_base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||||
|
|| windows_base
|
||||||
|
.strip_prefix("COM")
|
||||||
|
.or_else(|| windows_base.strip_prefix("LPT"))
|
||||||
|
.is_some_and(|number| number.len() == 1 && matches!(number.as_bytes()[0], b'1'..=b'9'));
|
||||||
|
if windows_reserved {
|
||||||
|
shortened.insert(0, '_');
|
||||||
|
}
|
||||||
|
shortened
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_destination(requested: PathBuf, id: i64, reserved: &mut HashSet<PathBuf>) -> PathBuf {
|
||||||
|
if !requested.exists() && reserved.insert(requested.clone()) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
let stem = requested
|
||||||
|
.file_stem()
|
||||||
|
.unwrap_or_else(|| std::ffi::OsStr::new("file"))
|
||||||
|
.to_string_lossy();
|
||||||
|
let extension = requested.extension().map(|value| value.to_os_string());
|
||||||
|
let mut candidate = requested.with_file_name(format!("{stem}-{id}"));
|
||||||
|
if let Some(extension) = extension {
|
||||||
|
candidate.set_extension(extension);
|
||||||
|
}
|
||||||
|
let mut suffix = 2usize;
|
||||||
|
while candidate.exists() || !reserved.insert(candidate.clone()) {
|
||||||
|
candidate = requested.with_file_name(format!("{stem}-{id}-{suffix}"));
|
||||||
|
if let Some(extension) = requested.extension() {
|
||||||
|
candidate.set_extension(extension);
|
||||||
|
}
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_file_exclusive(source: &Path, destination: &Path) -> Result<()> {
|
||||||
|
let mut input =
|
||||||
|
std::fs::File::open(source).with_context(|| format!("opening {}", source.display()))?;
|
||||||
|
let mut output = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(destination)
|
||||||
|
.with_context(|| format!("creating {}", destination.display()))?;
|
||||||
|
let result = std::io::copy(&mut input, &mut output)
|
||||||
|
.with_context(|| format!("copying {}", source.display()))
|
||||||
|
.and_then(|_| {
|
||||||
|
output
|
||||||
|
.sync_all()
|
||||||
|
.with_context(|| format!("syncing {}", destination.display()))
|
||||||
|
});
|
||||||
|
if let Err(error) = result {
|
||||||
|
drop(output);
|
||||||
|
let _ = std::fs::remove_file(destination);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_empty_directories(root: &Path) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
remove_empty_directories(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir(root);
|
||||||
|
}
|
||||||
|
|
||||||
fn cleanup_empty_releases(tx: &rusqlite::Transaction) -> rusqlite::Result<()> {
|
fn cleanup_empty_releases(tx: &rusqlite::Transaction) -> rusqlite::Result<()> {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM releases WHERE id NOT IN (SELECT release_id FROM tracks)",
|
"DELETE FROM releases WHERE id NOT IN (SELECT release_id FROM tracks)",
|
||||||
@@ -2661,6 +3426,32 @@ fn now_ms_i64() -> i64 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn embedding_to_bytes(vector: &[f32]) -> Vec<u8> {
|
||||||
|
let mut bytes = Vec::with_capacity(std::mem::size_of_val(vector));
|
||||||
|
for value in vector {
|
||||||
|
bytes.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embedding_from_bytes(dimensions: i64, bytes: &[u8]) -> Result<Vec<f32>> {
|
||||||
|
anyhow::ensure!(dimensions > 0, "stored embedding has invalid dimensions");
|
||||||
|
let dimensions = dimensions as usize;
|
||||||
|
anyhow::ensure!(
|
||||||
|
bytes.len() == dimensions * std::mem::size_of::<f32>(),
|
||||||
|
"stored embedding byte length does not match its dimensions"
|
||||||
|
);
|
||||||
|
let vector: Vec<f32> = bytes
|
||||||
|
.chunks_exact(4)
|
||||||
|
.map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
|
||||||
|
.collect();
|
||||||
|
anyhow::ensure!(
|
||||||
|
vector.iter().all(|value| value.is_finite()),
|
||||||
|
"stored embedding contains a non-finite value"
|
||||||
|
);
|
||||||
|
Ok(vector)
|
||||||
|
}
|
||||||
|
|
||||||
fn remote_artist_id(artist_key: &str) -> i64 {
|
fn remote_artist_id(artist_key: &str) -> i64 {
|
||||||
let hash = blake3::hash(artist_key.as_bytes());
|
let hash = blake3::hash(artist_key.as_bytes());
|
||||||
let mut bytes = [0u8; 8];
|
let mut bytes = [0u8; 8];
|
||||||
@@ -2716,6 +3507,16 @@ fn ensure_schema_migrations(conn: &Connection) -> Result<()> {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
let embedding_columns = table_columns(conn, "track_embeddings")?;
|
||||||
|
if !embedding_columns
|
||||||
|
.iter()
|
||||||
|
.any(|column| column == "routing_signature")
|
||||||
|
{
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE track_embeddings ADD COLUMN routing_signature BLOB",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
let mut rows = conn.prepare("SELECT id, title FROM playlists WHERE sync_id IS NULL")?;
|
let mut rows = conn.prepare("SELECT id, title FROM playlists WHERE sync_id IS NULL")?;
|
||||||
let missing = rows
|
let missing = rows
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
|
|||||||
+279
-2
@@ -60,6 +60,138 @@ fn artist_filters(hide_featured_only: bool) -> crate::config::settings::LibraryF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unique_test_dir(label: &str) -> std::path::PathBuf {
|
||||||
|
let unique = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("furumi-{label}-{}-{unique}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_music_relocation_builds_artist_release_tree_and_updates_paths() {
|
||||||
|
let root = unique_test_dir("music-relocation");
|
||||||
|
let old = root.join("old");
|
||||||
|
let new = root.join("new");
|
||||||
|
let covers = root.join("covers");
|
||||||
|
std::fs::create_dir_all(&old).unwrap();
|
||||||
|
std::fs::create_dir_all(&covers).unwrap();
|
||||||
|
let audio = old.join("legacy.flac");
|
||||||
|
let cover = covers.join("release.jpg");
|
||||||
|
let artist_image = covers.join("artist.png");
|
||||||
|
std::fs::write(&audio, b"audio").unwrap();
|
||||||
|
std::fs::write(&cover, b"cover").unwrap();
|
||||||
|
std::fs::write(&artist_image, b"artist").unwrap();
|
||||||
|
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||||
|
register_norm_function(&conn).unwrap();
|
||||||
|
conn.execute_batch(SCHEMA).unwrap();
|
||||||
|
let library = Library {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
db_path: root.join("library.db"),
|
||||||
|
covers_dir: covers,
|
||||||
|
};
|
||||||
|
let track_id = import::upsert_track(
|
||||||
|
&library,
|
||||||
|
&import::TrackImport {
|
||||||
|
file_path: audio.to_string_lossy().into_owned(),
|
||||||
|
title: "Song".into(),
|
||||||
|
artists: vec!["Artist".into()],
|
||||||
|
featured_artists: vec![],
|
||||||
|
album_artists: vec!["Artist".into()],
|
||||||
|
release_title: "Release".into(),
|
||||||
|
release_type: Some("album".into()),
|
||||||
|
year: Some(2026),
|
||||||
|
track_number: Some(1),
|
||||||
|
disc_number: Some(1),
|
||||||
|
duration_seconds: 1.0,
|
||||||
|
audio_format: Some("flac".into()),
|
||||||
|
audio_bitrate: None,
|
||||||
|
audio_sample_rate: None,
|
||||||
|
audio_bit_depth: None,
|
||||||
|
file_size_bytes: Some(5),
|
||||||
|
cover: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
let (release_id, artist_id): (i64, i64) = library
|
||||||
|
.lock()
|
||||||
|
.query_row(
|
||||||
|
"SELECT t.release_id, ta.artist_id
|
||||||
|
FROM tracks t JOIN track_artists ta ON ta.track_id = t.id
|
||||||
|
WHERE t.id = ?1 AND ta.role = 'main'",
|
||||||
|
[track_id],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
library
|
||||||
|
.lock()
|
||||||
|
.execute(
|
||||||
|
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
|
||||||
|
params![release_id, cover.to_string_lossy()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
library
|
||||||
|
.lock()
|
||||||
|
.execute(
|
||||||
|
"UPDATE artists SET image_path = ?2 WHERE id = ?1",
|
||||||
|
params![artist_id, artist_image.to_string_lossy()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let stats = library.relocate_managed_music(&old, &new).unwrap();
|
||||||
|
assert_eq!(stats.tracks, 1);
|
||||||
|
assert_eq!(stats.images, 2);
|
||||||
|
let track = library.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
||||||
|
assert_eq!(
|
||||||
|
track.file_path,
|
||||||
|
new.join("Artist/Release/legacy.flac").to_string_lossy()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
track.cover_path.as_deref(),
|
||||||
|
Some(
|
||||||
|
new.join("Artist/Release/cover.jpg")
|
||||||
|
.to_string_lossy()
|
||||||
|
.as_ref()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let image: String = library
|
||||||
|
.lock()
|
||||||
|
.query_row(
|
||||||
|
"SELECT image_path FROM artists WHERE id = ?1",
|
||||||
|
[artist_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(image, new.join("Artist/artist.png").to_string_lossy());
|
||||||
|
assert!(!audio.exists());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn music_directory_validation_rejects_a_file_without_touching_it() {
|
||||||
|
let root = unique_test_dir("music-validation");
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
let file = root.join("not-a-directory");
|
||||||
|
std::fs::write(&file, b"keep").unwrap();
|
||||||
|
|
||||||
|
assert!(Library::validate_music_directory(&file).is_err());
|
||||||
|
assert_eq!(std::fs::read(&file).unwrap(), b"keep");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_music_names_are_portable_to_windows() {
|
||||||
|
assert_eq!(storage_name("Artist/Name", "fallback"), "Artist_Name");
|
||||||
|
assert_eq!(storage_name("CON", "fallback"), "_CON");
|
||||||
|
assert_eq!(storage_name("lpt9.live", "fallback"), "_lpt9.live");
|
||||||
|
assert_eq!(storage_name("...", "fallback"), "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_stats_counts_library_rows_and_audio_bytes() {
|
fn local_stats_counts_library_rows_and_audio_bytes() {
|
||||||
let lib = test_library();
|
let lib = test_library();
|
||||||
@@ -502,8 +634,153 @@ fn delete_track_drops_empty_release() {
|
|||||||
fn history_counts_completed_plays() {
|
fn history_counts_completed_plays() {
|
||||||
let lib = test_library();
|
let lib = test_library();
|
||||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||||
lib.add_history(track_id, None, 60, true).unwrap();
|
let content_id = lib
|
||||||
lib.add_history(track_id, None, 10, false).unwrap();
|
.tracks_by_ids(&[track_id])
|
||||||
|
.unwrap()
|
||||||
|
.remove(0)
|
||||||
|
.content_id
|
||||||
|
.unwrap();
|
||||||
|
let event = music_dht::device_sync::ListenEvent {
|
||||||
|
listen_id: "listen-1".to_string(),
|
||||||
|
content_id,
|
||||||
|
started_at_ms: 1_700_000_000_000,
|
||||||
|
listened_ms: 60_000,
|
||||||
|
track_duration_ms: Some(60_000),
|
||||||
|
ended_reason: music_dht::device_sync::ListenEndReason::Finished,
|
||||||
|
track: music_dht::device_sync::ListenTrackMetadata {
|
||||||
|
title: "Song".to_string(),
|
||||||
|
artist_names: vec!["Artist".to_string()],
|
||||||
|
featured_artist_names: Vec::new(),
|
||||||
|
release_title: Some("Album".to_string()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(lib.apply_listen_event(&event, "device-a").unwrap());
|
||||||
|
assert!(!lib.apply_listen_event(&event, "device-a").unwrap());
|
||||||
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
||||||
assert_eq!(track.play_count, 1);
|
assert_eq!(track.play_count, 1);
|
||||||
|
let history = lib.listen_history(20).unwrap();
|
||||||
|
assert_eq!(history.len(), 1);
|
||||||
|
assert_eq!(history[0].listen_id, "listen-1");
|
||||||
|
assert_eq!(history[0].title, "Song");
|
||||||
|
assert_eq!(history[0].artist, "Artist");
|
||||||
|
assert_eq!(history[0].origin_device_id, "device-a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listen_history_hides_unqualified_events_and_keeps_remote_metadata() {
|
||||||
|
let lib = test_library();
|
||||||
|
let event = music_dht::device_sync::ListenEvent {
|
||||||
|
listen_id: "remote-listen".to_string(),
|
||||||
|
content_id: format!("b3:{}", "a".repeat(64)),
|
||||||
|
started_at_ms: 1_700_000_000_000,
|
||||||
|
listened_ms: 10_000,
|
||||||
|
track_duration_ms: Some(120_000),
|
||||||
|
ended_reason: music_dht::device_sync::ListenEndReason::Skipped,
|
||||||
|
track: music_dht::device_sync::ListenTrackMetadata {
|
||||||
|
title: "Remote song".to_string(),
|
||||||
|
artist_names: vec!["Remote artist".to_string()],
|
||||||
|
featured_artist_names: vec!["Guest".to_string()],
|
||||||
|
release_title: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(lib.apply_listen_event(&event, "remote-device").unwrap());
|
||||||
|
assert!(lib.listen_history(20).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similarity_embeddings_round_trip_and_keep_profiles_separate() {
|
||||||
|
let lib = test_library();
|
||||||
|
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||||
|
for profile in ["profile-a", "profile-b"] {
|
||||||
|
lib.ensure_similarity_profile(profile, "model", "1", "sha", "prep", 3)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let track = lib
|
||||||
|
.pending_similarity_tracks("profile-a")
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|track| track.id == track_id)
|
||||||
|
.unwrap();
|
||||||
|
let first = [0.26726124, 0.5345225, 0.8017837];
|
||||||
|
let second = [0.8017837, 0.5345225, 0.26726124];
|
||||||
|
lib.store_similarity_embedding(&track, "profile-a", &first)
|
||||||
|
.unwrap();
|
||||||
|
lib.store_similarity_embedding(&track, "profile-b", &second)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
lib.similarity_embedding(track_id, "profile-a").unwrap(),
|
||||||
|
Some(first.to_vec())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lib.similarity_embedding(track_id, "profile-b").unwrap(),
|
||||||
|
Some(second.to_vec())
|
||||||
|
);
|
||||||
|
let stats = lib.similarity_storage_stats("profile-a").unwrap();
|
||||||
|
assert_eq!(stats.total_tracks, 1);
|
||||||
|
assert_eq!(stats.embedded_tracks, 1);
|
||||||
|
assert_eq!(stats.stored_vectors, 2);
|
||||||
|
assert_eq!(stats.stored_bytes, 24);
|
||||||
|
lib.lock()
|
||||||
|
.execute(
|
||||||
|
"UPDATE track_embeddings SET routing_signature = NULL WHERE profile_id = 'profile-a'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
lib.similarity_routing_signatures("profile-a")
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let stored_signature_bytes: i64 = lib
|
||||||
|
.lock()
|
||||||
|
.query_row(
|
||||||
|
"SELECT length(routing_signature) FROM track_embeddings WHERE profile_id = 'profile-a'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored_signature_bytes, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_content_id_invalidates_only_the_stale_embedding() {
|
||||||
|
let lib = test_library();
|
||||||
|
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||||
|
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
|
||||||
|
.unwrap();
|
||||||
|
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
|
||||||
|
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
|
||||||
|
.unwrap();
|
||||||
|
lib.lock()
|
||||||
|
.execute(
|
||||||
|
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
|
||||||
|
params![track_id, format!("b3:{}", "f".repeat(64))],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(lib.pending_similarity_tracks("profile").unwrap().len(), 1);
|
||||||
|
assert_eq!(lib.similarity_embedding(track_id, "profile").unwrap(), None);
|
||||||
|
assert!(lib.load_similarity_index("profile").unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_embeddings_preserves_the_library() {
|
||||||
|
let lib = test_library();
|
||||||
|
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||||
|
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
|
||||||
|
.unwrap();
|
||||||
|
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
|
||||||
|
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
lib.clear_similarity_embeddings().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(lib.tracks_by_ids(&[track_id]).unwrap().len(), 1);
|
||||||
|
assert!(
|
||||||
|
lib.similarity_embedding(track_id, "profile")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-1
@@ -3,10 +3,13 @@ mod art;
|
|||||||
mod config;
|
mod config;
|
||||||
mod devices;
|
mod devices;
|
||||||
mod federation;
|
mod federation;
|
||||||
|
mod jam;
|
||||||
mod library;
|
mod library;
|
||||||
mod media;
|
mod media;
|
||||||
mod player;
|
mod player;
|
||||||
mod share;
|
mod share;
|
||||||
|
mod similarity;
|
||||||
|
mod status;
|
||||||
mod streaming;
|
mod streaming;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod visualizer;
|
mod visualizer;
|
||||||
@@ -19,11 +22,39 @@ use crossterm::event::{
|
|||||||
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HELP: &str = "\
|
||||||
|
Furumi — federated terminal music player
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
furumi [OPTION]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help Show this help
|
||||||
|
-V, --version Show version
|
||||||
|
--status Print a one-line now-playing status
|
||||||
|
--status-json
|
||||||
|
Print now-playing status as JSON
|
||||||
|
|
||||||
|
tmux:
|
||||||
|
set -g status-right '#(furumi --status)'
|
||||||
|
";
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
if std::env::args_os().any(|arg| arg == "--version" || arg == "-V") {
|
let args: Vec<_> = std::env::args_os().collect();
|
||||||
|
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
|
print!("{HELP}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if args.iter().any(|arg| arg == "--version" || arg == "-V") {
|
||||||
println!("furumi {}", env!("CARGO_PKG_VERSION"));
|
println!("furumi {}", env!("CARGO_PKG_VERSION"));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if args
|
||||||
|
.iter()
|
||||||
|
.any(|arg| arg == "--status" || arg == "--status-json")
|
||||||
|
{
|
||||||
|
return status::print(args.iter().any(|arg| arg == "--status-json"));
|
||||||
|
}
|
||||||
|
|
||||||
let mut startup_warning = None;
|
let mut startup_warning = None;
|
||||||
if let Err(err) = config::logging::init() {
|
if let Err(err) = config::logging::init() {
|
||||||
|
|||||||
+52
-1
@@ -99,9 +99,10 @@ fn create_controls() -> Option<MediaControls> {
|
|||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let hwnd = None;
|
let hwnd = None;
|
||||||
|
|
||||||
|
let dbus_name = platform_dbus_name();
|
||||||
let config = PlatformConfig {
|
let config = PlatformConfig {
|
||||||
display_name: "Furumi",
|
display_name: "Furumi",
|
||||||
dbus_name: "cy.hexor.furumi",
|
dbus_name: &dbus_name,
|
||||||
hwnd,
|
hwnd,
|
||||||
};
|
};
|
||||||
match MediaControls::new(config) {
|
match MediaControls::new(config) {
|
||||||
@@ -113,6 +114,41 @@ fn create_controls() -> Option<MediaControls> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MPRIS well-known names must be unique on the session bus. Developers often
|
||||||
|
/// run a checkout next to an installed Furumi, and souvlaki 0.8.3 reports a
|
||||||
|
/// duplicate name by panicking in its private service thread. Ratatui's global
|
||||||
|
/// panic hook then restores the terminal even though the app thread is still
|
||||||
|
/// alive, leaving a frozen UI in canonical/echo mode.
|
||||||
|
///
|
||||||
|
/// Give every Unix MPRIS instance its own valid bus-name component. Other
|
||||||
|
/// backends ignore `dbus_name`, so retain the stable application identifier
|
||||||
|
/// there.
|
||||||
|
fn platform_dbus_name() -> String {
|
||||||
|
#[cfg(all(
|
||||||
|
unix,
|
||||||
|
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||||
|
))]
|
||||||
|
{
|
||||||
|
mpris_dbus_name(std::process::id())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(all(
|
||||||
|
unix,
|
||||||
|
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||||
|
)))]
|
||||||
|
{
|
||||||
|
"cy.hexor.furumi".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(
|
||||||
|
unix,
|
||||||
|
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||||
|
))]
|
||||||
|
fn mpris_dbus_name(process_id: u32) -> String {
|
||||||
|
format!("cy.hexor.furumi.instance{process_id}")
|
||||||
|
}
|
||||||
|
|
||||||
/// An invisible top-level window owning the SMTC session. Created on the
|
/// An invisible top-level window owning the SMTC session. Created on the
|
||||||
/// main thread, which also pumps its messages in `pump_platform_events`.
|
/// main thread, which also pumps its messages in `pump_platform_events`.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
@@ -217,3 +253,18 @@ fn pump_platform_events() {
|
|||||||
|
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
fn pump_platform_events() {}
|
fn pump_platform_events() {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[cfg(all(
|
||||||
|
unix,
|
||||||
|
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||||
|
))]
|
||||||
|
#[test]
|
||||||
|
fn mpris_names_are_distinct_between_processes() {
|
||||||
|
let first = super::mpris_dbus_name(41);
|
||||||
|
let second = super::mpris_dbus_name(42);
|
||||||
|
assert_eq!(first, "cy.hexor.furumi.instance41");
|
||||||
|
assert_ne!(first, second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1189
File diff suppressed because it is too large
Load Diff
+190
@@ -0,0 +1,190 @@
|
|||||||
|
//! Cheap now-playing export for status bars and other polling clients.
|
||||||
|
//!
|
||||||
|
//! The UI only sends snapshots through a bounded channel. A dedicated thread
|
||||||
|
//! performs the filesystem writes so a slow filesystem cannot stall drawing
|
||||||
|
//! or input handling.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::{self, SyncSender, TrySendError};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::app::state::PlayerBar;
|
||||||
|
|
||||||
|
const STALE_AFTER_MS: u64 = 5_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PlaybackStatus {
|
||||||
|
pub playing: bool,
|
||||||
|
pub paused: bool,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub album: String,
|
||||||
|
pub position_secs: f64,
|
||||||
|
pub duration_secs: f64,
|
||||||
|
pub volume: u8,
|
||||||
|
pub updated_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackStatus {
|
||||||
|
pub fn from_player(player: &PlayerBar) -> Option<Self> {
|
||||||
|
let track = player.current.as_ref()?;
|
||||||
|
player.playing.then(|| Self {
|
||||||
|
playing: true,
|
||||||
|
paused: player.paused,
|
||||||
|
title: track.title.clone(),
|
||||||
|
artist: track.artist_line(),
|
||||||
|
album: track.release_title.clone(),
|
||||||
|
position_secs: player.position_secs.max(0.0),
|
||||||
|
duration_secs: track.duration_seconds.max(0.0),
|
||||||
|
volume: player.volume,
|
||||||
|
updated_at_ms: now_ms(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn one_line(&self) -> String {
|
||||||
|
let icon = if self.paused { "⏸" } else { "▶" };
|
||||||
|
let track = if self.artist.is_empty() {
|
||||||
|
self.title.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} — {}", self.artist, self.title)
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{icon} {track} {}/{}",
|
||||||
|
duration(self.position_secs),
|
||||||
|
duration(self.duration_secs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Publisher {
|
||||||
|
tx: Option<SyncSender<Option<PlaybackStatus>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Publisher {
|
||||||
|
pub fn spawn() -> Self {
|
||||||
|
let (tx, rx) = mpsc::sync_channel(1);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("playback-status".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
while let Ok(snapshot) = rx.recv() {
|
||||||
|
if let Some(snapshot) = snapshot {
|
||||||
|
if let Err(err) = write(&snapshot) {
|
||||||
|
tracing::debug!(?err, "writing playback status failed");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
Self { tx: Some(tx) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publish(&self, snapshot: Option<PlaybackStatus>) {
|
||||||
|
let Some(tx) = &self.tx else { return };
|
||||||
|
match tx.try_send(snapshot) {
|
||||||
|
Ok(()) | Err(TrySendError::Full(_)) => {}
|
||||||
|
Err(TrySendError::Disconnected(_)) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Publisher {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.tx.take();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print(json: bool) -> anyhow::Result<()> {
|
||||||
|
let Some(status) = read()? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&status)?);
|
||||||
|
} else {
|
||||||
|
println!("{}", status.one_line());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read() -> anyhow::Result<Option<PlaybackStatus>> {
|
||||||
|
let Some(path) = path() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let bytes = match fs::read(path) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
let status: PlaybackStatus = serde_json::from_slice(&bytes)?;
|
||||||
|
if now_ms().saturating_sub(status.updated_at_ms) > STALE_AFTER_MS {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(status: &PlaybackStatus) -> anyhow::Result<()> {
|
||||||
|
let Some(path) = path() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(dir) = path.parent() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
let temporary = dir.join("playback-status.tmp");
|
||||||
|
fs::write(&temporary, serde_json::to_vec(status)?)?;
|
||||||
|
#[cfg(windows)]
|
||||||
|
if path.exists() {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
fs::rename(temporary, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove() {
|
||||||
|
if let Some(path) = path() {
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path() -> Option<PathBuf> {
|
||||||
|
crate::config::project_dirs().map(|dirs| dirs.cache_dir().join("playback-status.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration(seconds: f64) -> String {
|
||||||
|
let total = seconds.max(0.0).round() as u64;
|
||||||
|
format!("{}:{:02}", total / 60, total % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_line_contains_state_metadata_and_progress() {
|
||||||
|
let status = PlaybackStatus {
|
||||||
|
playing: true,
|
||||||
|
paused: true,
|
||||||
|
title: "Track".into(),
|
||||||
|
artist: "Artist".into(),
|
||||||
|
album: "Release".into(),
|
||||||
|
position_secs: 83.0,
|
||||||
|
duration_secs: 245.0,
|
||||||
|
volume: 80,
|
||||||
|
updated_at_ms: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(status.one_line(), "⏸ Artist — Track 1:23/4:05");
|
||||||
|
}
|
||||||
|
}
|
||||||
+350
-53
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph};
|
use ratatui::widgets::{Block, Paragraph};
|
||||||
|
|
||||||
use super::theme;
|
use super::theme;
|
||||||
use crate::app::state::{AppState, DevicePresenceSection, FedRow, settings_rows};
|
use crate::app::state::{AppState, DevicePresenceSection, FedRow, SimilarityRow, settings_rows};
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
@@ -27,12 +28,12 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
.areas(inner);
|
.areas(inner);
|
||||||
|
|
||||||
draw_settings_rows(frame, rows_area, state);
|
draw_settings_rows(frame, rows_area, state);
|
||||||
draw_status(frame, status_area, state);
|
draw_status_column(frame, status_area, state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows_height =
|
let rows_height =
|
||||||
(settings_rows(state).len() + 6 + device_presence_sections(state).len()) as u16;
|
(settings_rows(state).len() + 10 + device_presence_sections(state).len()) as u16;
|
||||||
let [rows_area, _, status_area] = Layout::vertical([
|
let [rows_area, _, status_area] = Layout::vertical([
|
||||||
Constraint::Length(rows_height.min(inner.height)),
|
Constraint::Length(rows_height.min(inner.height)),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
@@ -41,7 +42,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
.areas(inner);
|
.areas(inner);
|
||||||
|
|
||||||
draw_settings_rows(frame, rows_area, state);
|
draw_settings_rows(frame, rows_area, state);
|
||||||
draw_status(frame, status_area, state);
|
draw_status_column(frame, status_area, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn device_presence_sections(state: &AppState) -> Vec<DevicePresenceSection> {
|
fn device_presence_sections(state: &AppState) -> Vec<DevicePresenceSection> {
|
||||||
@@ -68,6 +69,66 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
let mut y = area.y;
|
let mut y = area.y;
|
||||||
let mut cursor = 0usize;
|
let mut cursor = 0usize;
|
||||||
|
|
||||||
|
draw_section(frame, area, state, &mut y, "Library");
|
||||||
|
draw_row(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
state,
|
||||||
|
&mut y,
|
||||||
|
cursor,
|
||||||
|
state.settings_cursor,
|
||||||
|
"Music save directory",
|
||||||
|
if state.music_dir_changing {
|
||||||
|
format!("{} checking/changing…", state.spinner())
|
||||||
|
} else {
|
||||||
|
state.music_dir.to_string_lossy().into_owned()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cursor += 1;
|
||||||
|
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
|
||||||
|
draw_section(frame, area, state, &mut y, "Similarity Search");
|
||||||
|
let similarity = &state.similarity.settings;
|
||||||
|
for row in SimilarityRow::ALL {
|
||||||
|
let (label, value) = match row {
|
||||||
|
SimilarityRow::Toggle => ("Similarity search", on_off(similarity.enabled).to_string()),
|
||||||
|
SimilarityRow::Model => (
|
||||||
|
"Embedding model",
|
||||||
|
crate::similarity::model_by_id(&similarity.model)
|
||||||
|
.map(|model| format!("{} · {}", model.id, model.license))
|
||||||
|
.unwrap_or_else(|| similarity.model.clone()),
|
||||||
|
),
|
||||||
|
SimilarityRow::Profile => (
|
||||||
|
"Preprocessing profile",
|
||||||
|
format!("{} (enter for details)", similarity.profile),
|
||||||
|
),
|
||||||
|
SimilarityRow::MinimumScore => (
|
||||||
|
"Minimum similarity",
|
||||||
|
format!("{:.2}", similarity.minimum_score),
|
||||||
|
),
|
||||||
|
SimilarityRow::MaxTracksPerArtist => (
|
||||||
|
"Tracks per artist",
|
||||||
|
similarity.max_tracks_per_artist.to_string(),
|
||||||
|
),
|
||||||
|
SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
|
||||||
|
SimilarityRow::Clear => ("Clear all stored embeddings", "↵".to_string()),
|
||||||
|
};
|
||||||
|
draw_row(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
state,
|
||||||
|
&mut y,
|
||||||
|
cursor,
|
||||||
|
state.settings_cursor,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
|
||||||
draw_section(frame, area, state, &mut y, "Federation");
|
draw_section(frame, area, state, &mut y, "Federation");
|
||||||
for row in FedRow::ALL {
|
for row in FedRow::ALL {
|
||||||
let (label, value) = match row {
|
let (label, value) = match row {
|
||||||
@@ -338,6 +399,22 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn protocol_label(id: &str) -> &str {
|
||||||
|
match id {
|
||||||
|
"federation_net" => "Federation transport",
|
||||||
|
"ticket" => "Peer ticket",
|
||||||
|
"rendezvous" => "Rendezvous",
|
||||||
|
"music_dht" => "Music DHT",
|
||||||
|
"catalog" => "Catalog",
|
||||||
|
"audio" => "Audio transfer",
|
||||||
|
"similarity" => "Similarity search",
|
||||||
|
"similarity_dht" => "Similarity DHT",
|
||||||
|
"device_sync" => "Device sync",
|
||||||
|
"jam" => "Jam",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_section(frame: &mut Frame, area: Rect, state: &AppState, y: &mut u16, title: &'static str) {
|
fn draw_section(frame: &mut Frame, area: Rect, state: &AppState, y: &mut u16, title: &'static str) {
|
||||||
if *y >= area.y + area.height {
|
if *y >= area.y + area.height {
|
||||||
return;
|
return;
|
||||||
@@ -471,6 +548,61 @@ fn short_id(id: &str) -> String {
|
|||||||
id.chars().take(12).collect::<String>() + "…"
|
id.chars().take(12).collect::<String>() + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_status_column(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
draw_status(frame, area, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_similarity_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
draw_summary_card(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
state,
|
||||||
|
" Similarity Processing ",
|
||||||
|
similarity_summary_lines(state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn similarity_summary_lines(state: &AppState) -> Vec<Line<'static>> {
|
||||||
|
let status = &state.similarity.status;
|
||||||
|
let progress = if status.total_tracks == 0 {
|
||||||
|
"0 / 0".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} / {}", status.completed_tracks, status.total_tracks)
|
||||||
|
};
|
||||||
|
let active = status
|
||||||
|
.active_profile
|
||||||
|
.as_deref()
|
||||||
|
.map(short_id)
|
||||||
|
.unwrap_or_else(|| "not ready".to_string());
|
||||||
|
let target = status
|
||||||
|
.target_profile
|
||||||
|
.as_deref()
|
||||||
|
.map(short_id)
|
||||||
|
.unwrap_or_else(|| "—".to_string());
|
||||||
|
vec![
|
||||||
|
summary_line("State", status.phase.label().to_string()),
|
||||||
|
summary_line("Progress", progress),
|
||||||
|
summary_line("Active", active),
|
||||||
|
summary_line("Processing", target),
|
||||||
|
summary_line(
|
||||||
|
"Stored",
|
||||||
|
format!(
|
||||||
|
"{} vectors / {}",
|
||||||
|
status.stored_vectors,
|
||||||
|
short_bytes_label(status.stored_bytes)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
summary_line(
|
||||||
|
"Current",
|
||||||
|
status
|
||||||
|
.current_track
|
||||||
|
.clone()
|
||||||
|
.or_else(|| status.last_error.clone())
|
||||||
|
.unwrap_or_else(|| format!("{} errors", status.failed_tracks)),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -485,57 +617,81 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if area.width >= 60 && area.height >= 15 {
|
if area.width >= 60 {
|
||||||
let [top_area, _, bottom_area] = Layout::vertical([
|
let paired_width = area.width.saturating_sub(1) / 2;
|
||||||
Constraint::Length(7),
|
let paired_height =
|
||||||
Constraint::Length(1),
|
protocol_card_height(state, paired_width.saturating_sub(2), area.height).max(8);
|
||||||
Constraint::Length(7),
|
if area.height >= 16 + paired_height {
|
||||||
])
|
let [top_area, _, middle_area, _, paired_area, _] = Layout::vertical([
|
||||||
.areas(area);
|
Constraint::Length(7),
|
||||||
let [status_area, _, local_area] = Layout::horizontal([
|
Constraint::Length(1),
|
||||||
Constraint::Percentage(50),
|
Constraint::Length(7),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Percentage(50),
|
Constraint::Length(paired_height),
|
||||||
])
|
Constraint::Min(0),
|
||||||
.areas(top_area);
|
])
|
||||||
let [transport_area, _, devices_area] = Layout::horizontal([
|
.areas(area);
|
||||||
Constraint::Percentage(50),
|
let [status_area, _, local_area] = Layout::horizontal([
|
||||||
Constraint::Length(1),
|
Constraint::Percentage(50),
|
||||||
Constraint::Percentage(50),
|
Constraint::Length(1),
|
||||||
])
|
Constraint::Percentage(50),
|
||||||
.areas(bottom_area);
|
])
|
||||||
draw_summary_card(
|
.areas(top_area);
|
||||||
frame,
|
let [transport_area, _, devices_area] = Layout::horizontal([
|
||||||
status_area,
|
Constraint::Percentage(50),
|
||||||
state,
|
Constraint::Length(1),
|
||||||
" Status ",
|
Constraint::Percentage(50),
|
||||||
node_summary_lines(state),
|
])
|
||||||
);
|
.areas(middle_area);
|
||||||
draw_summary_card(
|
let [similarity_area, _, protocols_area] = Layout::horizontal([
|
||||||
frame,
|
Constraint::Percentage(50),
|
||||||
local_area,
|
Constraint::Length(1),
|
||||||
state,
|
Constraint::Percentage(50),
|
||||||
" Local Data ",
|
])
|
||||||
local_data_summary_lines(state),
|
.areas(paired_area);
|
||||||
);
|
draw_summary_card(
|
||||||
draw_summary_card(
|
frame,
|
||||||
frame,
|
status_area,
|
||||||
transport_area,
|
state,
|
||||||
state,
|
" Status ",
|
||||||
" Iroh Transport ",
|
node_summary_lines(state),
|
||||||
transport_summary_lines(state),
|
);
|
||||||
);
|
draw_summary_card(
|
||||||
draw_summary_card(
|
frame,
|
||||||
frame,
|
local_area,
|
||||||
devices_area,
|
state,
|
||||||
state,
|
" Local Data ",
|
||||||
" Connected Devices ",
|
local_data_summary_lines(state),
|
||||||
device_summary_lines(state),
|
);
|
||||||
);
|
draw_summary_card(
|
||||||
return;
|
frame,
|
||||||
|
transport_area,
|
||||||
|
state,
|
||||||
|
" Iroh Transport ",
|
||||||
|
transport_summary_lines(state),
|
||||||
|
);
|
||||||
|
draw_summary_card(
|
||||||
|
frame,
|
||||||
|
devices_area,
|
||||||
|
state,
|
||||||
|
" Connected Devices ",
|
||||||
|
device_summary_lines(state),
|
||||||
|
);
|
||||||
|
draw_similarity_status(frame, similarity_area, state);
|
||||||
|
draw_summary_card(
|
||||||
|
frame,
|
||||||
|
protocols_area,
|
||||||
|
state,
|
||||||
|
" Protocol Versions ",
|
||||||
|
protocol_summary_lines(state, protocols_area.width.saturating_sub(2)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if area.height < 31 {
|
let protocols_height = protocol_card_height(state, area.width.saturating_sub(2), area.height);
|
||||||
|
let required_vertical_height = 41u16.saturating_add(protocols_height);
|
||||||
|
if area.height < required_vertical_height {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(compact_status_lines(state))
|
Paragraph::new(compact_status_lines(state))
|
||||||
.wrap(ratatui::widgets::Wrap { trim: false }),
|
.wrap(ratatui::widgets::Wrap { trim: false }),
|
||||||
@@ -553,6 +709,10 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
_,
|
_,
|
||||||
local_area,
|
local_area,
|
||||||
_,
|
_,
|
||||||
|
similarity_area,
|
||||||
|
_,
|
||||||
|
protocols_area,
|
||||||
|
_,
|
||||||
] = Layout::vertical([
|
] = Layout::vertical([
|
||||||
Constraint::Length(7),
|
Constraint::Length(7),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
@@ -561,6 +721,10 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
Constraint::Length(7),
|
Constraint::Length(7),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(7),
|
Constraint::Length(7),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(8),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(protocols_height),
|
||||||
Constraint::Min(0),
|
Constraint::Min(0),
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
@@ -593,6 +757,14 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
" Local Data ",
|
" Local Data ",
|
||||||
local_data_summary_lines(state),
|
local_data_summary_lines(state),
|
||||||
);
|
);
|
||||||
|
draw_similarity_status(frame, similarity_area, state);
|
||||||
|
draw_summary_card(
|
||||||
|
frame,
|
||||||
|
protocols_area,
|
||||||
|
state,
|
||||||
|
" Protocol Versions ",
|
||||||
|
protocol_summary_lines(state, protocols_area.width.saturating_sub(2)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compact_status_lines(state: &AppState) -> Vec<Line<'static>> {
|
fn compact_status_lines(state: &AppState) -> Vec<Line<'static>> {
|
||||||
@@ -608,6 +780,15 @@ fn compact_status_lines(state: &AppState) -> Vec<Line<'static>> {
|
|||||||
lines.push(Line::default());
|
lines.push(Line::default());
|
||||||
lines.push(Line::styled("Connected Devices", theme::header_for(state)));
|
lines.push(Line::styled("Connected Devices", theme::header_for(state)));
|
||||||
lines.extend(device_summary_lines(state).into_iter().take(2));
|
lines.extend(device_summary_lines(state).into_iter().take(2));
|
||||||
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::styled(
|
||||||
|
"Similarity Processing",
|
||||||
|
theme::header_for(state),
|
||||||
|
));
|
||||||
|
lines.extend(similarity_summary_lines(state).into_iter().take(3));
|
||||||
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::styled("Protocol Versions", theme::header_for(state)));
|
||||||
|
lines.extend(protocol_summary_lines(state, 0).into_iter().take(3));
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,6 +818,122 @@ fn summary_line(label: &'static str, value: String) -> Line<'static> {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn protocol_summary_line(
|
||||||
|
label: &str,
|
||||||
|
value: String,
|
||||||
|
style: Style,
|
||||||
|
label_width: usize,
|
||||||
|
) -> Line<'static> {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{:<label_width$}", protocol_label(label)),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
Span::styled(value, style),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_summary_lines(state: &AppState, width: u16) -> Vec<Line<'static>> {
|
||||||
|
let Some(status) = state.federation.status.as_ref() else {
|
||||||
|
return vec![protocol_summary_line(
|
||||||
|
"status",
|
||||||
|
"[UNKNOWN]".to_string(),
|
||||||
|
theme::dim(),
|
||||||
|
22,
|
||||||
|
)];
|
||||||
|
};
|
||||||
|
let protocols = &status.protocols;
|
||||||
|
let newer = !protocols.newer.is_empty();
|
||||||
|
let badge = if newer {
|
||||||
|
"[NEWER VERSION SEEN]"
|
||||||
|
} else if status.running && protocols.observed_peers == 0 {
|
||||||
|
"[CURRENT · waiting for peers]"
|
||||||
|
} else {
|
||||||
|
"[CURRENT]"
|
||||||
|
};
|
||||||
|
let badge_style = Style::new()
|
||||||
|
.fg(if newer { Color::LightRed } else { Color::Green })
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let mut lines = vec![protocol_summary_line(
|
||||||
|
"status",
|
||||||
|
badge.to_string(),
|
||||||
|
badge_style,
|
||||||
|
22,
|
||||||
|
)];
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for (id, local) in &protocols.local {
|
||||||
|
let observed = protocols.observed.get(id).copied();
|
||||||
|
let value = match observed {
|
||||||
|
Some(remote) if remote > *local => format!("local {local} · network {remote}"),
|
||||||
|
Some(remote) => format!("{local} · seen {remote}"),
|
||||||
|
None => local.to_string(),
|
||||||
|
};
|
||||||
|
let style = if observed.is_some_and(|remote| remote > *local) {
|
||||||
|
Style::new()
|
||||||
|
.fg(Color::LightRed)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
entries.push((id.as_str(), value, style));
|
||||||
|
}
|
||||||
|
if width >= 58 {
|
||||||
|
let cell_width = width as usize / 2;
|
||||||
|
let label_width = 22.min(cell_width.saturating_sub(3));
|
||||||
|
let value_width = cell_width.saturating_sub(label_width + 2);
|
||||||
|
for pair in entries.chunks(2) {
|
||||||
|
let mut spans =
|
||||||
|
protocol_cell_spans(pair[0].0, &pair[0].1, pair[0].2, label_width, value_width);
|
||||||
|
if let Some(second) = pair.get(1) {
|
||||||
|
spans.push(Span::styled(" ", theme::dim()));
|
||||||
|
spans.extend(protocol_cell_spans(
|
||||||
|
second.0,
|
||||||
|
&second.1,
|
||||||
|
second.2,
|
||||||
|
label_width,
|
||||||
|
value_width,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.extend(
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, value, style)| protocol_summary_line(id, value, style, 22)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if newer {
|
||||||
|
lines.push(Line::styled(
|
||||||
|
"A newer protocol was observed; update Furumi for compatibility.",
|
||||||
|
Style::new().fg(Color::LightRed),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_card_height(state: &AppState, width: u16, available: u16) -> u16 {
|
||||||
|
let content = protocol_summary_lines(state, width).len() as u16;
|
||||||
|
content.saturating_add(2).min(available)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_cell_spans(
|
||||||
|
label: &str,
|
||||||
|
value: &str,
|
||||||
|
style: Style,
|
||||||
|
label_width: usize,
|
||||||
|
value_width: usize,
|
||||||
|
) -> Vec<Span<'static>> {
|
||||||
|
let value = value.chars().take(value_width).collect::<String>();
|
||||||
|
vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{:<label_width$}", protocol_label(label)),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
Span::styled(format!("{value:<value_width$}"), style),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
fn node_summary_lines(state: &AppState) -> Vec<Line<'static>> {
|
fn node_summary_lines(state: &AppState) -> Vec<Line<'static>> {
|
||||||
match &state.federation.status {
|
match &state.federation.status {
|
||||||
None => vec![
|
None => vec![
|
||||||
|
|||||||
+161
-2
@@ -152,7 +152,8 @@ fn draw_tile_meta(
|
|||||||
availability: Option<Availability>,
|
availability: Option<Availability>,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
) {
|
) {
|
||||||
let marker = availability.map(|availability| availability_marker(availability, selected));
|
let selected_style = selected.then(|| theme::tab_active_for(state));
|
||||||
|
let marker = availability.map(|availability| availability_marker(availability, selected_style));
|
||||||
let marker_width = marker
|
let marker_width = marker
|
||||||
.map(|(label, _)| UnicodeWidthStr::width(label) as u16)
|
.map(|(label, _)| UnicodeWidthStr::width(label) as u16)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
@@ -889,11 +890,19 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
|||||||
|
|
||||||
fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||||
let search = &state.search;
|
let search = &state.search;
|
||||||
let mut title = format!(" Search: {} ", search.query);
|
let prefix = if search.similarity_source.is_some() {
|
||||||
|
"Search similar to"
|
||||||
|
} else {
|
||||||
|
"Search"
|
||||||
|
};
|
||||||
|
let mut title = format!(" {prefix}: {} ", search.query);
|
||||||
if search.loading {
|
if search.loading {
|
||||||
title.push_str("· searching… ");
|
title.push_str("· searching… ");
|
||||||
}
|
}
|
||||||
let inner = bordered(frame, area, state, title);
|
let inner = bordered(frame, area, state, title);
|
||||||
|
if search.similarity_source.is_some() {
|
||||||
|
return draw_similarity_search(frame, inner, state, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
let empty_results = SearchResults::default();
|
let empty_results = SearchResults::default();
|
||||||
let results = match &search.results {
|
let results = match &search.results {
|
||||||
@@ -1096,6 +1105,156 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_similarity_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||||
|
let search = &state.search;
|
||||||
|
let status = if search.fed_loading {
|
||||||
|
super::loading_line(state, "searching federation…")
|
||||||
|
} else if let Some(stats) = &search.similarity_stats {
|
||||||
|
let elapsed = if stats.elapsed_ms < 1_000 {
|
||||||
|
format!("{} ms", stats.elapsed_ms)
|
||||||
|
} else {
|
||||||
|
format!("{:.2} s", stats.elapsed_ms as f64 / 1_000.0)
|
||||||
|
};
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Federation · ", theme::accent_for(state)),
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"{} tracks · {} artists · {} peers queried · {elapsed}",
|
||||||
|
stats.tracks, stats.artists, stats.peers_queried
|
||||||
|
),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
} else if search.similarity_error.is_some() {
|
||||||
|
Line::styled(
|
||||||
|
"Federation search failed · showing local results",
|
||||||
|
error_style(),
|
||||||
|
)
|
||||||
|
} else if search.loading {
|
||||||
|
super::loading_line(state, "preparing local similarity search…")
|
||||||
|
} else {
|
||||||
|
Line::styled("Federation disabled · showing local results", theme::dim())
|
||||||
|
};
|
||||||
|
let [status_area, content] =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
|
||||||
|
frame.render_widget(Paragraph::new(status), status_area);
|
||||||
|
|
||||||
|
let mut rows: Vec<(Line, Option<String>, Option<usize>)> = Vec::new();
|
||||||
|
rows.push((Line::styled("Tracks", theme::header_for(state)), None, None));
|
||||||
|
if let Some(track) = &search.similarity_source_track {
|
||||||
|
let heart = if state.track_liked(track) {
|
||||||
|
Span::styled("♥ ", theme::accent_for(state))
|
||||||
|
} else {
|
||||||
|
Span::raw(" ")
|
||||||
|
};
|
||||||
|
rows.push((
|
||||||
|
Line::from(vec![
|
||||||
|
heart,
|
||||||
|
Span::raw(track.title.clone()),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {} · {}", track.artist_line(), track.release_title),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Some(super::track_meta_suffix(track, true)),
|
||||||
|
Some(0),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (offset, hit) in search.similarity_tracks.iter().enumerate() {
|
||||||
|
let index = offset + 1;
|
||||||
|
match hit {
|
||||||
|
crate::app::state::SimilaritySearchHit::Local { track, .. } => {
|
||||||
|
let heart = if state.track_liked(track) {
|
||||||
|
Span::styled("♥ ", theme::accent_for(state))
|
||||||
|
} else {
|
||||||
|
Span::raw(" ")
|
||||||
|
};
|
||||||
|
rows.push((
|
||||||
|
Line::from(vec![
|
||||||
|
heart,
|
||||||
|
Span::raw(track.title.clone()),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {} · {}", track.artist_line(), track.release_title),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Some(super::track_meta_suffix(track, true)),
|
||||||
|
Some(index),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
crate::app::state::SimilaritySearchHit::Federated { track, .. } => {
|
||||||
|
let heart = if state.fed_track_liked(track) {
|
||||||
|
Span::styled("♥ ", theme::accent_for(state))
|
||||||
|
} else {
|
||||||
|
Span::raw(" ")
|
||||||
|
};
|
||||||
|
let origin = if track.own {
|
||||||
|
"your library".to_string()
|
||||||
|
} else {
|
||||||
|
format!("peer {}…", track.owner_short())
|
||||||
|
};
|
||||||
|
let mut meta = track.duration_label();
|
||||||
|
if let Some(year) = track.year {
|
||||||
|
if !meta.is_empty() {
|
||||||
|
meta.push_str(" · ");
|
||||||
|
}
|
||||||
|
meta.push_str(&year.to_string());
|
||||||
|
}
|
||||||
|
rows.push((
|
||||||
|
Line::from(vec![
|
||||||
|
heart,
|
||||||
|
fed_track_availability_prefix(state, track),
|
||||||
|
Span::raw(track.title.clone()),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {} · {origin}", track.artist_line()),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Some(meta),
|
||||||
|
Some(index),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scope = crate::app::state::TrackSelectionScope::SimilaritySearch;
|
||||||
|
let mut selected = std::collections::HashSet::new();
|
||||||
|
if state.track_selection.is_active_for(&scope)
|
||||||
|
&& let Some(indices) = state
|
||||||
|
.track_selection
|
||||||
|
.indices(&scope, search.similarity_len())
|
||||||
|
{
|
||||||
|
selected.extend(indices);
|
||||||
|
}
|
||||||
|
let cursor_row = rows
|
||||||
|
.iter()
|
||||||
|
.position(|(_, _, row_cursor)| *row_cursor == Some(cursor))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let visible = usize::from(content.height.max(1));
|
||||||
|
let first = cursor_row
|
||||||
|
.saturating_sub(visible / 2)
|
||||||
|
.min(rows.len().saturating_sub(visible));
|
||||||
|
for (offset, (line, right, row_cursor)) in
|
||||||
|
rows.into_iter().enumerate().skip(first).take(visible)
|
||||||
|
{
|
||||||
|
let rect = Rect {
|
||||||
|
x: content.x,
|
||||||
|
y: content.y + (offset - first) as u16,
|
||||||
|
width: content.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
if let Some(row_index) = row_cursor
|
||||||
|
&& selected.contains(&row_index)
|
||||||
|
&& row_index != cursor
|
||||||
|
{
|
||||||
|
frame
|
||||||
|
.buffer_mut()
|
||||||
|
.set_style(rect, theme::selection_for(state));
|
||||||
|
}
|
||||||
|
draw_row(frame, rect, state, line, right, row_cursor == Some(cursor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Federated artist card (assembled from peer catalogs)
|
// Federated artist card (assembled from peer catalogs)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+19
-8
@@ -19,25 +19,36 @@ use crate::library::models::Availability;
|
|||||||
|
|
||||||
pub(crate) fn availability_marker(
|
pub(crate) fn availability_marker(
|
||||||
availability: Availability,
|
availability: Availability,
|
||||||
selected: bool,
|
selected_style: Option<Style>,
|
||||||
) -> (&'static str, Style) {
|
) -> (&'static str, Style) {
|
||||||
let (label, style) = match availability {
|
let (label, style) = match availability {
|
||||||
Availability::Local => ("●", Style::new().fg(Color::Green)),
|
Availability::Local => ("●", Style::new().fg(Color::Green)),
|
||||||
Availability::Mixed => ("◐", Style::new().fg(Color::Yellow)),
|
Availability::Mixed => ("◐", Style::new().fg(Color::Yellow)),
|
||||||
Availability::Remote => ("⇅", theme::accent()),
|
Availability::Remote => ("⇅", theme::accent()),
|
||||||
};
|
};
|
||||||
if selected {
|
(label, selected_style.unwrap_or(style))
|
||||||
(label, theme::tab_active())
|
|
||||||
} else {
|
|
||||||
(label, style)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn availability_prefix(availability: Availability) -> Span<'static> {
|
pub(crate) fn availability_prefix(availability: Availability) -> Span<'static> {
|
||||||
let (label, style) = availability_marker(availability, false);
|
let (label, style) = availability_marker(availability, None);
|
||||||
Span::styled(format!("{label} "), style)
|
Span::styled(format!("{label} "), style)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_availability_marker_uses_the_supplied_mode_style() {
|
||||||
|
let client_style = Style::new().fg(Color::Black).bg(Color::Yellow);
|
||||||
|
|
||||||
|
let (label, style) = availability_marker(Availability::Local, Some(client_style));
|
||||||
|
|
||||||
|
assert_eq!(label, "●");
|
||||||
|
assert_eq!(style, client_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
||||||
if state.visualizer.active {
|
if state.visualizer.active {
|
||||||
crate::visualizer::draw(frame, state);
|
crate::visualizer::draw(frame, state);
|
||||||
@@ -247,7 +258,7 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
let player = &state.player;
|
let player = &state.player;
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(format!(
|
.title(format!(
|
||||||
" Queue — {} tracks · Mode: {} · enter: play · d: remove · shift-v: select · :clear ",
|
" Queue — {} tracks · Mode: {} · enter: play · alt-j/k: move · d: remove · shift-v: select · :clear ",
|
||||||
player.queue.len(),
|
player.queue.len(),
|
||||||
state.global.filters.source_mode.label()
|
state.global.filters.source_mode.label()
|
||||||
))
|
))
|
||||||
|
|||||||
+300
-23
@@ -1,7 +1,7 @@
|
|||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Cell, Clear, Paragraph, Row, Table, TableState, Wrap};
|
||||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use super::theme;
|
use super::theme;
|
||||||
@@ -25,6 +25,10 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
..
|
..
|
||||||
}) => draw_edit(frame, state, title, fields, *focus, error.as_deref()),
|
}) => draw_edit(frame, state, title, fields, *focus, error.as_deref()),
|
||||||
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, state, label),
|
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, state, label),
|
||||||
|
Some(Popup::SimilarityPrivacyConsent { .. }) => {
|
||||||
|
draw_similarity_privacy_consent(frame, state)
|
||||||
|
}
|
||||||
|
Some(Popup::ConfirmClearEmbeddings) => draw_confirm_clear_embeddings(frame, state),
|
||||||
Some(Popup::LibraryFilters { cursor }) => draw_library_filters(frame, state, *cursor),
|
Some(Popup::LibraryFilters { cursor }) => draw_library_filters(frame, state, *cursor),
|
||||||
Some(Popup::TrackInfo {
|
Some(Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
@@ -42,8 +46,15 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
draw_fed_input(frame, state, field.title(), field.help(), input)
|
draw_fed_input(frame, state, field.title(), field.help(), input)
|
||||||
}
|
}
|
||||||
Some(Popup::FedText { title, text }) => draw_fed_text(frame, state, title, text),
|
Some(Popup::FedText { title, text }) => draw_fed_text(frame, state, title, text),
|
||||||
Some(Popup::FedCopyText { title, text, help }) => {
|
Some(Popup::FedCopyText {
|
||||||
draw_fed_copy_text(frame, state, title, text, help)
|
title,
|
||||||
|
text,
|
||||||
|
help,
|
||||||
|
cursor,
|
||||||
|
}) => draw_fed_copy_text(frame, state, title, text, help, *cursor),
|
||||||
|
Some(Popup::PlainText { .. }) => {}
|
||||||
|
Some(Popup::ConfirmMusicDirectory { path }) => {
|
||||||
|
draw_music_directory_confirmation(frame, state, path)
|
||||||
}
|
}
|
||||||
Some(Popup::FederationStatusDetails {
|
Some(Popup::FederationStatusDetails {
|
||||||
focus,
|
focus,
|
||||||
@@ -88,10 +99,194 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
}
|
}
|
||||||
Some(Popup::ConfirmDeviceLeave) => draw_device_leave(frame, state),
|
Some(Popup::ConfirmDeviceLeave) => draw_device_leave(frame, state),
|
||||||
Some(Popup::ConnectedDevices { cursor }) => draw_connected_devices(frame, state, *cursor),
|
Some(Popup::ConnectedDevices { cursor }) => draw_connected_devices(frame, state, *cursor),
|
||||||
|
Some(Popup::ListenHistory { cursor }) => draw_listen_history(frame, state, *cursor),
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_similarity_privacy_consent(frame: &mut Frame, state: &AppState) {
|
||||||
|
let area = centered(frame.area(), 78, 11);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Similarity search and federation ")
|
||||||
|
.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);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::raw("To find similar music on other peers, Furumi sends them an"),
|
||||||
|
Line::raw("anonymous numeric embedding of the selected track."),
|
||||||
|
Line::raw(""),
|
||||||
|
Line::raw("It contains no account identity, but a peer may technically"),
|
||||||
|
Line::raw("infer what kind of music you are searching from it."),
|
||||||
|
Line::raw(""),
|
||||||
|
Line::styled("enter/y: agree and enable · n/esc: cancel", theme::dim()),
|
||||||
|
])
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_confirm_clear_embeddings(frame: &mut Frame, state: &AppState) {
|
||||||
|
let area = centered(frame.area(), 70, 8);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Clear embeddings? ")
|
||||||
|
.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);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::raw("All model/profile embeddings will be removed from SQLite."),
|
||||||
|
Line::raw("Audio and library metadata stay untouched."),
|
||||||
|
Line::raw("If enabled, processing starts again automatically."),
|
||||||
|
Line::raw(""),
|
||||||
|
Line::styled("enter/y: clear · n/esc: cancel", theme::dim()),
|
||||||
|
]),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_music_directory_confirmation(frame: &mut Frame, state: &AppState, path: &std::path::Path) {
|
||||||
|
let area = centered(frame.area(), 76, 9);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Move existing music? ")
|
||||||
|
.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);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::raw("The destination is writable:"),
|
||||||
|
Line::styled(
|
||||||
|
path.to_string_lossy().into_owned(),
|
||||||
|
theme::accent_for(state),
|
||||||
|
),
|
||||||
|
Line::raw(""),
|
||||||
|
Line::raw("Move music and artwork previously saved by Furumi there?"),
|
||||||
|
Line::styled(
|
||||||
|
"enter/y move · n keep existing files where they are · esc cancel",
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_listen_history(frame: &mut Frame, state: &AppState, cursor: usize) {
|
||||||
|
let area = centered(
|
||||||
|
frame.area(),
|
||||||
|
100,
|
||||||
|
frame.area().height.saturating_sub(4).clamp(10, 30),
|
||||||
|
);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Listening history ")
|
||||||
|
.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);
|
||||||
|
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
|
||||||
|
|
||||||
|
match state.listen_history.as_ref() {
|
||||||
|
Some(Loadable::Loading) | None => {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!("{} Loading history…", state.spinner()))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(Loadable::Failed(err)) => {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!("History unavailable: {err}"))
|
||||||
|
.style(theme::dim())
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(Loadable::Ready(entries)) if entries.is_empty() => {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("No qualified listens yet.")
|
||||||
|
.style(theme::dim())
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(Loadable::Ready(entries)) => {
|
||||||
|
let now_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let rows = entries.iter().map(|entry| {
|
||||||
|
Row::new(vec![
|
||||||
|
Cell::from(entry.title.clone()),
|
||||||
|
Cell::from(entry.artist.clone()),
|
||||||
|
Cell::from(relative_listen_time(entry.started_at_ms, now_ms)),
|
||||||
|
Cell::from(history_device_name(state, &entry.origin_device_id)),
|
||||||
|
])
|
||||||
|
});
|
||||||
|
let mut table_state =
|
||||||
|
TableState::default().with_selected(cursor.min(entries.len() - 1));
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
Constraint::Percentage(34),
|
||||||
|
Constraint::Percentage(28),
|
||||||
|
Constraint::Length(12),
|
||||||
|
Constraint::Percentage(26),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(Row::new(["Track", "Artist", "When", "Device"]).style(theme::header_for(state)))
|
||||||
|
.row_highlight_style(theme::selection_for(state))
|
||||||
|
.highlight_symbol("› ");
|
||||||
|
frame.render_stateful_widget(table, body, &mut table_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("j/k scroll · pgup/pgdn page · esc close")
|
||||||
|
.style(theme::dim())
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
footer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_device_name(state: &AppState, device_id: &str) -> String {
|
||||||
|
state
|
||||||
|
.federation
|
||||||
|
.devices
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|status| {
|
||||||
|
status
|
||||||
|
.devices
|
||||||
|
.iter()
|
||||||
|
.find(|device| device.device_id == device_id)
|
||||||
|
.map(|device| device.name.clone())
|
||||||
|
})
|
||||||
|
.filter(|name| !name.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if device_id == state.device_playback.self_device_id {
|
||||||
|
state.device_playback.self_device_name.clone()
|
||||||
|
} else {
|
||||||
|
device_id.chars().take(10).collect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_listen_time(started_at_ms: i64, now_ms: i64) -> String {
|
||||||
|
let elapsed = now_ms.saturating_sub(started_at_ms).max(0) / 1_000;
|
||||||
|
match elapsed {
|
||||||
|
0..=59 => "now".to_string(),
|
||||||
|
60..=3_599 => format!("{}m ago", elapsed / 60),
|
||||||
|
3_600..=86_399 => format!("{}h ago", elapsed / 3_600),
|
||||||
|
86_400..=604_799 => format!("{}d ago", elapsed / 86_400),
|
||||||
|
_ => format!("{}w ago", elapsed / 604_800),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_federation_status_details(
|
fn draw_federation_status_details(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -329,7 +524,7 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
display_lines.push(DisplayLine::Row(index));
|
display_lines.push(DisplayLine::Row(index));
|
||||||
}
|
}
|
||||||
let height =
|
let height =
|
||||||
(display_lines.len() as u16 + 9).clamp(11, frame.area().height.saturating_sub(2).max(11));
|
(display_lines.len() as u16 + 14).clamp(16, frame.area().height.saturating_sub(2).max(16));
|
||||||
let area = centered(frame.area(), 76, height);
|
let area = centered(frame.area(), 76, height);
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(" Connected devices ")
|
.title(" Connected devices ")
|
||||||
@@ -339,9 +534,10 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let [summary_area, this_area, other_area, hint_area] = Layout::vertical([
|
let [summary_area, this_area, jam_area, other_area, hint_area] = Layout::vertical([
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(4),
|
||||||
Constraint::Min(1),
|
Constraint::Min(1),
|
||||||
Constraint::Length(2),
|
Constraint::Length(2),
|
||||||
])
|
])
|
||||||
@@ -368,12 +564,11 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
width: this_area.width,
|
width: this_area.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let action_label =
|
let action_label = if state.device_playback.is_audio_owner() {
|
||||||
if state.device_playback.role == crate::app::state::DevicePlaybackRole::Active {
|
"This device is active"
|
||||||
"This device is active"
|
} else {
|
||||||
} else {
|
"Make this device active"
|
||||||
"Make this device active"
|
};
|
||||||
};
|
|
||||||
let self_name = self_row
|
let self_name = self_row
|
||||||
.map(|row| row.name.as_str())
|
.map(|row| row.name.as_str())
|
||||||
.unwrap_or(state.device_playback.self_device_name.as_str());
|
.unwrap_or(state.device_playback.self_device_name.as_str());
|
||||||
@@ -390,7 +585,53 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
render_subtitle(frame, other_area, state, "Other devices");
|
render_subtitle(frame, jam_area, state, "Jam");
|
||||||
|
let jam_status = match state.jam.role {
|
||||||
|
crate::jam::JamRole::None => "inactive · h host · J join".to_string(),
|
||||||
|
crate::jam::JamRole::Host => format!(
|
||||||
|
"HOST · {} peer(s) · c copy · h new · l leave",
|
||||||
|
state.jam.participants.len()
|
||||||
|
),
|
||||||
|
crate::jam::JamRole::Participant => format!(
|
||||||
|
"{} · {} · {} participant(s) · l leave",
|
||||||
|
if state.jam.connected {
|
||||||
|
"CONNECTED"
|
||||||
|
} else {
|
||||||
|
"RECONNECTING"
|
||||||
|
},
|
||||||
|
state.jam.host_name.as_deref().unwrap_or("Jam host"),
|
||||||
|
state.jam.participants.len()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::styled(
|
||||||
|
format!(" {jam_status}"),
|
||||||
|
if state.jam.role == crate::jam::JamRole::None {
|
||||||
|
theme::dim()
|
||||||
|
} else {
|
||||||
|
theme::accent_for(state)
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
Rect {
|
||||||
|
x: jam_area.x,
|
||||||
|
y: jam_area.y + 1,
|
||||||
|
width: jam_area.width,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if let Some(error) = state.jam.last_error.as_deref() {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::styled(format!(" {error}"), theme::dim())),
|
||||||
|
Rect {
|
||||||
|
x: jam_area.x,
|
||||||
|
y: jam_area.y + 2,
|
||||||
|
width: jam_area.width,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render_subtitle(frame, other_area, state, "My devices");
|
||||||
let list_area = Rect {
|
let list_area = Rect {
|
||||||
x: other_area.x,
|
x: other_area.x,
|
||||||
y: other_area.y + 1,
|
y: other_area.y + 1,
|
||||||
@@ -449,7 +690,7 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
);
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::styled(
|
Paragraph::new(Line::styled(
|
||||||
"enter: activate selected device / control active selected · esc close",
|
"enter device · h host Jam · J join · c copy · l leave · esc close",
|
||||||
theme::dim(),
|
theme::dim(),
|
||||||
))
|
))
|
||||||
.alignment(Alignment::Center),
|
.alignment(Alignment::Center),
|
||||||
@@ -681,11 +922,19 @@ fn draw_fed_input(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only wrapped text (this peer's federation ticket).
|
/// Read-only wrapped text.
|
||||||
fn draw_fed_text(frame: &mut Frame, state: &AppState, title: &str, text: &str) {
|
fn draw_fed_text(frame: &mut Frame, state: &AppState, title: &str, text: &str) {
|
||||||
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
|
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
|
||||||
let text_width = usize::from(width.saturating_sub(2));
|
let text_width = usize::from(width.saturating_sub(2));
|
||||||
let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16;
|
let lines_needed = text
|
||||||
|
.lines()
|
||||||
|
.map(|line| {
|
||||||
|
UnicodeWidthStr::width(line)
|
||||||
|
.max(1)
|
||||||
|
.div_ceil(text_width.max(1))
|
||||||
|
})
|
||||||
|
.sum::<usize>()
|
||||||
|
.saturating_add(2) as u16;
|
||||||
let area = centered(
|
let area = centered(
|
||||||
frame.area(),
|
frame.area(),
|
||||||
width,
|
width,
|
||||||
@@ -705,7 +954,14 @@ fn draw_fed_text(frame: &mut Frame, state: &AppState, title: &str, text: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapped text with an explicit copy-and-close action.
|
/// Wrapped text with an explicit copy-and-close action.
|
||||||
fn draw_fed_copy_text(frame: &mut Frame, state: &AppState, title: &str, text: &str, help: &str) {
|
fn draw_fed_copy_text(
|
||||||
|
frame: &mut Frame,
|
||||||
|
state: &AppState,
|
||||||
|
title: &str,
|
||||||
|
text: &str,
|
||||||
|
help: &str,
|
||||||
|
cursor: usize,
|
||||||
|
) {
|
||||||
let width = frame.area().width.saturating_sub(8).clamp(36, 96);
|
let width = frame.area().width.saturating_sub(8).clamp(36, 96);
|
||||||
let text_width = usize::from(width.saturating_sub(2));
|
let text_width = usize::from(width.saturating_sub(2));
|
||||||
let text_lines = (text.chars().count() / text_width.max(1) + 1) as u16;
|
let text_lines = (text.chars().count() / text_width.max(1) + 1) as u16;
|
||||||
@@ -737,17 +993,28 @@ fn draw_fed_copy_text(frame: &mut Frame, state: &AppState, title: &str, text: &s
|
|||||||
Paragraph::new(text.to_string()).wrap(Wrap { trim: false }),
|
Paragraph::new(text.to_string()).wrap(Wrap { trim: false }),
|
||||||
body_area,
|
body_area,
|
||||||
);
|
);
|
||||||
|
let button = |label: &str, selected: bool| {
|
||||||
|
if selected {
|
||||||
|
Span::styled(format!(" {label} "), theme::tab_active_for(state))
|
||||||
|
} else {
|
||||||
|
Span::styled(format!(" {label} "), theme::dim())
|
||||||
|
}
|
||||||
|
};
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::styled(
|
Paragraph::new(Line::from(vec![
|
||||||
" Copy to clipboard and close ",
|
button("Copy to clipboard", cursor == 0),
|
||||||
theme::tab_active_for(state),
|
Span::raw(" "),
|
||||||
))
|
button("Show as plain terminal line", cursor == 1),
|
||||||
|
]))
|
||||||
.alignment(Alignment::Center),
|
.alignment(Alignment::Center),
|
||||||
button_area,
|
button_area,
|
||||||
);
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::styled("enter/c copy · esc close", theme::dim()))
|
Paragraph::new(Line::styled(
|
||||||
.alignment(Alignment::Center),
|
"left/right choose · enter apply · c copy · p plain line · esc close",
|
||||||
|
theme::dim(),
|
||||||
|
))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
footer_area,
|
footer_area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1071,12 +1338,22 @@ fn draw_track_info(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let can_share = crate::share::track_can_share(track);
|
let can_share = crate::share::track_can_share(track);
|
||||||
let hint = if tracks.len() > 1 && can_share {
|
let can_similar =
|
||||||
|
state.similarity.settings.enabled && track.id >= 0 && !track.file_path.is_empty();
|
||||||
|
let hint = if tracks.len() > 1 && can_share && can_similar {
|
||||||
|
"j/k scroll · h/left previous · l/right next · a artist · s similar · c copy link · esc"
|
||||||
|
} else if tracks.len() > 1 && can_share {
|
||||||
"j/k scroll · h/left previous · l/right next · a artist · c copy frid link · esc close"
|
"j/k scroll · h/left previous · l/right next · a artist · c copy frid link · esc close"
|
||||||
|
} else if tracks.len() > 1 && can_similar {
|
||||||
|
"j/k scroll · h/left previous · l/right next · a artist · s similar · esc close"
|
||||||
} else if tracks.len() > 1 {
|
} else if tracks.len() > 1 {
|
||||||
"j/k scroll · h/left previous · l/right next · a artist · esc close"
|
"j/k scroll · h/left previous · l/right next · a artist · esc close"
|
||||||
|
} else if can_share && can_similar {
|
||||||
|
"j/k scroll · a artist · s similar · c copy link · esc close"
|
||||||
} else if can_share {
|
} else if can_share {
|
||||||
"j/k scroll · a artist · c copy frid link · esc close"
|
"j/k scroll · a artist · c copy frid link · esc close"
|
||||||
|
} else if can_similar {
|
||||||
|
"j/k scroll · a artist · s similar · esc close"
|
||||||
} else {
|
} else {
|
||||||
"j/k scroll · a artist · esc close"
|
"j/k scroll · a artist · esc close"
|
||||||
};
|
};
|
||||||
|
|||||||
+14
-19
@@ -4,6 +4,7 @@ use crate::app::state::{AppState, DevicePlaybackRole};
|
|||||||
|
|
||||||
pub const ACCENT: Color = Color::Cyan;
|
pub const ACCENT: Color = Color::Cyan;
|
||||||
pub const CONTROL_ACCENT: Color = Color::Yellow;
|
pub const CONTROL_ACCENT: Color = Color::Yellow;
|
||||||
|
pub const JAM_ACCENT: Color = Color::Magenta;
|
||||||
pub const DIM: Color = Color::DarkGray;
|
pub const DIM: Color = Color::DarkGray;
|
||||||
|
|
||||||
pub fn accent() -> Style {
|
pub fn accent() -> Style {
|
||||||
@@ -18,13 +19,6 @@ pub fn dim() -> Style {
|
|||||||
Style::new().fg(DIM)
|
Style::new().fg(DIM)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tab_active() -> Style {
|
|
||||||
Style::new()
|
|
||||||
.fg(Color::Black)
|
|
||||||
.bg(ACCENT)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn tab_active_for(state: &AppState) -> Style {
|
pub fn tab_active_for(state: &AppState) -> Style {
|
||||||
Style::new()
|
Style::new()
|
||||||
.fg(Color::Black)
|
.fg(Color::Black)
|
||||||
@@ -44,10 +38,10 @@ pub fn selection() -> Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn selection_for(state: &AppState) -> Style {
|
pub fn selection_for(state: &AppState) -> Style {
|
||||||
if state.device_playback.is_control() {
|
match state.device_playback.role {
|
||||||
Style::new().fg(Color::White).bg(Color::Rgb(92, 72, 0))
|
DevicePlaybackRole::Jam => Style::new().fg(Color::White).bg(Color::Rgb(80, 24, 96)),
|
||||||
} else {
|
DevicePlaybackRole::Control => Style::new().fg(Color::White).bg(Color::Rgb(92, 72, 0)),
|
||||||
selection()
|
DevicePlaybackRole::Active => selection(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +50,10 @@ pub fn header_for(state: &AppState) -> Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn border_for(state: &AppState) -> Style {
|
pub fn border_for(state: &AppState) -> Style {
|
||||||
if state.device_playback.is_control() {
|
match state.device_playback.role {
|
||||||
Style::new().fg(CONTROL_ACCENT)
|
DevicePlaybackRole::Jam => Style::new().fg(JAM_ACCENT),
|
||||||
} else {
|
DevicePlaybackRole::Control => Style::new().fg(CONTROL_ACCENT),
|
||||||
dim()
|
DevicePlaybackRole::Active => dim(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +65,7 @@ pub fn role_pill(role: DevicePlaybackRole) -> Style {
|
|||||||
let bg = match role {
|
let bg = match role {
|
||||||
DevicePlaybackRole::Active => Color::Green,
|
DevicePlaybackRole::Active => Color::Green,
|
||||||
DevicePlaybackRole::Control => CONTROL_ACCENT,
|
DevicePlaybackRole::Control => CONTROL_ACCENT,
|
||||||
|
DevicePlaybackRole::Jam => JAM_ACCENT,
|
||||||
};
|
};
|
||||||
Style::new()
|
Style::new()
|
||||||
.fg(Color::Black)
|
.fg(Color::Black)
|
||||||
@@ -79,9 +74,9 @@ pub fn role_pill(role: DevicePlaybackRole) -> Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn accent_color_for(state: &AppState) -> Color {
|
fn accent_color_for(state: &AppState) -> Color {
|
||||||
if state.device_playback.is_control() {
|
match state.device_playback.role {
|
||||||
CONTROL_ACCENT
|
DevicePlaybackRole::Jam => JAM_ACCENT,
|
||||||
} else {
|
DevicePlaybackRole::Control => CONTROL_ACCENT,
|
||||||
ACCENT
|
DevicePlaybackRole::Active => ACCENT,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+23
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "block"
|
||||||
|
version = "0.1.6"
|
||||||
|
authors = ["Steven Sheldon"]
|
||||||
|
|
||||||
|
description = "Rust interface for Apple's C language extension of blocks."
|
||||||
|
keywords = ["blocks", "osx", "ios", "objective-c"]
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "http://github.com/SSheldon/rust-block"
|
||||||
|
documentation = "http://ssheldon.github.io/rust-objc/block/"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
exclude = [
|
||||||
|
".gitignore",
|
||||||
|
".travis.yml",
|
||||||
|
"travis_install.sh",
|
||||||
|
"travis_test.sh",
|
||||||
|
"tests-ios/**",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dev-dependencies.objc_test_utils]
|
||||||
|
version = "0.0"
|
||||||
|
path = "test_utils"
|
||||||
Vendored
+42
@@ -0,0 +1,42 @@
|
|||||||
|
Rust interface for Apple's C language extension of blocks.
|
||||||
|
|
||||||
|
For more information on the specifics of the block implementation, see
|
||||||
|
Clang's documentation: http://clang.llvm.org/docs/Block-ABI-Apple.html
|
||||||
|
|
||||||
|
## Invoking blocks
|
||||||
|
|
||||||
|
The `Block` struct is used for invoking blocks from Objective-C. For example,
|
||||||
|
consider this Objective-C function:
|
||||||
|
|
||||||
|
``` objc
|
||||||
|
int32_t sum(int32_t (^block)(int32_t, int32_t)) {
|
||||||
|
return block(5, 8);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We could write it in Rust as the following:
|
||||||
|
|
||||||
|
``` rust
|
||||||
|
unsafe fn sum(block: &Block<(i32, i32), i32>) -> i32 {
|
||||||
|
block.call((5, 8))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the extra parentheses in the `call` method, since the arguments must be
|
||||||
|
passed as a tuple.
|
||||||
|
|
||||||
|
## Creating blocks
|
||||||
|
|
||||||
|
Creating a block to pass to Objective-C can be done with the `ConcreteBlock`
|
||||||
|
struct. For example, to create a block that adds two `i32`s, we could write:
|
||||||
|
|
||||||
|
``` rust
|
||||||
|
let block = ConcreteBlock::new(|a: i32, b: i32| a + b);
|
||||||
|
let block = block.copy();
|
||||||
|
assert!(unsafe { block.call((5, 8)) } == 13);
|
||||||
|
```
|
||||||
|
|
||||||
|
It is important to copy your block to the heap (with the `copy` method) before
|
||||||
|
passing it to Objective-C; this is because our `ConcreteBlock` is only meant
|
||||||
|
to be copied once, and we can enforce this in Rust, but if Objective-C code
|
||||||
|
were to copy it twice we could have a double free.
|
||||||
Vendored
+399
@@ -0,0 +1,399 @@
|
|||||||
|
/*!
|
||||||
|
A Rust interface for Objective-C blocks.
|
||||||
|
|
||||||
|
For more information on the specifics of the block implementation, see
|
||||||
|
Clang's documentation: http://clang.llvm.org/docs/Block-ABI-Apple.html
|
||||||
|
|
||||||
|
# Invoking blocks
|
||||||
|
|
||||||
|
The `Block` struct is used for invoking blocks from Objective-C. For example,
|
||||||
|
consider this Objective-C function:
|
||||||
|
|
||||||
|
``` objc
|
||||||
|
int32_t sum(int32_t (^block)(int32_t, int32_t)) {
|
||||||
|
return block(5, 8);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We could write it in Rust as the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
# use block::Block;
|
||||||
|
unsafe fn sum(block: &Block<(i32, i32), i32>) -> i32 {
|
||||||
|
block.call((5, 8))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the extra parentheses in the `call` method, since the arguments must be
|
||||||
|
passed as a tuple.
|
||||||
|
|
||||||
|
# Creating blocks
|
||||||
|
|
||||||
|
Creating a block to pass to Objective-C can be done with the `ConcreteBlock`
|
||||||
|
struct. For example, to create a block that adds two `i32`s, we could write:
|
||||||
|
|
||||||
|
```
|
||||||
|
# use block::ConcreteBlock;
|
||||||
|
let block = ConcreteBlock::new(|a: i32, b: i32| a + b);
|
||||||
|
let block = block.copy();
|
||||||
|
assert!(unsafe { block.call((5, 8)) } == 13);
|
||||||
|
```
|
||||||
|
|
||||||
|
It is important to copy your block to the heap (with the `copy` method) before
|
||||||
|
passing it to Objective-C; this is because our `ConcreteBlock` is only meant
|
||||||
|
to be copied once, and we can enforce this in Rust, but if Objective-C code
|
||||||
|
were to copy it twice we could have a double free.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_utils;
|
||||||
|
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::mem;
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::os::raw::{c_int, c_ulong, c_void};
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct Class {
|
||||||
|
_private: [u8; 0],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(any(target_os = "macos", target_os = "ios"),
|
||||||
|
link(name = "System", kind = "dylib"))]
|
||||||
|
#[cfg_attr(not(any(target_os = "macos", target_os = "ios")),
|
||||||
|
link(name = "BlocksRuntime", kind = "dylib"))]
|
||||||
|
extern "C" {
|
||||||
|
static _NSConcreteStackBlock: Class;
|
||||||
|
|
||||||
|
fn _Block_copy(block: *const c_void) -> *mut c_void;
|
||||||
|
fn _Block_release(block: *const c_void);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Types that may be used as the arguments to an Objective-C block.
|
||||||
|
pub trait BlockArguments: Sized {
|
||||||
|
/// Calls the given `Block` with self as the arguments.
|
||||||
|
///
|
||||||
|
/// Unsafe because `block` must point to a valid `Block` and this invokes
|
||||||
|
/// foreign code whose safety the compiler cannot verify.
|
||||||
|
unsafe fn call_block<R>(self, block: *mut Block<Self, R>) -> R;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! block_args_impl {
|
||||||
|
($($a:ident : $t:ident),*) => (
|
||||||
|
impl<$($t),*> BlockArguments for ($($t,)*) {
|
||||||
|
unsafe fn call_block<R>(self, block: *mut Block<Self, R>) -> R {
|
||||||
|
let invoke: unsafe extern "C" fn(*mut Block<Self, R> $(, $t)*) -> R = {
|
||||||
|
let base = block as *mut BlockBase<Self, R>;
|
||||||
|
mem::transmute((*base).invoke)
|
||||||
|
};
|
||||||
|
let ($($a,)*) = self;
|
||||||
|
invoke(block $(, $a)*)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
block_args_impl!();
|
||||||
|
block_args_impl!(a: A);
|
||||||
|
block_args_impl!(a: A, b: B);
|
||||||
|
block_args_impl!(a: A, b: B, c: C);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K);
|
||||||
|
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct BlockBase<A, R> {
|
||||||
|
isa: *const Class,
|
||||||
|
flags: c_int,
|
||||||
|
_reserved: c_int,
|
||||||
|
invoke: unsafe extern "C" fn(*mut Block<A, R>, ...) -> R,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An Objective-C block that takes arguments of `A` when called and
|
||||||
|
/// returns a value of `R`.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct Block<A, R> {
|
||||||
|
_base: PhantomData<BlockBase<A, R>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A: BlockArguments, R> Block<A, R> where A: BlockArguments {
|
||||||
|
/// Call self with the given arguments.
|
||||||
|
///
|
||||||
|
/// Unsafe because this invokes foreign code that the caller must verify
|
||||||
|
/// doesn't violate any of Rust's safety rules. For example, if this block
|
||||||
|
/// is shared with multiple references, the caller must ensure that calling
|
||||||
|
/// it will not cause a data race.
|
||||||
|
pub unsafe fn call(&self, args: A) -> R {
|
||||||
|
args.call_block(self as *const _ as *mut _)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reference-counted Objective-C block.
|
||||||
|
pub struct RcBlock<A, R> {
|
||||||
|
ptr: *mut Block<A, R>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R> RcBlock<A, R> {
|
||||||
|
/// Construct an `RcBlock` for the given block without copying it.
|
||||||
|
/// The caller must ensure the block has a +1 reference count.
|
||||||
|
///
|
||||||
|
/// Unsafe because `ptr` must point to a valid `Block` and must have a +1
|
||||||
|
/// reference count or it will be overreleased when the `RcBlock` is
|
||||||
|
/// dropped.
|
||||||
|
pub unsafe fn new(ptr: *mut Block<A, R>) -> Self {
|
||||||
|
RcBlock { ptr: ptr }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs an `RcBlock` by copying the given block.
|
||||||
|
///
|
||||||
|
/// Unsafe because `ptr` must point to a valid `Block`.
|
||||||
|
pub unsafe fn copy(ptr: *mut Block<A, R>) -> Self {
|
||||||
|
let ptr = _Block_copy(ptr as *const c_void) as *mut Block<A, R>;
|
||||||
|
RcBlock { ptr: ptr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R> Clone for RcBlock<A, R> {
|
||||||
|
fn clone(&self) -> RcBlock<A, R> {
|
||||||
|
unsafe {
|
||||||
|
RcBlock::copy(self.ptr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R> Deref for RcBlock<A, R> {
|
||||||
|
type Target = Block<A, R>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Block<A, R> {
|
||||||
|
unsafe { &*self.ptr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R> Drop for RcBlock<A, R> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
_Block_release(self.ptr as *const c_void);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Types that may be converted into a `ConcreteBlock`.
|
||||||
|
pub trait IntoConcreteBlock<A>: Sized where A: BlockArguments {
|
||||||
|
/// The return type of the resulting `ConcreteBlock`.
|
||||||
|
type Ret;
|
||||||
|
|
||||||
|
/// Consumes self to create a `ConcreteBlock`.
|
||||||
|
fn into_concrete_block(self) -> ConcreteBlock<A, Self::Ret, Self>;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! concrete_block_impl {
|
||||||
|
($f:ident) => (
|
||||||
|
concrete_block_impl!($f,);
|
||||||
|
);
|
||||||
|
($f:ident, $($a:ident : $t:ident),*) => (
|
||||||
|
impl<$($t,)* R, X> IntoConcreteBlock<($($t,)*)> for X
|
||||||
|
where X: Fn($($t,)*) -> R {
|
||||||
|
type Ret = R;
|
||||||
|
|
||||||
|
fn into_concrete_block(self) -> ConcreteBlock<($($t,)*), R, X> {
|
||||||
|
unsafe extern "C" fn $f<$($t,)* R, X>(
|
||||||
|
block_ptr: *mut ConcreteBlock<($($t,)*), R, X>
|
||||||
|
$(, $a: $t)*) -> R
|
||||||
|
where X: Fn($($t,)*) -> R {
|
||||||
|
let block = &*block_ptr;
|
||||||
|
(block.closure)($($a),*)
|
||||||
|
}
|
||||||
|
|
||||||
|
let f: unsafe extern "C" fn(*mut ConcreteBlock<($($t,)*), R, X> $(, $a: $t)*) -> R = $f;
|
||||||
|
unsafe {
|
||||||
|
ConcreteBlock::with_invoke(mem::transmute(f), self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args0);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args1, a: A);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args2, a: A, b: B);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args3, a: A, b: B, c: C);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args4, a: A, b: B, c: C, d: D);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args5, a: A, b: B, c: C, d: D, e: E);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args6, a: A, b: B, c: C, d: D, e: E, f: F);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args7, a: A, b: B, c: C, d: D, e: E, f: F, g: G);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args8, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args9, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args10, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args11, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K);
|
||||||
|
concrete_block_impl!(concrete_block_invoke_args12, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L);
|
||||||
|
|
||||||
|
/// An Objective-C block whose size is known at compile time and may be
|
||||||
|
/// constructed on the stack.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct ConcreteBlock<A, R, F> {
|
||||||
|
base: BlockBase<A, R>,
|
||||||
|
descriptor: Box<BlockDescriptor<ConcreteBlock<A, R, F>>>,
|
||||||
|
closure: F,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> ConcreteBlock<A, R, F>
|
||||||
|
where A: BlockArguments, F: IntoConcreteBlock<A, Ret=R> {
|
||||||
|
/// Constructs a `ConcreteBlock` with the given closure.
|
||||||
|
/// When the block is called, it will return the value that results from
|
||||||
|
/// calling the closure.
|
||||||
|
pub fn new(closure: F) -> Self {
|
||||||
|
closure.into_concrete_block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> ConcreteBlock<A, R, F> {
|
||||||
|
/// Constructs a `ConcreteBlock` with the given invoke function and closure.
|
||||||
|
/// Unsafe because the caller must ensure the invoke function takes the
|
||||||
|
/// correct arguments.
|
||||||
|
unsafe fn with_invoke(invoke: unsafe extern "C" fn(*mut Self, ...) -> R,
|
||||||
|
closure: F) -> Self {
|
||||||
|
ConcreteBlock {
|
||||||
|
base: BlockBase {
|
||||||
|
isa: &_NSConcreteStackBlock,
|
||||||
|
// 1 << 25 = BLOCK_HAS_COPY_DISPOSE
|
||||||
|
flags: 1 << 25,
|
||||||
|
_reserved: 0,
|
||||||
|
invoke: mem::transmute(invoke),
|
||||||
|
},
|
||||||
|
descriptor: Box::new(BlockDescriptor::new()),
|
||||||
|
closure: closure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> ConcreteBlock<A, R, F> where F: 'static {
|
||||||
|
/// Copy self onto the heap as an `RcBlock`.
|
||||||
|
pub fn copy(self) -> RcBlock<A, R> {
|
||||||
|
unsafe {
|
||||||
|
let mut block = self;
|
||||||
|
let copied = RcBlock::copy(&mut *block);
|
||||||
|
// At this point, our copy helper has been run so the block will
|
||||||
|
// be moved to the heap and we can forget the original block
|
||||||
|
// because the heap block will drop in our dispose helper.
|
||||||
|
mem::forget(block);
|
||||||
|
copied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> Clone for ConcreteBlock<A, R, F> where F: Clone {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
unsafe {
|
||||||
|
ConcreteBlock::with_invoke(mem::transmute(self.base.invoke),
|
||||||
|
self.closure.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> Deref for ConcreteBlock<A, R, F> {
|
||||||
|
type Target = Block<A, R>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Block<A, R> {
|
||||||
|
unsafe { &*(&self.base as *const _ as *const Block<A, R>) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, R, F> DerefMut for ConcreteBlock<A, R, F> {
|
||||||
|
fn deref_mut(&mut self) -> &mut Block<A, R> {
|
||||||
|
unsafe { &mut *(&mut self.base as *mut _ as *mut Block<A, R>) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" fn block_context_dispose<B>(block: &mut B) {
|
||||||
|
// Read the block onto the stack and let it drop
|
||||||
|
ptr::read(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" fn block_context_copy<B>(_dst: &mut B, _src: &B) {
|
||||||
|
// The runtime memmoves the src block into the dst block, nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct BlockDescriptor<B> {
|
||||||
|
_reserved: c_ulong,
|
||||||
|
block_size: c_ulong,
|
||||||
|
copy_helper: unsafe extern "C" fn(&mut B, &B),
|
||||||
|
dispose_helper: unsafe extern "C" fn(&mut B),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B> BlockDescriptor<B> {
|
||||||
|
fn new() -> BlockDescriptor<B> {
|
||||||
|
BlockDescriptor {
|
||||||
|
_reserved: 0,
|
||||||
|
block_size: mem::size_of::<B>() as c_ulong,
|
||||||
|
copy_helper: block_context_copy::<B>,
|
||||||
|
dispose_helper: block_context_dispose::<B>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use test_utils::*;
|
||||||
|
use super::{ConcreteBlock, RcBlock};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_block() {
|
||||||
|
let block = get_int_block_with(13);
|
||||||
|
unsafe {
|
||||||
|
assert!(block.call(()) == 13);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_block_args() {
|
||||||
|
let block = get_add_block_with(13);
|
||||||
|
unsafe {
|
||||||
|
assert!(block.call((2,)) == 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_block() {
|
||||||
|
let block = ConcreteBlock::new(|| 13);
|
||||||
|
let result = invoke_int_block(&block);
|
||||||
|
assert!(result == 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_block_args() {
|
||||||
|
let block = ConcreteBlock::new(|a: i32| a + 5);
|
||||||
|
let result = invoke_add_block(&block, 6);
|
||||||
|
assert!(result == 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_concrete_block_copy() {
|
||||||
|
let s = "Hello!".to_string();
|
||||||
|
let expected_len = s.len() as i32;
|
||||||
|
let block = ConcreteBlock::new(move || s.len() as i32);
|
||||||
|
assert!(invoke_int_block(&block) == expected_len);
|
||||||
|
|
||||||
|
let copied = block.copy();
|
||||||
|
assert!(invoke_int_block(&copied) == expected_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_concrete_block_stack_copy() {
|
||||||
|
fn make_block() -> RcBlock<(), i32> {
|
||||||
|
let x = 7;
|
||||||
|
let block = ConcreteBlock::new(move || x);
|
||||||
|
block.copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = make_block();
|
||||||
|
assert!(invoke_int_block(&block) == 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
extern crate objc_test_utils;
|
||||||
|
|
||||||
|
use {Block, RcBlock};
|
||||||
|
|
||||||
|
pub fn get_int_block_with(i: i32) -> RcBlock<(), i32> {
|
||||||
|
unsafe {
|
||||||
|
let ptr = objc_test_utils::get_int_block_with(i);
|
||||||
|
RcBlock::new(ptr as *mut _)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_add_block_with(i: i32) -> RcBlock<(i32,), i32> {
|
||||||
|
unsafe {
|
||||||
|
let ptr = objc_test_utils::get_add_block_with(i);
|
||||||
|
RcBlock::new(ptr as *mut _)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invoke_int_block(block: &Block<(), i32>) -> i32 {
|
||||||
|
let ptr = block as *const _;
|
||||||
|
unsafe {
|
||||||
|
objc_test_utils::invoke_int_block(ptr as *mut _)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invoke_add_block(block: &Block<(i32,), i32>, a: i32) -> i32 {
|
||||||
|
let ptr = block as *const _;
|
||||||
|
unsafe {
|
||||||
|
objc_test_utils::invoke_add_block(ptr as *mut _, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user