17 Commits
41 changed files with 7387 additions and 314 deletions
+42
View File
@@ -89,6 +89,20 @@ joining another user's trusted-device group.
Keeping these layers separate prevents discovery convenience from silently
becoming a synchronization trust decision.
### Federation Jam control
Jam is a third, deliberately narrow authority boundary. A host creates an
opaque `frid://j/...` runtime capability and remains the only node producing
audio. Other TUI peers use a dedicated Jam ALPN to submit the same portable
playback commands used by connected-device control and receive the host's
playback snapshot. They receive queue metadata, not audio.
Jam never exchanges trusted membership, likes, playlists, or listening
history. Volume remains local. Commands carry unique IDs and are retried until
the host acknowledges them, while inactive participants expire from the
runtime session. Regenerating the capability or restarting the host invalidates
the previous link.
## Discovery and direct communication
Furumi separates finding content from transferring it.
@@ -268,6 +282,31 @@ intended role: transforming current audio features into drawing commands. It
is not a plugin mechanism for accessing the library, network, or player
controls.
## Music-similarity indexing
Similarity search is an optional local capability and is disabled by default.
When enabled, a background pipeline downloads a selected ONNX model, verifies
its pinned SHA-256 digest, decodes durable local tracks, and stores normalized
embeddings in the library SQLite database. Embeddings are keyed by an exact
fingerprint of the model artifact and preprocessing profile. Old profile rows
remain available while a new profile is calculated, and the in-memory exact
cosine index switches only after the replacement profile is usable.
The SQLite rows are the canonical derived store. The in-memory index can be
discarded and rebuilt, and neither is required for import, browsing, or
playback. Remote/cache-only tracks are never scheduled for local embedding.
Federated similarity uses a separate versioned direct-stream protocol. With
explicit privacy consent, the requester sends only a normalized embedding and
its profile fingerprint to a bounded set of known peers. It does not publish
queries to the DHT. Each peer searches its own active local index and returns a
bounded metadata result with a compact embedding SimHash. The requester uses
that signature to suppress near-duplicate recordings across peers without
receiving every result vector. Fan-out, concurrency, message sizes, and
timeouts are bounded; incompatible profiles are rejected. This direct
peer-selection layer can later be replaced by DHT routing without changing
local storage or ranking.
## Persistence boundaries
Furumi stores different kinds of state according to their lifetime:
@@ -276,6 +315,7 @@ Furumi stores different kinds of state according to their lifetime:
| --- | --- | --- |
| Library, playlists, likes, history | SQLite | Durable local source of truth |
| Device operation log and replicas | SQLite | Offline synchronization |
| Versioned track embeddings | SQLite | Durable, locally rebuildable similarity data |
| Federation catalog cache | SQLite/cache | Faster network browsing |
| Audio and artwork cache | Filesystem cache | Reusable fetched data |
| Settings, keymap, identity | Platform config/data dirs | Node configuration |
@@ -307,6 +347,8 @@ The source tree follows the architectural responsibilities:
- `library/` owns the local catalog and import pipeline;
- `player/` owns audio playback and analysis;
- `similarity.rs` owns model acquisition, preprocessing, background indexing,
and the replaceable exact in-memory index;
- `federation/` owns DHT-facing search, peer catalogs, and audio exchange;
- `devices.rs` owns trusted-device replication and playback coordination;
- `app/` owns state transitions and runtime orchestration;
+72
View File
@@ -0,0 +1,72 @@
# Changelog
All notable changes to Furumi are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Optional offline music-similarity search for local tracks, backed by
versioned SQLite embeddings and a replaceable exact in-memory cosine index.
- Automatic SHA-256-verified download and local inference for the first ONNX
embedding model, with retained model/profile generations and background
backfilling of existing library tracks.
- Similarity settings for enablement, model/profile information, numeric worker
count, derived-data cleanup, processing progress, and federation privacy
consent.
- Track-seeded similarity search from the track-information popup, including
bounded federated queries to compatible known peers.
- The `furumi-fd/similarity/1` protocol in the visible protocol-version status.
### Changed
- Similarity wire types, bounds, validation, and stream framing now come from
the shared `music-dht 0.3.1` API so native, web, and future clients can
interoperate without sharing an embedding implementation.
- A similarity result page keeps the source track first as query context while
excluding it from the actual nearest-neighbor ranking, labels the mode as
`Search similar to`, and suppresses near-identical embeddings across releases
and federated peer responses.
- Preprocessing profiles now open a read-only details window describing their
audio selection, resampling, spectrogram, patching, and aggregation contract.
### Fixed
- Current-track information (`Shift+I`) now uses the enriched queue entry, so
it shows the same complete metadata and similarity action as `I` on that
track in the queue.
## [0.2.5] - 2026-08-02
### Added
- Command history navigation with the Up and Down arrow keys.
- A copy-friendly plain terminal view for connection tickets, device and Jam
invites, and track-sharing links.
- A configurable permanent music directory with write validation and an
optional safe migration of existing Furumi-managed files.
- Queue reordering for one track or a `Shift+V` selection with `Alt+K` and
`Alt+J`.
- Reproducible Nix/devenv tooling with Rust 1.97 and ALSA development files.
### Changed
- Sequential `a` actions now build one ordered play-next block instead of
reversing independently inserted tracks.
- Federated artist images and release covers are stored beside permanent music
in an `Artist/Release` directory tree.
### Fixed
- Running a development build next to another Furumi instance no longer lets
an MPRIS name collision disable terminal raw mode and freeze keyboard input.
- `nix develop` now keeps devenv state outside the read-only Nix store and
isolates Rust 1.97 build artifacts from other toolchains.
- Music-directory validation and migration now reject overlapping changes,
resolve canonical paths, and produce Windows-portable managed filenames.
[Unreleased]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.5...HEAD
[0.2.5]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.4...v0.2.5
Generated
+837 -141
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "furumi_tui"
version = "0.2.0"
version = "0.2.6"
edition = "2024"
rust-version = "1.97"
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"
# P2P federation: library index in a shared DHT + audio streaming between
# peers (same protocol as furumi-fd).
music-dht = { git = "https://gt.hexor.cy/ab/frid.git", rev = "8de7d1292708fa0b225e5a4a9d5ab4f0676202d3" }
music-dht = "0.3.1"
ratatui = "0.30.1"
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] }
rhai = { version = "1", features = ["sync"] }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
rustfft = "6.4.1"
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sha2 = "0.10.9"
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
toml = "1.1.2"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tract-onnx = "0.23.4"
unicode-width = "0.2.2"
[patch.crates-io]
# souvlaki 0.8.3 still depends on the unmaintained block 0.1.6. Keep its API
# intact while using an opaque inhabited FFI type accepted by current Rust.
block = { path = "vendor/block" }
[target.'cfg(target_os="macos")'.dependencies]
core-foundation = "0.10.1"
+31
View File
@@ -69,6 +69,13 @@ library management are included. Visualizations are runtime-loadable Rhai
scripts executed in a resource-limited sandbox, so they can be added or edited
without rebuilding the player.
Optional similarity search calculates versioned embeddings for local tracks
in the background and keeps them in SQLite. It works offline; after a separate
privacy consent it can also ask a bounded set of federation peers for matches.
The first selectable model is downloaded on demand and is licensed separately
by MTG under CC BY-NC-SA 4.0 (a proprietary license is also available from
MTG); Furumi itself remains WTFPL.
## Install
### macOS
@@ -114,6 +121,29 @@ Import a music directory from Furumi's command line:
Federation, trusted-device pairing, and key bindings are configured directly
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
Furumi is a Rust application built with:
@@ -121,6 +151,7 @@ Furumi is a Rust application built with:
- `ratatui` and `crossterm` for the cross-platform TUI;
- `rodio` for local audio playback;
- SQLite for the personal library and synchronization state;
- tract ONNX inference for optional local music embeddings;
- a dedicated DHT for decentralized discovery;
- iroh-based P2P streams for client-to-client communication;
- an offline-tolerant operation log for trusted-device synchronization;
+40
View File
@@ -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
View File
@@ -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
}
+33
View File
@@ -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 ];
};
};
};
}
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.97.0"
profile = "minimal"
components = ["clippy", "rust-src", "rustfmt"]
+6
View File
@@ -38,6 +38,8 @@ pub enum Action {
OpenCurrentTrackInfo,
QueueAddNext,
QueueAddLast,
MoveQueueUp,
MoveQueueDown,
/// Download the selected federated track(s) into the local library.
DownloadSelected,
RemoveFromQueue,
@@ -107,6 +109,8 @@ impl Action {
| Action::OpenListenHistory => Category::Playback,
Action::QueueAddNext
| Action::QueueAddLast
| Action::MoveQueueUp
| Action::MoveQueueDown
| Action::DownloadSelected
| Action::RemoveFromQueue
| Action::ClearQueue
@@ -193,6 +197,8 @@ impl Action {
Action::OpenCurrentTrackInfo => "Current track info".into(),
Action::QueueAddNext => "Queue: add next".into(),
Action::QueueAddLast => "Queue: add to end".into(),
Action::MoveQueueUp => "Queue: move track/selection up".into(),
Action::MoveQueueDown => "Queue: move track/selection down".into(),
Action::DownloadSelected => "Federation: download to library".into(),
Action::RemoveFromQueue => "Queue: remove selected".into(),
Action::ClearQueue => "Queue: clear".into(),
+74
View File
@@ -8,6 +8,7 @@ use crate::app::Runtime;
use crate::app::command::{self, Command, Parsed};
use crate::app::event::AppEvent;
use crate::app::state::{AppState, GlobalView, SearchState, Tab};
use crate::library::models::SearchResults;
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180);
const SEARCH_LIMIT: i64 = 12;
@@ -17,6 +18,16 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
match key.code {
KeyCode::Esc => cancel(state),
KeyCode::Enter => commit(state, runtime),
KeyCode::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.
KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state),
_ => {
@@ -87,6 +98,7 @@ fn set_view_cursor_zero(state: &mut AppState) {
/// spawned task only queries if it is still the latest after the debounce,
/// and the receiver drops responses that arrive out of date.
pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
state.search.similarity_source = None;
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
let query = state.search.query.clone();
if query.is_empty() {
@@ -135,6 +147,62 @@ 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.loading = true;
state.search.results = None;
state.search.fed_tracks.clear();
state.search.fed_artists.clear();
state.search.fed_loading = false;
state.active_tab = crate::app::state::Tab::Global;
if let Some(crate::app::state::GlobalView::Search { cursor }) = state.global.stack.last_mut() {
*cursor = 0;
} else {
state
.global
.stack
.push(crate::app::state::GlobalView::Search { cursor: 0 });
}
let similarity = Arc::clone(&runtime.similarity);
let tx = runtime.event_tx.clone();
let track_id = track.id;
let source_track = track.clone();
tokio::task::spawn_blocking(move || {
let result = similarity
.search_track(track_id, 49)
.map(|(matches, query)| {
let mut tracks = Vec::with_capacity(1 + matches.len());
tracks.push(source_track);
tracks.extend(matches.into_iter().map(|found| found.track));
(
SearchResults {
artists: Vec::new(),
releases: Vec::new(),
tracks,
},
query,
)
});
let (result, query) = match result {
Ok((results, query)) => (Ok(results), Some(query)),
Err(err) => (Err(format!("{err:#}")), None),
};
let _ = tx.send(AppEvent::SimilaritySearchLoaded { seq, result, query });
});
}
/// Refresh only the local-library half of an already open search.
///
/// Library/device sync notifications can arrive while federated search
@@ -142,6 +210,9 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
/// federation rows and bump the shared sequence, causing valid network
/// responses to be dropped or flicker away.
pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
if state.search.similarity_source.is_some() {
return;
}
let query = state.search.query.clone();
if query.is_empty() {
return;
@@ -162,7 +233,9 @@ pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
/// Enter: close the line. Live commands already took effect (their view
/// stays open); one-shot commands execute here.
fn commit(state: &mut AppState, runtime: &mut Runtime) {
let remembered = state.cmdline.input.as_str().to_string();
let parsed = command::parse(&state.cmdline.input);
state.cmdline.remember(&remembered);
close(state);
match parsed {
Parsed::Empty => {}
@@ -272,6 +345,7 @@ fn close(state: &mut AppState) {
state.cmdline.active = false;
state.cmdline.input.clear();
state.cmdline.live = false;
state.cmdline.begin_history_navigation();
}
/// Pop the live search view if this command-line session opened it.
+22
View File
@@ -32,6 +32,16 @@ pub enum AppEvent {
seq: u64,
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<SearchResults, String>,
query: Option<crate::similarity::QueryVector>,
},
SimilarityStatus(crate::similarity::SimilarityStatus),
/// `None` is emitted after clearing every stored embedding.
SimilarityProfileActivated(Option<String>),
/// Artwork loaded and decoded for the shared art cache.
ArtLoaded {
key: String,
@@ -56,6 +66,8 @@ pub enum AppEvent {
LocalContentIdsLoaded(Result<Vec<String>, String>),
/// Counts and storage footprint of the local library/database.
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
MusicDirectoryValidated(Result<std::path::PathBuf, String>),
MusicDirectoryChanged(Result<crate::library::MusicRelocationStats, String>),
ListenHistoryLoaded(Result<Vec<crate::library::ListenHistoryEntry>, String>),
/// One content id became available locally while the UI is open.
LocalContentAvailable {
@@ -166,4 +178,14 @@ pub enum AppEvent {
DevicePlayback(crate::devices::PlaybackSnapshot),
/// Playback command addressed to this device.
PlaybackCommand(crate::devices::PlaybackCommand),
/// Current lifecycle/status of the federation Jam.
JamStatus(crate::jam::JamStatus),
/// Host playback snapshot received by a Jam participant.
JamPlayback(crate::devices::PlaybackSnapshot),
/// Participant command accepted by this Jam host.
JamCommand(crate::devices::PlaybackCommand),
/// Result of creating/regenerating a Jam capability.
JamInvite(Result<String, String>),
/// Result of joining a Jam capability.
JamJoined(Result<String, String>),
}
+407 -20
View File
@@ -8,6 +8,7 @@ pub mod state;
pub mod update;
use std::io;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
@@ -38,7 +39,9 @@ pub struct Runtime {
pub event_tx: mpsc::UnboundedSender<AppEvent>,
pub library: Arc<Library>,
pub devices: Arc<crate::devices::DeviceSync>,
pub jam: Arc<crate::jam::JamManager>,
pub federation: Arc<crate::federation::Federation>,
pub similarity: Arc<crate::similarity::Manager>,
/// When the last Federation-tab status snapshot was requested.
pub fed_status_at: Option<std::time::Instant>,
pub library_network_refresh_at: Option<std::time::Instant>,
@@ -60,12 +63,15 @@ pub struct Runtime {
pub art_semaphore: Arc<tokio::sync::Semaphore>,
/// The terminal screen was externally disturbed and needs a full repaint.
pub force_redraw: bool,
/// The alternate screen is temporarily suspended for copy-friendly text.
pub plain_text_mode: bool,
/// Monotonic sequence for live search; stale responses are dropped.
pub search_seq: Arc<std::sync::atomic::AtomicU64>,
pub player: player::Controller,
pub player_start_pending: bool,
pub media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
pub last_media_push: Option<std::time::Instant>,
pub status_publisher: crate::status::Publisher,
}
#[derive(Debug, Clone, Copy)]
@@ -104,6 +110,20 @@ fn refresh_local_library_stats(runtime: &Runtime) {
});
}
pub(super) fn validate_music_directory(state: &mut AppState, runtime: &Runtime, path: PathBuf) {
if state.music_dir_changing {
state.status_message = Some("music directory change is already running".into());
return;
}
state.music_dir_changing = true;
state.status_message = Some("checking music directory write access…".into());
let tx = runtime.event_tx.clone();
tokio::task::spawn_blocking(move || {
let result = Library::validate_music_directory(&path).map_err(err_string);
let _ = tx.send(AppEvent::MusicDirectoryValidated(result));
});
}
fn spawn_artist_federation_enrichment(runtime: &Runtime, id: i64, name: String) {
let fed = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
@@ -242,13 +262,29 @@ pub async fn run(
};
state.player.volume = settings.volume;
state.global.filters = settings.library;
state.music_dir = settings.music_dir.clone();
state.similarity.settings = settings.similarity.clone();
if let Err(err) = state.visualizer.load_library() {
state.status_message = Some(format!("visualizations disabled: {err:#}"));
}
let devices = crate::devices::DeviceSync::new(Arc::clone(&library))?;
devices.set_event_tx(event_tx.clone());
let federation = crate::federation::Federation::new(Arc::clone(&library), Arc::clone(&devices));
let jam = crate::jam::JamManager::new(event_tx.clone());
let similarity = crate::similarity::Manager::new(
Arc::clone(&library),
event_tx.clone(),
settings.similarity.clone(),
);
state.similarity.status = similarity.status();
let federation = crate::federation::Federation::new(
Arc::clone(&library),
Arc::clone(&devices),
Arc::clone(&jam),
Arc::clone(&similarity),
settings.music_dir.clone(),
);
state.music_dir = federation.media_dir();
state.federation.settings = federation.settings();
state.federation.devices = Some(devices.status());
if let Ok((device_id, device_name)) = devices.identity_summary() {
@@ -256,19 +292,22 @@ pub async fn run(
state.device_playback.self_device_name = device_name.clone();
state.device_playback.active_device_id = Some(device_id);
state.device_playback.active_device_name = Some(device_name);
state.device_playback.startup_takeover_pending = true;
}
let player_events = event_tx.clone();
let mut runtime = Runtime {
event_tx,
library,
devices,
jam,
federation,
similarity,
fed_status_at: None,
library_network_refresh_at: None,
library_network_refreshing: Arc::new(std::sync::atomic::AtomicBool::new(false)),
library_network_cursors: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
library_network_done: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
library_network_mode: crate::config::settings::LibrarySourceMode::Local,
library_network_mode: state.global.filters.source_mode,
library_network_art_fetching: Arc::new(std::sync::atomic::AtomicBool::new(false)),
library_network_art_attempted: Arc::new(std::sync::Mutex::new(
std::collections::HashSet::new(),
@@ -279,6 +318,7 @@ pub async fn run(
fed_streaming: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
force_redraw: false,
plain_text_mode: false,
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
player: player::spawn(move |event| {
let _ = player_events.send(AppEvent::Player(event));
@@ -286,8 +326,12 @@ pub async fn run(
player_start_pending: false,
media_tx,
last_media_push: None,
status_publisher: crate::status::Publisher::spawn(),
};
spawn_content_id_backfill(&runtime);
if state.similarity.settings.enabled {
runtime.similarity.start();
}
{
let fed = Arc::clone(&runtime.federation);
@@ -308,11 +352,30 @@ pub async fn run(
visual_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
if runtime.force_redraw {
terminal.clear()?;
runtime.force_redraw = false;
let plain_text = match state.popup.as_ref() {
Some(state::Popup::PlainText { text }) => Some(text.as_str()),
_ => None,
};
match (runtime.plain_text_mode, plain_text) {
(false, Some(text)) => {
enter_plain_text_mode(text)?;
runtime.plain_text_mode = true;
}
(true, None) => {
leave_plain_text_mode()?;
runtime.plain_text_mode = false;
runtime.force_redraw = true;
}
_ => {}
}
if !runtime.plain_text_mode {
if runtime.force_redraw {
terminal.clear()?;
runtime.force_redraw = false;
}
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
}
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
tokio::select! {
maybe_event = input.next() => match maybe_event {
@@ -325,6 +388,9 @@ pub async fn run(
state.advance_spinner();
expire_quit_confirmation(&mut state);
sync_player_shared(&mut state, &runtime);
runtime
.status_publisher
.publish(crate::status::PlaybackStatus::from_player(&state.player));
maybe_prefetch_next(&mut state, &runtime);
push_media_update(&state, &mut runtime, false);
}
@@ -334,6 +400,10 @@ pub async fn run(
}
if state.should_quit {
if runtime.plain_text_mode {
leave_plain_text_mode()?;
runtime.plain_text_mode = false;
}
state.shutting_down = true;
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
runtime.federation.shutdown().await;
@@ -343,6 +413,32 @@ pub async fn run(
}
}
fn enter_plain_text_mode(text: &str) -> Result<()> {
let text: String = text
.chars()
.filter(|character| !character.is_control())
.collect();
crossterm::execute!(
io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableBracketedPaste,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::style::Print(text)
)?;
io::stdout().flush()?;
Ok(())
}
fn leave_plain_text_mode() -> Result<()> {
crossterm::execute!(
io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableBracketedPaste
)?;
Ok(())
}
fn sync_player_shared(state: &mut AppState, runtime: &Runtime) {
if state.device_playback.is_control() {
extrapolate_control_position(state);
@@ -355,7 +451,7 @@ fn sync_player_shared(state: &mut AppState, runtime: &Runtime) {
} else {
runtime.player.shared.audio_analysis()
};
if state.device_playback.role == state::DevicePlaybackRole::Active {
if state.device_playback.is_audio_owner() {
publish_playback_snapshot(state, runtime);
}
}
@@ -440,6 +536,7 @@ fn apply_playback_state_to_ui(
.filter(|track| update::track_allowed_by_source_mode(state, track))
.collect();
state.player.queue_pos = queue_pos.min(state.player.queue.len().saturating_sub(1));
state.player.play_next_end = None;
state.player.playing = wire.playing && !state.player.queue.is_empty();
state.player.paused = wire.paused;
state.device_playback.local_idle_since_ms = if state.player.playing && !state.player.paused {
@@ -469,7 +566,7 @@ fn apply_playback_state_to_ui(
}
fn publish_playback_snapshot(state: &mut AppState, runtime: &Runtime) {
if state.device_playback.role != state::DevicePlaybackRole::Active {
if !state.device_playback.is_audio_owner() {
return;
}
publish_playback_snapshot_with_active(state, runtime, true);
@@ -498,6 +595,19 @@ fn publish_playback_snapshot_with_active(state: &mut AppState, runtime: &Runtime
state: playback_state_from_ui(state),
};
runtime.devices.publish_playback(snapshot);
if state.device_playback.role == state::DevicePlaybackRole::Jam
&& state.device_playback.jam_host
{
runtime
.jam
.publish_host_playback(crate::devices::PlaybackSnapshot {
device_id: state.device_playback.self_device_id.clone(),
device_name: state.device_playback.self_device_name.clone(),
active: true,
updated_at_ms: unix_time_ms(),
state: playback_state_from_ui(state),
});
}
}
fn update_local_idle_since(state: &mut AppState) {
@@ -527,7 +637,7 @@ fn active_idle_lease_expired(snapshot: &crate::devices::PlaybackSnapshot, now: i
}
fn local_active_lease_protected(state: &mut AppState, now: i64) -> bool {
if state.device_playback.role != state::DevicePlaybackRole::Active || !state.player.playing {
if !state.device_playback.is_audio_owner() || !state.player.playing {
return false;
}
if !state.player.paused {
@@ -571,7 +681,7 @@ pub(crate) fn become_control_device(
runtime: &Runtime,
snapshot: crate::devices::PlaybackSnapshot,
) {
if state.device_playback.role == state::DevicePlaybackRole::Active {
if state.device_playback.is_audio_owner() {
runtime.player.stop();
publish_inactive_playback_snapshot(state, runtime);
}
@@ -591,6 +701,7 @@ pub(crate) fn become_control_device(
pub(crate) fn become_active_device(state: &mut AppState, runtime: &mut Runtime, start_audio: bool) {
let was_control = state.device_playback.role == state::DevicePlaybackRole::Control;
state.device_playback.role = state::DevicePlaybackRole::Active;
state.device_playback.jam_host = false;
let Ok((device_id, device_name)) = runtime.devices.identity_summary() else {
return;
};
@@ -612,7 +723,7 @@ pub(crate) fn become_active_device(state: &mut AppState, runtime: &mut Runtime,
}
pub(crate) fn transfer_active_to_this_device(state: &mut AppState, runtime: &mut Runtime) {
if state.device_playback.role == state::DevicePlaybackRole::Active {
if state.device_playback.is_audio_owner() {
publish_playback_snapshot(state, runtime);
request_urgent_device_sync(runtime);
return;
@@ -717,6 +828,12 @@ fn record_control_playback_state(state: &mut AppState, runtime: &Runtime, seek:
state: playback_state_from_ui(state),
seek,
};
if state.device_playback.role == state::DevicePlaybackRole::Jam {
if let Err(err) = runtime.jam.submit_command(command) {
state.status_message = Some(format!("Jam command failed: {err:#}"));
}
return;
}
record_playback_command_async(runtime, target, command, "device command");
}
@@ -1298,6 +1415,14 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
}
Effect::SetOptions => {}
Effect::PlaybackQueueChanged => {}
Effect::QueueOrderChanged { restart_current } => {
if restart_current && state.player.playing && state.player.current.is_some() {
let paused = state.player.paused;
start_current_audio(state, runtime, state.player.position_secs, paused);
push_media_metadata(state, runtime);
push_media_update(state, runtime, true);
}
}
Effect::SourceModeChanged => {
runtime.library_network_refresh_at = None;
if let Ok(mut cursors) = runtime.library_network_cursors.lock() {
@@ -1316,6 +1441,30 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
perform_effect(state, runtime, effect);
}
}
Effect::ChangeMusicDirectory {
path,
move_existing,
} => {
if state.music_dir_changing {
state.status_message = Some("music directory change is already running".into());
return;
}
state.music_dir_changing = true;
state.status_message = Some(if move_existing {
"moving saved music to the new directory…".into()
} else {
"changing music save directory…".into()
});
let federation = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = federation
.change_media_dir(path, move_existing)
.await
.map_err(err_string);
let _ = tx.send(AppEvent::MusicDirectoryChanged(result));
});
}
Effect::EnqueueRelease { id, next } => {
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
@@ -1445,6 +1594,15 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
let _ = tx.send(event);
});
}
Effect::SimilarityApplySettings => {
save_app_settings(state);
runtime.similarity.apply(state.similarity.settings.clone());
state.similarity.status = runtime.similarity.status();
}
Effect::SimilarityClear => {
state.status_message = Some("clearing stored embeddings…".to_string());
runtime.similarity.clear();
}
Effect::FedApplySettings => fed_apply_settings(state, runtime),
Effect::FedSyncNow => {
state.federation.publishing = true;
@@ -1474,10 +1632,41 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
| Effect::DeviceSetName(_)
| Effect::DeviceRevoke(_)
| Effect::DeviceLeaveGroup
| Effect::JamCreate
| Effect::JamJoin(_)
if !state.connected_devices_enabled() =>
{
state.status_message = Some("enable federation before using connected devices".into());
}
Effect::JamCreate => {
let federation = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = federation
.create_jam()
.await
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::JamInvite(result));
});
}
Effect::JamJoin(invite) => {
let result = runtime
.federation
.join_jam(&invite)
.map(|()| "joined Jam; waiting for host state".to_string())
.map_err(|err| format!("{err:#}"));
let _ = runtime.event_tx.send(AppEvent::JamJoined(result));
let _ = runtime
.event_tx
.send(AppEvent::JamStatus(runtime.jam.status()));
}
Effect::JamLeave => {
runtime.jam.leave();
state.jam = runtime.jam.status();
state.device_playback.role = state::DevicePlaybackRole::Active;
state.device_playback.jam_host = false;
state.status_message = Some("left Jam".into());
}
Effect::DeviceShowInvite => {
let fed = Arc::clone(&runtime.federation);
let devices = Arc::clone(&runtime.devices);
@@ -1669,11 +1858,14 @@ fn is_controlled_playback_effect(effect: &Effect) -> bool {
| Effect::SetOptions
| Effect::RemoveQueueIndices { .. }
| Effect::PlaybackQueueChanged
| Effect::QueueOrderChanged { .. }
)
}
fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
let mut seek = false;
let local_only_volume = state.device_playback.role == state::DevicePlaybackRole::Jam
&& matches!(effect, Effect::SetVolume(_));
match effect {
Effect::PlayCurrent => {
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
@@ -1700,9 +1892,14 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime,
Effect::SetOptions
| Effect::RemoveQueueIndices { .. }
| Effect::PlaybackQueueChanged
| Effect::LoadListenHistory => {}
| Effect::QueueOrderChanged { .. }
| Effect::LoadListenHistory
| Effect::ChangeMusicDirectory { .. } => {}
_ => {}
}
if local_only_volume {
return;
}
record_control_playback_state(state, runtime, seek);
}
@@ -2609,6 +2806,8 @@ fn save_app_settings(state: &AppState) {
let settings = crate::config::settings::AppSettings {
volume: state.player.volume,
library: state.global.filters,
music_dir: state.music_dir.clone(),
similarity: state.similarity.settings.clone(),
};
if let Err(err) = crate::config::settings::save(&settings) {
tracing::warn!(%err, "saving app settings failed");
@@ -2716,6 +2915,12 @@ fn handle_device_playback_snapshot(
runtime: &mut Runtime,
snapshot: crate::devices::PlaybackSnapshot,
) {
// Personal-device reconciliation must never change Jam ownership. Jam
// has its own authority and lifecycle even when the same TUI also belongs
// to a trusted-device group.
if state.device_playback.role == state::DevicePlaybackRole::Jam {
return;
}
if snapshot.device_id == state.device_playback.self_device_id {
return;
}
@@ -2737,6 +2942,18 @@ fn handle_device_playback_snapshot(
if !snapshot.active {
return;
}
// Starting a player is an explicit claim of the active role. Import the
// current queue/position from the previously active peer, then announce a
// normal handoff so that the old owner becomes a control device. This is
// intentionally one-shot: subsequent snapshots use the regular lease and
// explicit-transfer rules.
if state.device_playback.startup_takeover_pending {
state.device_playback.startup_takeover_pending = false;
become_control_device(state, runtime, snapshot);
transfer_active_to_this_device(state, runtime);
state.status_message = Some("playback moved to this newly started player".to_string());
return;
}
if local_active_lease_protected(state, now) {
tracing::debug!(
remote = %snapshot.device_id,
@@ -2745,10 +2962,10 @@ fn handle_device_playback_snapshot(
return;
}
let lease_expired = active_idle_lease_expired(&snapshot, now);
let already_controls_this_device = state.device_playback.is_control()
let already_controls_this_device = state.device_playback.is_personal_control()
&& state.device_playback.active_device_id.as_deref() == Some(snapshot.device_id.as_str());
if !lease_expired || already_controls_this_device {
let was_active = state.device_playback.role == state::DevicePlaybackRole::Active;
let was_active = state.device_playback.is_audio_owner();
let was_paused = state.player.playing && state.player.paused;
become_control_device(state, runtime, snapshot.clone());
if was_active && was_paused {
@@ -2760,7 +2977,7 @@ fn handle_device_playback_snapshot(
return;
}
if state.device_playback.is_control() {
if state.device_playback.is_personal_control() {
return;
}
become_active_device(state, runtime, false);
@@ -2824,7 +3041,7 @@ fn handle_playback_command(
if active_device_id == state.device_playback.self_device_id {
return;
}
let was_active = state.device_playback.role == state::DevicePlaybackRole::Active;
let was_active = state.device_playback.is_audio_owner();
let snapshot = crate::devices::PlaybackSnapshot {
device_id: active_device_id,
device_name: active_device_name,
@@ -2852,6 +3069,48 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
Err(err) => state::Loadable::Failed(err),
});
}
AppEvent::MusicDirectoryValidated(result) => {
state.music_dir_changing = false;
match result {
Ok(path) => {
let current = std::fs::canonicalize(&state.music_dir)
.unwrap_or_else(|_| state.music_dir.clone());
if path == current {
state.status_message =
Some("this is already the music save directory".into());
} else {
state.popup = Some(state::Popup::ConfirmMusicDirectory { path });
state.status_message = None;
}
}
Err(message) => {
state.status_message = Some(format!(
"music directory is not writable; nothing changed: {message}"
));
}
}
}
AppEvent::MusicDirectoryChanged(result) => {
state.music_dir_changing = false;
match result {
Ok(stats) => {
state.music_dir = runtime.federation.media_dir();
save_app_settings(state);
state.status_message = Some(format!(
"music directory changed · moved {} track(s), {} image(s)",
stats.tracks, stats.images
));
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
message: state.status_message.clone(),
});
}
Err(message) => {
state.status_message = Some(format!(
"music directory change failed; old library kept: {message}"
));
}
}
}
AppEvent::FederationStatus(status) => {
state.federation.status = Some(status);
}
@@ -2874,7 +3133,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
})
.unwrap_or(1)
.max(1);
let active_revoked = state.device_playback.is_control()
let active_revoked = state.device_playback.is_personal_control()
&& state
.device_playback
.active_device_id
@@ -2892,7 +3151,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
})
.is_some_and(|device| device.revoked)
});
let active_missing = state.device_playback.is_control()
let active_missing = state.device_playback.is_personal_control()
&& state
.device_playback
.active_device_id
@@ -2931,6 +3190,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
title: "Device invite".to_string(),
text: invite,
help: "Use this invite on another client within 10 minutes to pair it with this device group.".to_string(),
cursor: 0,
});
state.status_message = Some("device invite generated".to_string());
}
@@ -2954,9 +3214,92 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
AppEvent::DevicePlayback(snapshot) => {
handle_device_playback_snapshot(state, runtime, snapshot);
}
AppEvent::PlaybackCommand(_)
if state.device_playback.role == state::DevicePlaybackRole::Jam =>
{
tracing::debug!("ignored personal-device playback command while Jam is active");
}
AppEvent::PlaybackCommand(command) => {
handle_playback_command(state, runtime, command);
}
AppEvent::JamStatus(status) => {
state.jam = status.clone();
match status.role {
crate::jam::JamRole::Host => {
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = true;
publish_playback_snapshot(state, runtime);
}
crate::jam::JamRole::Participant => {
if state.device_playback.is_audio_owner() {
runtime.player.stop();
runtime.player_start_pending = false;
}
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = false;
}
crate::jam::JamRole::None => {
if state.device_playback.role == state::DevicePlaybackRole::Jam {
state.device_playback.role = state::DevicePlaybackRole::Active;
state.device_playback.jam_host = false;
}
}
}
}
AppEvent::JamPlayback(snapshot) => {
let local_volume = state.player.volume;
become_control_device(state, runtime, snapshot);
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = false;
state.player.volume = local_volume;
state.status_message = Some(format!(
"Jam · controlling {}",
state.device_playback.active_label()
));
}
AppEvent::JamCommand(command) => {
let command = match command {
crate::devices::PlaybackCommand::SetState {
state: mut wire,
seek,
} => {
wire.volume = state.player.volume;
crate::devices::PlaybackCommand::SetState { state: wire, seek }
}
crate::devices::PlaybackCommand::ActiveChanged { .. } => {
state.status_message =
Some("Jam cannot transfer audio away from its host".into());
return;
}
};
handle_playback_command(state, runtime, command);
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = true;
publish_playback_snapshot(state, runtime);
}
AppEvent::JamInvite(result) => match result {
Ok(invite) => {
if !state.device_playback.is_audio_owner() {
transfer_active_to_this_device(state, runtime);
}
state.jam = runtime.jam.status();
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = true;
publish_playback_snapshot(state, runtime);
state.popup = Some(state::Popup::FedCopyText {
title: "Jam invite".to_string(),
text: invite,
help: "Copied capability lets federation peers control this host player until restart or regeneration.".to_string(),
cursor: 0,
});
state.status_message = Some("Jam started".into());
}
Err(error) => state.status_message = Some(format!("Jam: {error}")),
},
AppEvent::JamJoined(result) => match result {
Ok(message) => state.status_message = Some(message),
Err(error) => state.status_message = Some(format!("Jam: {error}")),
},
AppEvent::FedSearchLoaded { seq, result } => {
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
return;
@@ -3104,6 +3447,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
// Skip the failed track instead of stalling the queue.
if state.player.queue_pos + 1 < state.player.queue.len() {
state.player.queue_pos += 1;
update::normalize_play_next_block(&mut state.player);
start_current_audio(state, runtime, 0.0, state.player.paused);
push_media_metadata(state, runtime);
push_media_update(state, runtime, true);
@@ -3205,6 +3549,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
text: ticket,
help: "Copy this ticket and paste it into Connect to a peer on another client."
.to_string(),
cursor: 0,
});
}
Err(message) => state.status_message = Some(message),
@@ -3310,6 +3655,40 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
Err(message) => state.status_message = Some(message),
}
}
AppEvent::SimilaritySearchLoaded { seq, result, query } => {
if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
state.search.loading = false;
match result {
Ok(results) => state.search.results = Some(results),
Err(message) => {
state.status_message = Some(format!("similarity search failed: {message}"));
return;
}
}
if let Some(query) = query
&& state.federation.settings.enabled
&& runtime.similarity.network_allowed()
{
state.search.fed_loading = true;
let federation = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = federation
.search_similar(query, 50)
.await
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::FedSearchLoaded { seq, result });
});
}
}
AppEvent::SimilarityStatus(status) => state.similarity.status = status,
AppEvent::SimilarityProfileActivated(profile_id) => {
state.similarity.settings.active_profile = profile_id;
state.similarity.status = runtime.similarity.status();
save_app_settings(state);
}
AppEvent::ArtLoaded { key, art } => {
let entry = match art {
Some(image) => state::ArtState::Ready(image),
@@ -3347,6 +3726,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
.take()
.unwrap_or(state.player.queue_pos + 1);
state.player.queue_pos = next_pos.min(state.player.queue.len().saturating_sub(1));
update::normalize_play_next_block(&mut state.player);
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
state.player.position_secs = 0.0;
state.player.track_started_at = Some(now_epoch_seconds());
@@ -3479,9 +3859,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
}
AppEvent::EnqueueTracks { tracks, next } => {
let previous_len = state.player.queue.len();
update::enqueue_tracks(state, tracks, next);
let restart_current = update::enqueue_tracks(state, tracks, next);
let count = state.player.queue.len().saturating_sub(previous_len);
record_control_playback_state(state, runtime, false);
perform_effect(
state,
runtime,
Effect::QueueOrderChanged { restart_current },
);
state.status_message = Some(if count == 0 {
"no tracks available in the current source mode".to_string()
} else if next {
@@ -3530,6 +3914,9 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
}
AppEvent::LibraryChanged { message } => {
on_library_changed(state, runtime);
if state.similarity.settings.enabled {
runtime.similarity.start();
}
if let Some(message) = message {
state.status_message = Some(message);
}
+193 -8
View File
@@ -82,7 +82,7 @@ pub(crate) fn connected_device_rows(state: &AppState) -> Vec<ConnectedDevicePopu
is_self: true,
online: true,
section: DevicePresenceSection::Online,
active: state.device_playback.role == crate::app::state::DevicePlaybackRole::Active,
active: state.device_playback.is_audio_owner(),
playing: state.player.playing,
paused: state.player.paused,
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 } => {
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::TrackInfo {
tracks,
@@ -136,17 +169,52 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
_ => 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::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) {
Ok(()) => state.status_message = Some("copied to clipboard".to_string()),
Err(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 {
focus,
status_cursor,
@@ -386,6 +454,32 @@ fn handle_connected_devices(
let cursor = cursor.min(last);
match key.code {
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') => {
state.popup = Some(Popup::ConnectedDevices {
cursor: cursor.saturating_sub(1),
@@ -397,6 +491,12 @@ fn handle_connected_devices(
});
}
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 {
super::transfer_active_to_this_device(state, runtime);
state.status_message = Some("active playback moved to this device".into());
@@ -470,6 +570,28 @@ fn handle_fed_input(
!state.federation.settings.network_id.is_empty();
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::ConnectTicket => {
if value.is_empty() {
state.status_message = Some("ticket is empty".into());
@@ -499,6 +621,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),
);
}
}
}
}
_ => {
@@ -508,6 +641,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(
state: &mut AppState,
runtime: &Runtime,
@@ -891,10 +1049,15 @@ fn handle_track_info(
KeyCode::Char('c') => {
if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) {
match crate::share::track_share_link(track) {
Some(link) => match copy_to_clipboard(&link) {
Ok(()) => state.status_message = Some("frid link copied".into()),
Err(err) => state.status_message = Some(format!("copy failed: {err}")),
},
Some(link) => {
state.popup = Some(Popup::FedCopyText {
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()),
}
}
@@ -904,6 +1067,28 @@ fn handle_track_info(
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 {
tracks,
+178 -1
View File
@@ -389,6 +389,7 @@ mod tests {
featured_tracks: Vec::new(),
};
let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
state.artist_fed_views.insert(
detail.id,
Loadable::Ready(crate::federation::FedArtistCard {
@@ -686,6 +687,10 @@ pub enum Popup {
},
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
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.
LibraryFilters { cursor: usize },
/// Track metadata viewer; left/right switch between selected tracks.
@@ -716,7 +721,16 @@ pub enum Popup {
title: String,
text: 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.
DevicePairing {
request_id: String,
@@ -801,24 +815,36 @@ impl StatusDetailFocus {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedInputField {
MusicDirectory,
SimilarityWorkers,
NetworkId,
ConnectTicket,
DeviceName,
ConnectInvite,
JamInvite,
}
impl FedInputField {
pub fn title(self) -> &'static str {
match self {
FedInputField::MusicDirectory => "Music save directory",
FedInputField::SimilarityWorkers => "Similarity background workers",
FedInputField::NetworkId => "Network ID",
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
FedInputField::DeviceName => "Device name",
FedInputField::ConnectInvite => "Connect device (paste frid://i invite)",
FedInputField::JamInvite => "Join Jam (paste frid://j invite)",
}
}
pub fn help(self) -> &'static str {
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::NetworkId => {
"A unique network id. It must match exactly on every client that should see and connect to the same peers."
}
@@ -831,6 +857,9 @@ impl FedInputField {
FedInputField::ConnectInvite => {
"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."
}
}
}
}
@@ -846,6 +875,25 @@ pub enum FedRow {
Connect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SimilarityRow {
Toggle,
Model,
Profile,
Workers,
Clear,
}
impl SimilarityRow {
pub const ALL: [SimilarityRow; 5] = [
SimilarityRow::Toggle,
SimilarityRow::Model,
SimilarityRow::Profile,
SimilarityRow::Workers,
SimilarityRow::Clear,
];
}
impl FedRow {
pub const ALL: [FedRow; 6] = [
FedRow::Toggle,
@@ -862,6 +910,8 @@ impl FedRow {
/// the config directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
MusicDirectory,
Similarity(SimilarityRow),
Federation(FedRow),
StatusDetails,
DeviceName,
@@ -999,7 +1049,8 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
}
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.push(SettingsRow::DeviceName);
rows.push(SettingsRow::DeviceInvite);
@@ -1038,6 +1089,12 @@ pub struct FederationTab {
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
/// through likes, not direct adds).
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
@@ -1059,6 +1116,98 @@ pub struct Cmdline {
/// A live command (search) applied effects during this session; Esc
/// undoes them, Enter keeps them.
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.
@@ -1074,6 +1223,9 @@ pub struct SearchState {
/// from the artist names of matching tracks.
pub fed_artists: Vec<crate::federation::FedArtistHit>,
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>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -1164,6 +1316,9 @@ impl RepeatMode {
pub struct PlayerBar {
pub queue: Vec<TrackItem>,
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>,
/// A track is loaded (playing or paused); false = stopped.
pub playing: bool,
@@ -1189,6 +1344,7 @@ impl Default for PlayerBar {
Self {
queue: Vec::new(),
queue_pos: 0,
play_next_end: None,
current: None,
playing: false,
paused: false,
@@ -1210,6 +1366,7 @@ pub enum DevicePlaybackRole {
#[default]
Active,
Control,
Jam,
}
impl DevicePlaybackRole {
@@ -1217,6 +1374,7 @@ impl DevicePlaybackRole {
match self {
DevicePlaybackRole::Active => "active",
DevicePlaybackRole::Control => "control",
DevicePlaybackRole::Jam => "jam",
}
}
}
@@ -1232,11 +1390,25 @@ pub struct DevicePlaybackState {
pub local_idle_since_ms: Option<i64>,
pub remote: BTreeMap<String, 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 {
pub fn is_control(&self) -> bool {
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 {
@@ -1262,8 +1434,12 @@ pub struct AppState {
pub status_message: Option<String>,
pub spinner_frame: 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 device_playback: DevicePlaybackState,
pub jam: crate::jam::JamStatus,
pub visualizer: crate::visualizer::VisualizerState,
pub global: GlobalTab,
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
@@ -1286,6 +1462,7 @@ pub struct AppState {
pub logs: LogsTab,
pub queue_tab: QueueTab,
pub federation: FederationTab,
pub similarity: SimilarityTab,
/// The one federated artist card being viewed (name + loading state);
/// opening another card replaces it.
pub fed_artist_view: Option<(String, Loadable<crate::federation::FedArtistCard>)>,
+224 -14
View File
@@ -48,10 +48,24 @@ pub enum Effect {
},
/// Queue/options changed without a direct audio engine action.
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.
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.
FedApplySettings,
/// Persist/apply embedding model, profile, worker or enable changes.
SimilarityApplySettings,
SimilarityClear,
/// Force an immediate library publish into the DHT.
FedSyncNow,
/// Fetch this peer's ticket and show it in a popup.
@@ -68,6 +82,12 @@ pub enum Effect {
DeviceRevoke(String),
/// Leave the current personal-device group after publishing self-revoke.
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).
FedOpenArtist(String),
/// Download federated tracks into the local library, one by one.
@@ -174,6 +194,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
Action::ToggleShuffle => {
state.player.shuffle = !state.player.shuffle;
state.player.play_next_end = None;
// Shuffle physically reorders the unplayed tail, so the Queue
// tab always shows the real upcoming order; turning it off
// restores the original ordering.
@@ -269,6 +290,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
Action::OpenCommandLine => {
state.cmdline.active = true;
state.cmdline.input.clear();
state.cmdline.begin_history_navigation();
None
}
Action::OpenSearch => {
@@ -277,6 +299,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
state.cmdline.active = true;
state.cmdline.input = crate::app::input::LineEdit::new("/");
state.cmdline.live = true;
state.cmdline.begin_history_navigation();
state.search = SearchState::default();
state.active_tab = Tab::Global;
if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
@@ -359,12 +382,14 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
open_track_info(state, tracks, "no track selected")
}
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")
}
Action::RemoveFromQueue => remove_selected_from_queue(state),
Action::QueueAddNext => queue_add(state, true),
Action::QueueAddLast => queue_add(state, false),
Action::MoveQueueUp => move_selected_queue(state, true),
Action::MoveQueueDown => move_selected_queue(state, false),
Action::GoToRelease => {
let track = selected_track(state).or_else(|| state.player.current.clone());
match track {
@@ -412,6 +437,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
let had_tracks = !state.player.queue.is_empty();
state.player.queue.clear();
state.player.queue_pos = 0;
state.player.play_next_end = None;
state.player.current = None;
state.player.playing = false;
state.player.paused = false;
@@ -434,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 {
track.fed.is_some()
&& (track.featured_artists.is_empty()
@@ -1024,6 +1075,68 @@ fn selected_queue_indices(state: &AppState) -> Vec<usize> {
.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> {
let indices = selected_queue_indices(state);
if indices.is_empty() {
@@ -1075,6 +1188,10 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
.iter()
.filter(|index| **index < old_queue_pos)
.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_paused = state.player.paused;
@@ -1101,6 +1218,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
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);
normalize_play_next_block(&mut state.player);
return QueueRemovalOutcome {
restart_paused: was_loaded.then_some(was_paused),
stop: false,
@@ -1128,6 +1246,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
.cloned()
});
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
normalize_play_next_block(&mut state.player);
QueueRemovalOutcome {
restart_paused: None,
stop: false,
@@ -1261,7 +1380,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
if !tracks.is_empty() {
let count = tracks.len();
let title = tracks[0].title.clone();
enqueue_tracks(state, tracks, next);
let restart_current = enqueue_tracks(state, tracks, next);
state.track_selection.clear();
state.status_message = Some(if count == 1 && next {
format!("queued next: {title}")
@@ -1272,7 +1391,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
} else {
format!("queued: {count} tracks")
});
return Some(Effect::PlaybackQueueChanged);
return Some(Effect::QueueOrderChanged { restart_current });
}
if let Some(id) = selected_release_id(state) {
return Some(Effect::EnqueueRelease { id, next });
@@ -1366,19 +1485,22 @@ pub(crate) fn track_artist_refs(track: &TrackItem) -> Vec<crate::library::models
refs
}
/// Insert tracks after the playing one (`next`) or at the end. Keeps the
/// gapless prefetch index pointing at the same track if items shift.
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) {
/// Insert tracks into the stable "play next" block (`next`) or at the end.
/// Returns whether a decoded gapless-next source became stale.
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) -> bool {
let tracks: Vec<_> = tracks
.into_iter()
.filter(|track| track_allowed_by_source_mode(state, track))
.collect();
let player = &mut state.player;
if tracks.is_empty() {
return;
return false;
}
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 {
0
} else {
@@ -1388,14 +1510,28 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
for (offset, track) in tracks.into_iter().enumerate() {
player.queue.insert(insert_at + offset, track);
}
if let Some(prefetched) = &mut player.prefetched_pos
&& insert_at <= *prefetched
{
*prefetched += count;
let restart_current = player
.prefetched_pos
.is_some_and(|prefetched| insert_at <= prefetched);
if restart_current {
player.prefetched_pos = None;
}
if insert_at <= player.queue_pos && player.current.is_some() {
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
@@ -1420,6 +1556,7 @@ fn queue_step(state: &mut AppState, direction: isize) -> Option<Effect> {
} else {
player.queue_pos = next as usize;
}
normalize_play_next_block(player);
Some(Effect::PlayCurrent)
}
@@ -1458,9 +1595,11 @@ pub fn advance_after_finish(state: &mut AppState) -> Option<Effect> {
repeat => {
if player.queue_pos + 1 < player.queue.len() {
player.queue_pos += 1;
normalize_play_next_block(player);
Some(Effect::PlayCurrent)
} else if repeat == super::state::RepeatMode::All {
player.queue_pos = 0;
player.play_next_end = None;
Some(Effect::PlayCurrent)
} else {
player.playing = false;
@@ -2070,6 +2209,7 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
return None;
}
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);
}
if state.active_tab != Tab::Global {
@@ -2608,8 +2748,67 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
/// Enter on Settings: toggle switches, open text inputs, run
/// one-shot operations. The heavy lifting happens in perform_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()? {
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::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) => {
let settings = &mut state.federation.settings;
if !settings.enabled && settings.network_id.trim().is_empty() {
@@ -2619,7 +2818,17 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
});
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)
}
SettingsRow::Federation(FedRow::NetworkId) => {
@@ -2783,6 +2992,7 @@ fn require_connected_devices_enabled(state: &mut AppState) -> bool {
pub(super) fn on_new_queue(state: &mut AppState) {
let player = &mut state.player;
player.original_order = None;
player.play_next_end = None;
if player.shuffle && !player.queue.is_empty() {
player.original_order = Some(player.queue.iter().map(track_key).collect());
shuffle_range(player, (player.queue_pos + 1).min(player.queue.len()));
+83 -7
View File
@@ -158,25 +158,25 @@ fn source_mode_cycles_on_library_playlists_and_queue_tabs() {
update(&mut state, Action::CycleSourceMode),
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;
assert_eq!(
update(&mut state, Action::CycleSourceMode),
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;
assert_eq!(
update(&mut state, Action::CycleSourceMode),
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;
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]
@@ -457,6 +457,7 @@ fn local_mode_hides_pending_federation_tracks_from_playlists_and_playback() {
active_tab: Tab::Playlists,
..AppState::default()
};
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 });
state.playlist_views.insert(
7,
@@ -512,6 +513,7 @@ fn network_modes_show_pending_federation_playlist_tracks() {
#[test]
fn local_mode_rejects_async_federation_queue_additions() {
let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
enqueue_tracks(
&mut state,
@@ -569,9 +571,15 @@ fn current_track_info_uses_now_playing_track() {
active_tab: Tab::Queue,
..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.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);
match &state.popup {
@@ -580,6 +588,9 @@ fn current_track_info_uses_now_playing_track() {
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
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:?}"),
}
@@ -732,13 +743,78 @@ fn artist_top_track_selection_queues_all_selected_tracks() {
update(&mut state, Action::MoveDown);
assert_eq!(
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();
assert_eq!(queued, vec![1, 2]);
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]
fn removing_current_queue_track_requests_paused_restart() {
let mut state = AppState {
+10
View File
@@ -63,6 +63,16 @@ command = "QueueAddNext"
key_sequence = "shift-a"
command = "QueueAddLast"
[[keymaps]]
key_sequence = "alt-k"
command = "MoveQueueUp"
context = "queue"
[[keymaps]]
key_sequence = "alt-j"
command = "MoveQueueDown"
context = "queue"
[[keymaps]]
key_sequence = "shift-c"
command = "OpenConnectedDevices"
+77 -2
View File
@@ -1,12 +1,13 @@
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LibrarySourceMode {
#[default]
Local,
My,
#[default]
Global,
}
@@ -44,12 +45,51 @@ pub struct LibraryFilters {
pub source_mode: LibrarySourceMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SimilaritySettings {
/// Local embedding/search master switch. Network participation follows
/// federation and additionally requires the explicit privacy consent.
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_similarity_model")]
pub model: String,
#[serde(default = "default_similarity_profile")]
pub profile: String,
#[serde(default = "default_similarity_workers")]
pub workers: usize,
#[serde(default)]
pub federation_consent: bool,
/// Exact fingerprint of the last fully usable profile. Keeping this
/// separate from the selected target lets an old index serve searches
/// while a newly selected model/profile is being calculated.
#[serde(default)]
pub active_profile: Option<String>,
}
impl Default for SimilaritySettings {
fn default() -> Self {
Self {
enabled: false,
model: default_similarity_model(),
profile: default_similarity_profile(),
workers: default_similarity_workers(),
federation_consent: false,
active_profile: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default = "default_volume")]
pub volume: u8,
#[serde(default)]
pub library: LibraryFilters,
/// Root used for music materialized from federation peers.
#[serde(default = "default_music_dir")]
pub music_dir: PathBuf,
#[serde(default)]
pub similarity: SimilaritySettings,
}
impl Default for AppSettings {
@@ -57,6 +97,8 @@ impl Default for AppSettings {
Self {
volume: default_volume(),
library: LibraryFilters::default(),
music_dir: default_music_dir(),
similarity: SimilaritySettings::default(),
}
}
}
@@ -64,6 +106,16 @@ impl Default for AppSettings {
impl AppSettings {
pub fn normalized(mut self) -> Self {
self.volume = self.volume.min(100);
if self.music_dir.as_os_str().is_empty() {
self.music_dir = default_music_dir();
}
if self.similarity.model.trim().is_empty() {
self.similarity.model = default_similarity_model();
}
if self.similarity.profile.trim().is_empty() {
self.similarity.profile = default_similarity_profile();
}
self.similarity.workers = self.similarity.workers.clamp(1, 16);
self
}
}
@@ -72,6 +124,28 @@ fn default_volume() -> u8 {
80
}
fn default_similarity_model() -> String {
"discogs-effnet-bsdynamic-1".to_string()
}
fn default_similarity_profile() -> String {
"furumi-full-track-v1".to_string()
}
fn default_similarity_workers() -> usize {
std::thread::available_parallelism()
.map(|count| (count.get() / 2).clamp(1, 4))
.unwrap_or(1)
}
/// The historical permanent-download location, kept as the default for
/// backward compatibility with existing installations.
pub fn default_music_dir() -> PathBuf {
crate::config::project_dirs()
.map(|dirs| dirs.data_dir().join("federation-media"))
.unwrap_or_else(|| PathBuf::from("federation-media"))
}
pub fn load() -> (AppSettings, Option<String>) {
let Some(path) = settings_path() else {
return (AppSettings::default(), None);
@@ -128,6 +202,7 @@ hide_featured_only = true
assert_eq!(settings.volume, 100);
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());
}
}
+60 -23
View File
@@ -24,13 +24,16 @@ use crate::library::models::{ArtistRef, TrackItem};
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const PROTOCOL_VERSION: u16 = 2;
pub const PROTOCOL_VERSION: u16 = 2;
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
const SYNC_INTERVAL: Duration = Duration::from_secs(2);
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;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -1249,8 +1252,7 @@ impl DeviceSync {
device: &StoredDevice,
transport_stats: Arc<crate::federation::TransportStats>,
) -> Result<()> {
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
let peer = service.connect(ticket).await?;
let peer = resolve_device_peer(&service, device).await?;
let own_ticket = service.ticket().await?.to_string();
let identity = self.ensure_identity()?;
let profile = self.own_profile(&own_ticket)?;
@@ -2174,25 +2176,39 @@ impl DeviceSync {
LEFT JOIN sync_peer_acks a
ON a.peer_device_id = ?1 AND a.origin_device_id = o.origin_device_id
WHERE o.seq > COALESCE(a.max_seq, 0)
ORDER BY o.hlc_ms, o.op_id
LIMIT ?2",
)?;
let rows = stmt.query_map(params![peer_device_id, 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),
AND (
o.kind != 'playback_command'
OR (
o.hlc_ms >= ?2
AND json_extract(o.payload_json, '$.target_device_id') = ?1
)
})?,
})
})?;
)
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<_>>>()?)
}
@@ -2380,7 +2396,7 @@ impl DeviceSync {
let own = self.ensure_identity()?.device_id;
let conn = lock(&self.conn);
let mut stmt = conn.prepare(
"SELECT device_id, endpoint_ticket
"SELECT device_id, endpoint_id, endpoint_ticket
FROM sync_devices
WHERE trusted_at_ms IS NOT NULL
AND revoked_at_ms IS NULL
@@ -2390,7 +2406,8 @@ impl DeviceSync {
let rows = stmt.query_map([own], |row| {
Ok(StoredDevice {
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<_>>>()?)
@@ -2642,9 +2659,29 @@ impl DeviceSync {
#[derive(Debug)]
struct StoredDevice {
device_id: String,
endpoint_id: 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(
mut acceptor: StreamAcceptor,
sync: Arc<DeviceSync>,
+52
View File
@@ -228,6 +228,58 @@ fn playback_command_is_targeted_and_deduplicated() {
assert!(rx.try_recv().is_err());
}
#[test]
fn playback_commands_are_caught_up_only_by_their_target_while_fresh() {
let sync = test_sync();
let command = PlaybackCommand::SetState {
state: PlaybackStateWire {
queue: Vec::new(),
queue_pos: 0,
playing: false,
paused: false,
idle_since_ms: None,
position_secs: 0.0,
volume: 42,
shuffle: false,
repeat: PlaybackRepeat::Off,
},
seek: false,
};
sync.record_playback_command("dev_target", command.clone())
.unwrap();
sync.record_playback_command("dev_other", command).unwrap();
let target_ops = sync.ops_for_peer("dev_target").unwrap();
assert_eq!(
target_ops
.iter()
.filter(|op| matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
.count(),
1
);
assert!(
sync.ops_for_peer("dev_unknown")
.unwrap()
.iter()
.all(|op| !matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
);
lock(&sync.conn)
.execute(
"UPDATE sync_ops
SET hlc_ms = ?1
WHERE kind = 'playback_command'",
[now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS + 1)],
)
.unwrap();
assert!(
sync.ops_for_peer("dev_target")
.unwrap()
.iter()
.all(|op| !matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
);
}
#[test]
fn newer_device_trust_reactivates_revoked_device() {
let sync = test_sync();
+2
View File
@@ -18,6 +18,8 @@ use crate::library::Library;
/// ALPN of the audio streaming protocol (shared with furumi-fd).
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
/// Version of the audio transfer stream protocol.
pub const AUDIO_PROTOCOL_VERSION: u16 = 1;
/// Maximum size of a JSON protocol line (request or response header).
const MAX_PROTOCOL_LINE: usize = 4096;
+191
View File
@@ -0,0 +1,191 @@
//! Informational publication and observation of protocol versions.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use anyhow::{Context, Result};
pub use music_dht::capabilities::CAPABILITIES_ALPN;
use music_dht::capabilities::{
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, read_message,
write_message,
};
use music_dht::{ByteStream, EndpointId, MusicDhtService, StreamAcceptor};
const PROBE_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProtocolVersions {
pub local: BTreeMap<String, u16>,
pub observed: BTreeMap<String, u16>,
pub observed_peers: usize,
pub newer: Vec<NewerProtocol>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewerProtocol {
pub id: String,
pub local: u16,
pub observed: u16,
}
impl ProtocolVersions {
pub fn snapshot(observed: &ObservedVersions) -> Self {
let local = local_manifest().protocols;
let observed_versions = lock(&observed.versions).clone();
let newer = observed_versions
.iter()
.filter_map(|(id, remote)| {
let local_version = local.get(id)?;
(*remote > *local_version).then(|| NewerProtocol {
id: id.clone(),
local: *local_version,
observed: *remote,
})
})
.collect();
Self {
local,
observed: observed_versions,
observed_peers: lock(&observed.peers).len(),
newer,
}
}
}
#[derive(Default)]
pub struct ObservedVersions {
versions: Mutex<BTreeMap<String, u16>>,
peers: Mutex<BTreeMap<String, String>>,
}
fn local_manifest() -> CapabilityManifest {
CapabilityManifest::frid("furumi", env!("CARGO_PKG_VERSION"))
.with_protocol("audio", super::audio::AUDIO_PROTOCOL_VERSION)
}
pub async fn serve(mut acceptor: StreamAcceptor) {
while let Some(stream) = acceptor.accept().await {
tokio::spawn(async move {
if let Err(error) = serve_one(stream).await {
tracing::debug!("capability stream failed: {error:#}");
}
});
}
}
async fn serve_one(mut stream: ByteStream) -> Result<()> {
let response = match read_message(&mut stream).await? {
CapabilityMessage::Get {
version: CAPABILITIES_PROTOCOL_VERSION,
} => CapabilityMessage::Manifest {
manifest: local_manifest(),
},
CapabilityMessage::Get { version } => CapabilityMessage::Error {
message: format!("unsupported capability protocol {version}"),
},
_ => CapabilityMessage::Error {
message: "expected capability request".to_string(),
},
};
write_message(&mut stream, &response).await?;
stream.send.finish()?;
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
Ok(())
}
pub async fn probe_loop(service: Arc<MusicDhtService>, observed: Arc<ObservedVersions>) {
let mut interval = tokio::time::interval(PROBE_INTERVAL);
loop {
interval.tick().await;
let peers = service
.connected_peers()
.into_iter()
.chain(
service
.known_peers()
.into_iter()
.map(|contact| contact.peer_id),
)
.collect::<std::collections::BTreeSet<_>>();
for peer in peers {
if let Err(error) = probe_peer(&service, peer, &observed).await {
tracing::trace!(%peer, "peer capability probe unavailable: {error:#}");
}
}
}
}
async fn probe_peer(
service: &MusicDhtService,
peer: EndpointId,
observed: &ObservedVersions,
) -> Result<()> {
let mut stream = service.open_stream(peer, CAPABILITIES_ALPN).await?;
write_message(
&mut stream,
&CapabilityMessage::Get {
version: CAPABILITIES_PROTOCOL_VERSION,
},
)
.await?;
stream.send.finish()?;
let response = tokio::time::timeout(Duration::from_secs(5), read_message(&mut stream))
.await
.context("capability request timed out")??;
let CapabilityMessage::Manifest { manifest } = response else {
anyhow::bail!("peer did not return a capability manifest");
};
manifest.validate()?;
{
let mut versions = lock(&observed.versions);
for (id, version) in manifest.protocols {
versions
.entry(id)
.and_modify(|current| *current = (*current).max(version))
.or_insert(version);
}
}
lock(&observed.peers).insert(peer.to_string(), manifest.application_version);
Ok(())
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_manifest_lists_every_player_protocol() {
let manifest = local_manifest();
for id in [
"federation_net",
"ticket",
"rendezvous",
"music_dht",
"catalog",
"audio",
"similarity",
"device_sync",
"jam",
] {
assert!(manifest.protocols.contains_key(id), "missing {id}");
}
manifest.validate().unwrap();
}
#[test]
fn snapshot_reports_only_strictly_newer_versions() {
let observed = ObservedVersions::default();
lock(&observed.versions).insert("music_dht".to_string(), 99);
lock(&observed.versions).insert("jam".to_string(), crate::jam::PROTOCOL_VERSION);
let snapshot = ProtocolVersions::snapshot(&observed);
assert_eq!(snapshot.newer.len(), 1);
assert_eq!(snapshot.newer[0].id, "music_dht");
}
}
+209 -51
View File
@@ -13,7 +13,9 @@
//! the network too).
mod audio;
mod capabilities;
pub mod catalog;
mod similarity;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
@@ -36,7 +38,9 @@ use crate::library::NetworkArtistPreview;
use crate::library::models::{ArtistRef, TrackItem};
pub use audio::{AUDIO_ALPN, DownloadProgress, StreamingStart, TrackMetadata};
pub use capabilities::ProtocolVersions;
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.
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
@@ -386,6 +390,7 @@ pub struct FedStatus {
pub last_sync: Option<String>,
pub last_error: Option<String>,
pub transport: TransportStatsSnapshot,
pub protocols: ProtocolVersions,
}
/// Outcome of preparing a federated track for playback.
@@ -416,15 +421,20 @@ struct Running {
pub struct Federation {
library: Arc<Library>,
devices: Arc<crate::devices::DeviceSync>,
jam: Arc<crate::jam::JamManager>,
similarity: Arc<crate::similarity::Manager>,
data_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>>,
settings: std::sync::Mutex<FedSettings>,
running: tokio::sync::Mutex<Option<Running>>,
last_sync: std::sync::Mutex<Option<String>>,
last_error: std::sync::Mutex<Option<String>>,
transport_stats: Arc<TransportStats>,
observed_protocols: Arc<capabilities::ObservedVersions>,
}
#[derive(Debug, Clone)]
@@ -515,7 +525,13 @@ async fn dht_record_payload_bytes(data_dir: PathBuf, now_ms: u64) -> Result<u64>
}
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 data_dir = dirs
.as_ref()
@@ -525,10 +541,6 @@ impl Federation {
.as_ref()
.map(|d| d.cache_dir().join("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]
.into_iter()
.find_map(|dir| {
@@ -536,18 +548,23 @@ impl Federation {
.err()
.map(|err| format!("cannot create {}: {err}", dir.display()))
});
let media_dir = std::fs::canonicalize(&media_dir).unwrap_or(media_dir);
Arc::new(Self {
library,
devices,
jam,
similarity,
data_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()),
settings: std::sync::Mutex::new(load_settings()),
running: tokio::sync::Mutex::new(None),
last_sync: std::sync::Mutex::new(None),
last_error: std::sync::Mutex::new(initial_error),
transport_stats: Arc::new(TransportStats::default()),
observed_protocols: Arc::new(capabilities::ObservedVersions::default()),
})
}
@@ -555,6 +572,61 @@ impl Federation {
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> {
lock(&self.metadata_cache).values().cloned().collect()
}
@@ -625,8 +697,14 @@ impl Federation {
.stream_protocol(AUDIO_ALPN)
// ...and browse each other's per-artist catalogs over this one.
.stream_protocol(CATALOG_ALPN)
// Anonymous, bounded direct embedding queries.
.stream_protocol(SIMILARITY_ALPN)
// Personal-device sync (likes, playlists, trusted devices).
.stream_protocol(crate::devices::SYNC_ALPN)
// Capability-scoped shared playback control.
.stream_protocol(crate::jam::JAM_ALPN)
// Informational application/protocol versions.
.schema_independent_stream_protocol(capabilities::CAPABILITIES_ALPN)
.build()
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
let (service, mut events) = MusicDhtService::start(config)
@@ -675,6 +753,15 @@ impl Federation {
service.endpoint_id(),
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
.stream_acceptor(crate::devices::SYNC_ALPN)
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
@@ -690,6 +777,23 @@ impl Federation {
let device_tick_task = tokio::spawn(async move {
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 {
service,
@@ -700,8 +804,13 @@ impl Federation {
sync_task,
audio_task,
catalog_task,
similarity_task,
device_sync_task,
device_tick_task,
jam_serve_task,
jam_poll_task,
capabilities_serve_task,
capabilities_probe_task,
],
});
self.set_error(None);
@@ -840,6 +949,7 @@ impl Federation {
network: settings.network_id,
last_sync: lock(&self.last_sync).clone(),
last_error: lock(&self.last_error).clone(),
protocols: ProtocolVersions::snapshot(&self.observed_protocols),
..FedStatus::default()
};
if let Some(running) = guard.as_ref() {
@@ -1054,6 +1164,21 @@ impl Federation {
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<FedSearchResults> {
anyhow::ensure!(
self.similarity.network_allowed(),
"similarity federation has no consent"
);
let service = self.service().await?;
similarity::search(service, query, limit, Arc::clone(&self.transport_stats)).await
}
/// Resolves a share-link content id to one playable federated track.
///
/// Resolution order: the in-session metadata cache, the DHT content key,
@@ -1345,29 +1470,21 @@ impl Federation {
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
/// (`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
/// local path is returned.
/// one answers with an image; the result is stored beside the artist or
/// release in the permanent music tree and its local path is returned.
pub async fn card_image(
&self,
owners: &[String],
artist: &str,
release: Option<&str>,
) -> 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 {
Some(release) => format!(
"cover-{}-{}",
sanitize_file_stem(artist),
sanitize_file_stem(release)
),
None => format!("artist-{}", sanitize_file_stem(artist)),
Some(_) => "cover".to_string(),
None => "artist".to_string(),
};
// Reuse a previously streamed copy of any known image type.
for extension in ["jpg", "png", "webp", "gif", "bmp"] {
@@ -1431,7 +1548,7 @@ impl Federation {
F: FnMut(DownloadProgress) + Send,
{
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
}
@@ -1446,7 +1563,7 @@ impl Federation {
S: FnMut(StreamingStart) + Send,
{
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
}
@@ -1500,6 +1617,11 @@ impl Federation {
where
F: FnMut(DownloadProgress) + Send,
{
let _media_guard = if save {
Some(self.media_change.lock().await)
} else {
None
};
let mut fed = fed.clone();
let mut tried_content_lookup = false;
@@ -1578,8 +1700,10 @@ impl Federation {
}
Err(_) => anyhow::bail!("malformed owner id '{}'", fed.owner),
};
let media_dir;
let dir = if save {
&self.media_dir
media_dir = music_release_dir(&self.media_dir(), &fed);
&media_dir
} else {
&self.cache_dir
};
@@ -1640,8 +1764,14 @@ impl Federation {
}
// Same for the cover: the peer's library cover wins over an
// embedded picture; embedded art stays as the fallback.
if import_cover.is_some() {
import.cover = import_cover;
if let Some((bytes, extension)) = &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)?;
// A like that referenced the federated track moves onto the
@@ -1653,7 +1783,13 @@ impl Federation {
// created (or still image-less) main artist.
if let (Some((bytes, extension)), Some(artist_name)) =
(&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");
}
@@ -1880,20 +2016,18 @@ impl Federation {
}
}
/// Writes a received artist image into the covers directory and attaches it
/// to the artist unless one is already set.
/// Writes a received artist image into the artist's music directory and
/// attaches it to the artist unless one is already set.
fn save_artist_image(
library: &Library,
artist_name: &str,
artist_dir: Option<&Path>,
bytes: &[u8],
extension: &str,
) -> Result<()> {
let covers_dir = library.covers_dir();
std::fs::create_dir_all(covers_dir)?;
let path = covers_dir.join(format!(
"artist-{}.{extension}",
sanitize_file_stem(artist_name)
));
let artist_dir = artist_dir.context("downloaded track has no artist directory")?;
std::fs::create_dir_all(artist_dir)?;
let path = artist_dir.join(format!("artist.{extension}"));
// Write only if the artist actually lacks an image, to avoid litter.
if library.artist_image_missing(artist_name)? {
std::fs::write(&path, bytes)?;
@@ -1902,6 +2036,16 @@ fn save_artist_image(
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
/// non-empty peer field wins; file tags only fill the gaps.
fn apply_remote_metadata(import: &mut crate::library::import::TrackImport, meta: &TrackMetadata) {
@@ -2488,29 +2632,43 @@ fn collect_specs(library: &Library) -> Result<Vec<ItemSpec>> {
Ok(specs)
}
fn sanitize_file_stem(value: &str) -> String {
let cleaned: String = value
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
let trimmed = cleaned.trim().trim_matches('.');
let mut stem: String = trimmed.chars().take(120).collect();
if stem.is_empty() {
stem.push_str("track");
fn music_artist_dir(root: &Path, artist: &str) -> PathBuf {
let artist = if artist.trim().is_empty() {
"Unknown Artist"
} else {
artist
};
root.join(crate::library::storage_name(artist, "Unknown Artist"))
}
fn music_art_dir(root: &Path, artist: &str, release: Option<&str>) -> PathBuf {
let artist_dir = music_artist_dir(root, artist);
match release.filter(|release| !release.trim().is_empty()) {
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 {
let artists = fed.artist_line();
if artists.is_empty() {
sanitize_file_stem(&fed.title)
crate::library::storage_name(&fed.title, "track")
} else {
sanitize_file_stem(&format!("{artists} - {}", fed.title))
crate::library::storage_name(&format!("{artists} - {}", fed.title), "track")
}
}
+277
View File
@@ -0,0 +1,277 @@
//! Furumi policy and local-index adapter for the shared similarity protocol.
//!
//! `music_dht::similarity` owns the versioned wire contract and framing. This
//! module owns application policy: consent, peer fan-out, local index access,
//! result conversion, deduplication, and ranking limits.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as _, Result};
use futures_util::stream::{self, StreamExt as _};
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
use crate::federation::{FedSearchResults, FedTrack, TransportStats};
use crate::similarity::{Manager, QueryVector};
pub use music_dht::similarity::SIMILARITY_ALPN;
const MAX_QUERY_PEERS: usize = 16;
const QUERY_CONCURRENCY: usize = 6;
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_PER_ARTIST: usize = 3;
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
pub async fn serve_peers(
mut acceptor: StreamAcceptor,
similarity: Arc<Manager>,
own: EndpointId,
transport: Arc<TransportStats>,
) {
while let Some(stream) = acceptor.accept().await {
let similarity = Arc::clone(&similarity);
let transport = Arc::clone(&transport);
tokio::spawn(async move {
let peer = stream.peer_id;
if let Err(err) = serve_one(stream, similarity, own, transport).await {
tracing::warn!(peer = %peer, "similarity request failed: {err:#}");
}
});
}
}
async fn serve_one(
mut stream: ByteStream,
similarity: Arc<Manager>,
own: EndpointId,
transport: Arc<TransportStats>,
) -> Result<()> {
super::record_stream_transport(&transport, "similarity", "inbound", "open", &stream);
let request = wire::read_request(&mut stream).await?;
let response = if !similarity.network_allowed() {
SimilarityResponse::refused("similarity federation is disabled or has no privacy consent")?
} else {
let profile = request.profile_id;
let vector = request.vector;
let limit = request.limit;
let matches = tokio::task::spawn_blocking(move || {
similarity.search_vector(&profile, &vector, None, None, limit)
})
.await
.context("local similarity task failed")
.and_then(|result| result);
match matches {
Ok(matches) => {
let hits = matches
.into_iter()
.filter_map(|found| {
let track = found.track;
let hit = SimilarityHit {
score: found.score,
item_id: super::audio::hex_encode(
ItemId::derive(
&own,
ItemKind::Track,
&format!("track:{}", track.id),
)
.as_bytes(),
),
title: track.title,
artist_names: track
.artists
.into_iter()
.map(|artist| artist.name)
.collect(),
featured_artist_names: track
.featured_artists
.into_iter()
.map(|artist| artist.name)
.collect(),
year: track.release_year,
duration_seconds: Some(track.duration_seconds.round() as i64),
content_id: track.content_id,
release_title: Some(track.release_title),
track_number: track.track_number,
disc_number: track.disc_number,
embedding_signature: Some(found.embedding_signature),
};
match hit.validate() {
Ok(()) => Some(hit),
Err(err) => {
tracing::debug!(%err, "invalid local similarity metadata skipped");
None
}
}
})
.collect();
SimilarityResponse::success(hits)?
}
Err(err) => {
SimilarityResponse::refused(format!("similarity query is unavailable: {err:#}"))?
}
}
};
wire::write_response(&mut stream, &response).await?;
stream.send.finish()?;
let _ = stream.send.stopped().await;
super::record_stream_transport(&transport, "similarity", "inbound", "done", &stream);
Ok(())
}
pub async fn search(
service: Arc<MusicDhtService>,
query: QueryVector,
limit: usize,
transport: Arc<TransportStats>,
) -> Result<FedSearchResults> {
let own = service.endpoint_id();
let mut peers = Vec::new();
let mut seen = HashSet::new();
for peer in service
.connected_peers()
.into_iter()
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
{
if peer != own && seen.insert(peer) {
peers.push(peer);
}
if peers.len() >= MAX_QUERY_PEERS {
break;
}
}
let query_signature = wire::embedding_signature(&query.vector)?;
let request = Arc::new(SimilarityRequest::new(
query.profile_id,
query.vector,
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
)?);
let responses = stream::iter(peers.into_iter().map(|peer| {
let service = Arc::clone(&service);
let request = Arc::clone(&request);
let transport = Arc::clone(&transport);
async move {
tokio::time::timeout(
QUERY_TIMEOUT,
query_peer(service, peer, &request, transport),
)
.await
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
}
}))
.buffer_unordered(QUERY_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut hits = Vec::new();
for response in responses {
match response {
Ok(peer_hits) => hits.extend(peer_hits),
Err(err) => tracing::debug!(%err, "similarity peer query skipped"),
}
}
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
let mut dedup = HashSet::new();
let mut embedding_signatures = vec![query_signature];
let mut artist_counts: HashMap<String, usize> = HashMap::new();
let mut tracks = Vec::new();
for (track, _, embedding_signature) in hits {
if query
.source_content_id
.as_deref()
.is_some_and(|source| track.content_id.as_deref() == Some(source))
{
continue;
}
let key = track
.content_id
.clone()
.unwrap_or_else(|| format!("{}:{}", track.owner, track.item_id));
if !dedup.insert(key) {
continue;
}
if embedding_signature.is_some_and(|candidate| {
embedding_signatures.iter().any(|existing| {
wire::signature_distance(&candidate, existing)
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
})
}) {
continue;
}
let artist = track
.artist_names
.first()
.map(|name| music_dht::normalize_name(name))
.unwrap_or_default();
let count = artist_counts.entry(artist.clone()).or_default();
if !artist.is_empty() && *count >= MAX_PER_ARTIST {
continue;
}
*count += 1;
if let Some(signature) = embedding_signature {
embedding_signatures.push(signature);
}
tracks.push(track);
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
break;
}
}
Ok(FedSearchResults {
artists: Vec::new(),
tracks,
})
}
async fn query_peer(
service: Arc<MusicDhtService>,
owner: EndpointId,
request: &SimilarityRequest,
transport: Arc<TransportStats>,
) -> Result<
Vec<(
FedTrack,
f32,
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
)>,
> {
let mut stream = service
.open_stream(owner, SIMILARITY_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach similarity peer: {err}"))?;
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
let response = wire::exchange(&mut stream, request).await?;
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
anyhow::ensure!(
response.ok,
"peer refused similarity query: {}",
response.error.unwrap_or_default()
);
Ok(response
.hits
.into_iter()
.map(|hit| {
let score = hit.score;
let embedding_signature = hit.embedding_signature;
(
FedTrack {
item_id: hit.item_id,
owner: owner.to_string(),
own: false,
title: hit.title,
artist_names: hit.artist_names,
featured_artist_names: hit.featured_artist_names,
year: hit.year,
duration_seconds: hit.duration_seconds,
content_id: hit.content_id,
release_title: hit.release_title,
track_number: hit.track_number,
disc_number: hit.disc_number,
},
score,
embedding_signature,
)
})
.collect())
}
+728
View File
@@ -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);
}
}
+610
View File
@@ -11,6 +11,7 @@ pub mod import;
pub mod models;
use std::collections::{HashMap, HashSet};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -192,6 +193,26 @@ CREATE INDEX IF NOT EXISTS idx_network_artist_cache_kind
ON network_artist_cache(source_kind, seen_at_ms);
CREATE INDEX IF NOT EXISTS idx_network_artist_cache_artist
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,
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
@@ -270,6 +291,32 @@ pub struct NetworkArtistImageRequest {
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 {
conn: Mutex<Connection>,
db_path: PathBuf,
@@ -288,6 +335,12 @@ pub struct LocalLibraryStats {
pub database_bytes: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MusicRelocationStats {
pub tracks: usize,
pub images: usize,
}
impl LocalLibraryStats {
pub fn total_bytes(&self) -> u64 {
self.audio_bytes
@@ -330,6 +383,261 @@ impl Library {
&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> {
let (artist_count, release_count, track_count, audio_bytes, tracks_without_size) = {
let conn = self.lock();
@@ -1171,6 +1479,192 @@ impl Library {
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 conn = self.lock();
conn.execute(
"INSERT INTO track_embeddings (
track_id, profile_id, dimensions, vector,
source_content_id, computed_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(track_id, profile_id) DO UPDATE SET
dimensions = excluded.dimensions,
vector = excluded.vector,
source_content_id = excluded.source_content_id,
computed_at_ms = excluded.computed_at_ms",
params![
track.id,
profile_id,
vector.len() as i64,
bytes,
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)
}
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
// -----------------------------------------------------------------
@@ -2498,6 +2992,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<()> {
tx.execute(
"DELETE FROM releases WHERE id NOT IN (SELECT release_id FROM tracks)",
@@ -2779,6 +3363,32 @@ fn now_ms_i64() -> i64 {
.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 {
let hash = blake3::hash(artist_key.as_bytes());
let mut bytes = [0u8; 8];
+207
View File
@@ -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]
fn local_stats_counts_library_rows_and_audio_bytes() {
let lib = test_library();
@@ -554,3 +686,78 @@ fn listen_history_hides_unqualified_events_and_keeps_remote_metadata() {
assert!(lib.apply_listen_event(&event, "remote-device").unwrap());
assert!(lib.listen_history(20).unwrap().is_empty());
}
#[test]
fn similarity_embeddings_round_trip_and_keep_profiles_separate() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
for profile in ["profile-a", "profile-b"] {
lib.ensure_similarity_profile(profile, "model", "1", "sha", "prep", 3)
.unwrap();
}
let track = lib
.pending_similarity_tracks("profile-a")
.unwrap()
.into_iter()
.find(|track| track.id == track_id)
.unwrap();
lib.store_similarity_embedding(&track, "profile-a", &[0.1, 0.2, 0.3])
.unwrap();
lib.store_similarity_embedding(&track, "profile-b", &[0.3, 0.2, 0.1])
.unwrap();
assert_eq!(
lib.similarity_embedding(track_id, "profile-a").unwrap(),
Some(vec![0.1, 0.2, 0.3])
);
assert_eq!(
lib.similarity_embedding(track_id, "profile-b").unwrap(),
Some(vec![0.3, 0.2, 0.1])
);
let stats = lib.similarity_storage_stats("profile-a").unwrap();
assert_eq!(stats.total_tracks, 1);
assert_eq!(stats.embedded_tracks, 1);
assert_eq!(stats.stored_vectors, 2);
assert_eq!(stats.stored_bytes, 24);
}
#[test]
fn changed_content_id_invalidates_only_the_stale_embedding() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
.unwrap();
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
.unwrap();
lib.lock()
.execute(
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
params![track_id, format!("b3:{}", "f".repeat(64))],
)
.unwrap();
assert_eq!(lib.pending_similarity_tracks("profile").unwrap().len(), 1);
assert_eq!(lib.similarity_embedding(track_id, "profile").unwrap(), None);
assert!(lib.load_similarity_index("profile").unwrap().is_empty());
}
#[test]
fn clearing_embeddings_preserves_the_library() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
.unwrap();
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
.unwrap();
lib.clear_similarity_embeddings().unwrap();
assert_eq!(lib.tracks_by_ids(&[track_id]).unwrap().len(), 1);
assert!(
lib.similarity_embedding(track_id, "profile")
.unwrap()
.is_none()
);
}
+32 -1
View File
@@ -3,10 +3,13 @@ mod art;
mod config;
mod devices;
mod federation;
mod jam;
mod library;
mod media;
mod player;
mod share;
mod similarity;
mod status;
mod streaming;
mod ui;
mod visualizer;
@@ -19,11 +22,39 @@ use crossterm::event::{
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
const HELP: &str = "\
Furumi federated terminal music player
Usage:
furumi [OPTION]
Options:
-h, --help Show this help
-V, --version Show version
--status Print a one-line now-playing status
--status-json
Print now-playing status as JSON
tmux:
set -g status-right '#(furumi --status)'
";
fn main() -> Result<()> {
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"));
return Ok(());
}
if args
.iter()
.any(|arg| arg == "--status" || arg == "--status-json")
{
return status::print(args.iter().any(|arg| arg == "--status-json"));
}
let mut startup_warning = None;
if let Err(err) = config::logging::init() {
+52 -1
View File
@@ -99,9 +99,10 @@ fn create_controls() -> Option<MediaControls> {
#[cfg(not(target_os = "windows"))]
let hwnd = None;
let dbus_name = platform_dbus_name();
let config = PlatformConfig {
display_name: "Furumi",
dbus_name: "cy.hexor.furumi",
dbus_name: &dbus_name,
hwnd,
};
match MediaControls::new(config) {
@@ -113,6 +114,41 @@ fn create_controls() -> Option<MediaControls> {
}
}
/// MPRIS well-known names must be unique on the session bus. Developers often
/// run a checkout next to an installed Furumi, and souvlaki 0.8.3 reports a
/// duplicate name by panicking in its private service thread. Ratatui's global
/// panic hook then restores the terminal even though the app thread is still
/// alive, leaving a frozen UI in canonical/echo mode.
///
/// Give every Unix MPRIS instance its own valid bus-name component. Other
/// backends ignore `dbus_name`, so retain the stable application identifier
/// there.
fn platform_dbus_name() -> String {
#[cfg(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
))]
{
mpris_dbus_name(std::process::id())
}
#[cfg(not(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
)))]
{
"cy.hexor.furumi".to_string()
}
}
#[cfg(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
))]
fn mpris_dbus_name(process_id: u32) -> String {
format!("cy.hexor.furumi.instance{process_id}")
}
/// An invisible top-level window owning the SMTC session. Created on the
/// main thread, which also pumps its messages in `pump_platform_events`.
#[cfg(target_os = "windows")]
@@ -217,3 +253,18 @@ fn pump_platform_events() {
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn pump_platform_events() {}
#[cfg(test)]
mod tests {
#[cfg(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
))]
#[test]
fn mpris_names_are_distinct_between_processes() {
let first = super::mpris_dbus_name(41);
let second = super::mpris_dbus_name(42);
assert_eq!(first, "cy.hexor.furumi.instance41");
assert_ne!(first, second);
}
}
+1047
View File
File diff suppressed because it is too large Load Diff
+190
View File
@@ -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");
}
}
+283 -7
View File
@@ -2,11 +2,12 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
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) {
let block = Block::bordered()
@@ -27,12 +28,12 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
.areas(inner);
draw_settings_rows(frame, rows_area, state);
draw_status(frame, status_area, state);
draw_status_column(frame, status_area, state);
return;
}
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([
Constraint::Length(rows_height.min(inner.height)),
Constraint::Length(1),
@@ -41,7 +42,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
.areas(inner);
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> {
@@ -68,6 +69,58 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
let mut y = area.y;
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::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");
for row in FedRow::ALL {
let (label, value) = match row {
@@ -338,6 +391,21 @@ 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",
"device_sync" => "Device sync",
"jam" => "Jam",
other => other,
}
}
fn draw_section(frame: &mut Frame, area: Rect, state: &AppState, y: &mut u16, title: &'static str) {
if *y >= area.y + area.height {
return;
@@ -471,6 +539,68 @@ fn short_id(id: &str) -> String {
id.chars().take(12).collect::<String>() + ""
}
fn draw_status_column(frame: &mut Frame, area: Rect, state: &AppState) {
if area.height < 12 {
draw_status(frame, area, state);
return;
}
let [similarity_area, _, federation_area] = Layout::vertical([
Constraint::Length(8),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(area);
draw_similarity_status(frame, similarity_area, state);
draw_status(frame, federation_area, state);
}
fn draw_similarity_status(frame: &mut Frame, area: Rect, state: &AppState) {
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());
draw_summary_card(
frame,
area,
state,
" Similarity Processing ",
vec![
status_line("State", status.phase.label().to_string()),
status_line("Progress", progress),
status_line("Active", active),
status_line("Processing", target),
status_line(
"Stored",
format!(
"{} vectors / {}",
status.stored_vectors,
short_bytes_label(status.stored_bytes)
),
),
status_line(
"Current / errors",
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) {
if area.width == 0 || area.height == 0 {
return;
@@ -485,11 +615,16 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
return;
}
if area.width >= 60 && area.height >= 15 {
let [top_area, _, bottom_area] = Layout::vertical([
if area.width >= 60 && area.height >= 20 {
let protocols_height =
protocol_card_height(state, area.width.saturating_sub(2), area.height);
let [top_area, _, bottom_area, _, protocols_area, _] = Layout::vertical([
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(protocols_height),
Constraint::Min(0),
])
.areas(area);
let [status_area, _, local_area] = Layout::horizontal([
@@ -532,10 +667,17 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
" Connected Devices ",
device_summary_lines(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 {
if area.height < 39 {
frame.render_widget(
Paragraph::new(compact_status_lines(state))
.wrap(ratatui::widgets::Wrap { trim: false }),
@@ -553,6 +695,8 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
_,
local_area,
_,
protocols_area,
_,
] = Layout::vertical([
Constraint::Length(7),
Constraint::Length(1),
@@ -561,6 +705,12 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(protocol_card_height(
state,
area.width.saturating_sub(2),
area.height,
)),
Constraint::Min(0),
])
.areas(area);
@@ -593,6 +743,13 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
" Local Data ",
local_data_summary_lines(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>> {
@@ -608,6 +765,9 @@ fn compact_status_lines(state: &AppState) -> Vec<Line<'static>> {
lines.push(Line::default());
lines.push(Line::styled("Connected Devices", theme::header_for(state)));
lines.extend(device_summary_lines(state).into_iter().take(2));
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
}
@@ -637,6 +797,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>> {
match &state.federation.status {
None => vec![
+6 -1
View File
@@ -890,7 +890,12 @@ 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) {
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 {
title.push_str("· searching… ");
}
+1 -1
View File
@@ -258,7 +258,7 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
let player = &state.player;
let block = Block::bordered()
.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(),
state.global.filters.source_mode.label()
))
+188 -22
View File
@@ -25,6 +25,10 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
..
}) => draw_edit(frame, state, title, fields, *focus, error.as_deref()),
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::TrackInfo {
tracks,
@@ -42,8 +46,15 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
draw_fed_input(frame, state, field.title(), field.help(), input)
}
Some(Popup::FedText { title, text }) => draw_fed_text(frame, state, title, text),
Some(Popup::FedCopyText { title, text, help }) => {
draw_fed_copy_text(frame, state, title, text, help)
Some(Popup::FedCopyText {
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 {
focus,
@@ -93,6 +104,79 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
}
}
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(),
@@ -440,7 +524,7 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
display_lines.push(DisplayLine::Row(index));
}
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 block = Block::bordered()
.title(" Connected devices ")
@@ -450,9 +534,10 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
frame.render_widget(Clear, 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(3),
Constraint::Length(4),
Constraint::Min(1),
Constraint::Length(2),
])
@@ -479,12 +564,11 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
width: this_area.width,
height: 1,
};
let action_label =
if state.device_playback.role == crate::app::state::DevicePlaybackRole::Active {
"This device is active"
} else {
"Make this device active"
};
let action_label = if state.device_playback.is_audio_owner() {
"This device is active"
} else {
"Make this device active"
};
let self_name = self_row
.map(|row| row.name.as_str())
.unwrap_or(state.device_playback.self_device_name.as_str());
@@ -501,7 +585,53 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
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 {
x: other_area.x,
y: other_area.y + 1,
@@ -560,7 +690,7 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
);
frame.render_widget(
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(),
))
.alignment(Alignment::Center),
@@ -792,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) {
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
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(
frame.area(),
width,
@@ -816,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.
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 text_width = usize::from(width.saturating_sub(2));
let text_lines = (text.chars().count() / text_width.max(1) + 1) as u16;
@@ -848,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 }),
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(
Paragraph::new(Line::styled(
" Copy to clipboard and close ",
theme::tab_active_for(state),
))
Paragraph::new(Line::from(vec![
button("Copy to clipboard", cursor == 0),
Span::raw(" "),
button("Show as plain terminal line", cursor == 1),
]))
.alignment(Alignment::Center),
button_area,
);
frame.render_widget(
Paragraph::new(Line::styled("enter/c copy · esc close", theme::dim()))
.alignment(Alignment::Center),
Paragraph::new(Line::styled(
"left/right choose · enter apply · c copy · p plain line · esc close",
theme::dim(),
))
.alignment(Alignment::Center),
footer_area,
);
}
@@ -1182,12 +1338,22 @@ fn draw_track_info(
);
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"
} 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 {
"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 {
"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 {
"j/k scroll · a artist · esc close"
};
+14 -12
View File
@@ -4,6 +4,7 @@ use crate::app::state::{AppState, DevicePlaybackRole};
pub const ACCENT: Color = Color::Cyan;
pub const CONTROL_ACCENT: Color = Color::Yellow;
pub const JAM_ACCENT: Color = Color::Magenta;
pub const DIM: Color = Color::DarkGray;
pub fn accent() -> Style {
@@ -37,10 +38,10 @@ pub fn selection() -> Style {
}
pub fn selection_for(state: &AppState) -> Style {
if state.device_playback.is_control() {
Style::new().fg(Color::White).bg(Color::Rgb(92, 72, 0))
} else {
selection()
match state.device_playback.role {
DevicePlaybackRole::Jam => Style::new().fg(Color::White).bg(Color::Rgb(80, 24, 96)),
DevicePlaybackRole::Control => Style::new().fg(Color::White).bg(Color::Rgb(92, 72, 0)),
DevicePlaybackRole::Active => selection(),
}
}
@@ -49,10 +50,10 @@ pub fn header_for(state: &AppState) -> Style {
}
pub fn border_for(state: &AppState) -> Style {
if state.device_playback.is_control() {
Style::new().fg(CONTROL_ACCENT)
} else {
dim()
match state.device_playback.role {
DevicePlaybackRole::Jam => Style::new().fg(JAM_ACCENT),
DevicePlaybackRole::Control => Style::new().fg(CONTROL_ACCENT),
DevicePlaybackRole::Active => dim(),
}
}
@@ -64,6 +65,7 @@ pub fn role_pill(role: DevicePlaybackRole) -> Style {
let bg = match role {
DevicePlaybackRole::Active => Color::Green,
DevicePlaybackRole::Control => CONTROL_ACCENT,
DevicePlaybackRole::Jam => JAM_ACCENT,
};
Style::new()
.fg(Color::Black)
@@ -72,9 +74,9 @@ pub fn role_pill(role: DevicePlaybackRole) -> Style {
}
fn accent_color_for(state: &AppState) -> Color {
if state.device_playback.is_control() {
CONTROL_ACCENT
} else {
ACCENT
match state.device_playback.role {
DevicePlaybackRole::Jam => JAM_ACCENT,
DevicePlaybackRole::Control => CONTROL_ACCENT,
DevicePlaybackRole::Active => ACCENT,
}
}
+23
View File
@@ -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"
+42
View File
@@ -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.
+399
View File
@@ -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);
}
}
+31
View File
@@ -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)
}
}