Compare commits
8
Commits
v0.2.4
..
federation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc0bfa0e5 | ||
|
|
079d87a831 | ||
|
|
add764e51d | ||
|
|
87cb7fe74c | ||
|
|
e95d2e7fe1 | ||
|
|
03f74cc91e | ||
|
|
c971e81eed | ||
|
|
f73e3e7b95 |
@@ -123,6 +123,22 @@ The DHT is the distributed index. Nodes publish compact searchable
|
||||
descriptions of their local library and query the network without contacting a
|
||||
central search service.
|
||||
|
||||
Similarity discovery uses a separate schema-independent DHT overlay. A node
|
||||
derives a deterministic 256-bit routing signature from every local embedding,
|
||||
groups them into fixed two-level LSH buckets, and publishes compact summaries
|
||||
containing only fine-bucket representatives, its peer identity, and the ticket
|
||||
needed to dial a previously unknown owner. The owner signs every summary with
|
||||
its existing transport key, so storage peers can relay and cache it but cannot
|
||||
impersonate or modify it. Summaries expire with the ordinary library-record TTL
|
||||
and are replaceable federation cache, never local library authority.
|
||||
|
||||
A similarity search first performs bounded multi-probe LSH lookups to rank
|
||||
likely owners, then sends the existing normalized-vector request directly to
|
||||
at most 16 peers initially and 48 on fallback. Known peers remain a rollout
|
||||
fallback. The model, preprocessing, durable embeddings, exact cosine search,
|
||||
and consent policy remain client-owned; `music-dht` owns only compatible
|
||||
routing math, signed records, replication, and wire bounds.
|
||||
|
||||
Once a peer is known, communication moves to direct P2P streams provided by
|
||||
iroh through `music-dht`. Furumi defines separate application protocols for
|
||||
catalog requests, audio transfer, and trusted-device synchronization. This
|
||||
@@ -282,6 +298,31 @@ intended role: transforming current audio features into drawing commands. It
|
||||
is not a plugin mechanism for accessing the library, network, or player
|
||||
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:
|
||||
@@ -290,6 +331,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 |
|
||||
@@ -321,6 +363,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;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Furumi are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Optional offline music-similarity search for local tracks, backed by
|
||||
versioned SQLite embeddings and a replaceable exact in-memory cosine index.
|
||||
- Automatic SHA-256-verified download and local inference for the first ONNX
|
||||
embedding model, with retained model/profile generations and background
|
||||
backfilling of existing library tracks.
|
||||
- Similarity settings for enablement, model/profile information, numeric worker
|
||||
count, derived-data cleanup, processing progress, and federation privacy
|
||||
consent.
|
||||
- Track-seeded similarity search from the track-information popup, including
|
||||
bounded federated queries to compatible known peers.
|
||||
- The `furumi-fd/similarity/1` protocol in the visible protocol-version status.
|
||||
- Decentralized `similarity_dht` routing with signed anonymous two-level LSH
|
||||
summaries, multi-probe lookup beyond the locally known peer set, and known-
|
||||
peer fallback during gradual network upgrades.
|
||||
|
||||
### Changed
|
||||
|
||||
- Similarity wire types, bounds, validation, and stream framing now come from
|
||||
the shared `music-dht 0.4.0` API so native, web, and future clients can
|
||||
interoperate without sharing an embedding implementation.
|
||||
- Existing SQLite embeddings are backfilled once with compact 256-bit routing
|
||||
signatures; new embeddings store them immediately without changing exact
|
||||
local cosine search.
|
||||
- A similarity result page keeps the source track first as query context while
|
||||
excluding it from the actual nearest-neighbor ranking, labels the mode as
|
||||
`Search similar to`, and suppresses near-identical embeddings across releases
|
||||
and federated peer responses.
|
||||
- Preprocessing profiles now open a read-only details window describing their
|
||||
audio selection, resampling, spectrogram, patching, and aggregation contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Current-track information (`Shift+I`) now uses the enriched queue entry, so
|
||||
it shows the same complete metadata and similarity action as `I` on that
|
||||
track in the queue.
|
||||
|
||||
## [0.2.5] - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- Command history navigation with the Up and Down arrow keys.
|
||||
- A copy-friendly plain terminal view for connection tickets, device and Jam
|
||||
invites, and track-sharing links.
|
||||
- A configurable permanent music directory with write validation and an
|
||||
optional safe migration of existing Furumi-managed files.
|
||||
- Queue reordering for one track or a `Shift+V` selection with `Alt+K` and
|
||||
`Alt+J`.
|
||||
- Reproducible Nix/devenv tooling with Rust 1.97 and ALSA development files.
|
||||
|
||||
### Changed
|
||||
|
||||
- Sequential `a` actions now build one ordered play-next block instead of
|
||||
reversing independently inserted tracks.
|
||||
- Federated artist images and release covers are stored beside permanent music
|
||||
in an `Artist/Release` directory tree.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Running a development build next to another Furumi instance no longer lets
|
||||
an MPRIS name collision disable terminal raw mode and freeze keyboard input.
|
||||
- `nix develop` now keeps devenv state outside the read-only Nix store and
|
||||
isolates Rust 1.97 build artifacts from other toolchains.
|
||||
- Music-directory validation and migration now reject overlapping changes,
|
||||
resolve canonical paths, and produce Windows-portable managed filenames.
|
||||
|
||||
[Unreleased]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.5...HEAD
|
||||
[0.2.5]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.4...v0.2.5
|
||||
Generated
+852
-134
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumi_tui"
|
||||
version = "0.2.4"
|
||||
version = "0.2.6"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
description = "A federated P2P player for personal music libraries"
|
||||
@@ -17,23 +17,28 @@ crokey = "1.4.0"
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||
directories = "6.0.0"
|
||||
futures-util = "0.3.32"
|
||||
furumi-library = "0.1.0"
|
||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
||||
lofty = "0.22"
|
||||
# P2P federation: library index in a shared DHT + audio streaming between
|
||||
# peers (same protocol as furumi-fd).
|
||||
music-dht = "0.3"
|
||||
music-dht = "0.4.0"
|
||||
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]
|
||||
|
||||
@@ -69,6 +69,16 @@ library management are included. Visualizations are runtime-loadable Rhai
|
||||
scripts executed in a resource-limited sandbox, so they can be added or edited
|
||||
without rebuilding the player.
|
||||
|
||||
Optional similarity search calculates versioned embeddings for local tracks
|
||||
in the background and keeps them in SQLite. It works offline; after a separate
|
||||
privacy consent it can also ask a bounded set of federation peers for matches.
|
||||
Compatible peers are selected through signed, anonymous LSH summaries in a
|
||||
decentralized DHT; no central recommendation index or shared calibration file
|
||||
is required.
|
||||
The first selectable model is downloaded on demand and is licensed separately
|
||||
by MTG under CC BY-NC-SA 4.0 (a proprietary license is also available from
|
||||
MTG); Furumi itself remains WTFPL.
|
||||
|
||||
## Install
|
||||
|
||||
### macOS
|
||||
@@ -144,6 +154,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
@@ -0,0 +1,40 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
sourceRoot = builtins.toString ./.;
|
||||
sourceKey = builtins.substring 0 12 (builtins.hashString "sha256" sourceRoot);
|
||||
transientDotfile = "/tmp/furumi-tui-devenv-${sourceKey}";
|
||||
rustToolchain = builtins.fromTOML (builtins.readFile ./rust-toolchain.toml);
|
||||
rustVersion = rustToolchain.toolchain.channel;
|
||||
in
|
||||
{
|
||||
# Pure flakes only expose their read-only store source. Keep devenv's own
|
||||
# task/profile state writable, then restore the real checkout path in-shell.
|
||||
devenv.root = sourceRoot;
|
||||
devenv.dotfile = transientDotfile;
|
||||
devenv.state = "${transientDotfile}/state";
|
||||
|
||||
languages.rust = {
|
||||
enable = true;
|
||||
toolchainFile = ./rust-toolchain.toml;
|
||||
};
|
||||
|
||||
packages = [
|
||||
pkgs.pkg-config
|
||||
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.alsa-lib
|
||||
];
|
||||
|
||||
env.RUST_BACKTRACE = "1";
|
||||
|
||||
enterShell = lib.mkAfter ''
|
||||
export DEVENV_ROOT="$PWD"
|
||||
# Keep Nix builds separate from artifacts produced by rustup or another
|
||||
# devenv generation. Cargo metadata is not compatible across compilers.
|
||||
export CARGO_TARGET_DIR="$PWD/target/devenv-rust-${rustVersion}"
|
||||
export PATH="${config.languages.rust.toolchainPackage}/bin:$PATH"
|
||||
export RUSTC="${config.languages.rust.toolchainPackage}/bin/rustc"
|
||||
export RUSTDOC="${config.languages.rust.toolchainPackage}/bin/rustdoc"
|
||||
hash -r
|
||||
'';
|
||||
}
|
||||
Generated
+399
@@ -0,0 +1,399 @@
|
||||
{
|
||||
"nodes": {
|
||||
"cachix": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"git-hooks": [
|
||||
"devenv",
|
||||
"git-hooks"
|
||||
],
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1777487137,
|
||||
"narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "a66a440c321d35f7193472c317f42a55ccd1cb93",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crate2nix": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1772186516,
|
||||
"narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=",
|
||||
"owner": "rossng",
|
||||
"repo": "crate2nix",
|
||||
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rossng",
|
||||
"repo": "crate2nix",
|
||||
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv": {
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
"crate2nix": "crate2nix",
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-parts": "flake-parts",
|
||||
"ghostty": "ghostty",
|
||||
"git-hooks": "git-hooks",
|
||||
"nix": "nix",
|
||||
"nixd": "nixd",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785616853,
|
||||
"narHash": "sha256-WKA53CfbSwXh/QuGHc3gsWoLNHYaKw4xgvkc9ceRgUA=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "a3feed9ca96c66548496e8af2804d2a3a045d22e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"ghostty": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1784602798,
|
||||
"narHash": "sha256-298x90knBUWX5GHGXh2SKsAKvStjU2ri9UgOGoF79/8=",
|
||||
"owner": "ghostty-org",
|
||||
"repo": "ghostty",
|
||||
"rev": "88b4cd047fa627cdca6781bc7e7dc8b75a2cecb9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ghostty-org",
|
||||
"repo": "ghostty",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782908218,
|
||||
"narHash": "sha256-wLMOrPgVyeF3XmP+qfYcLqnVdTxikdcSvbIY7rA9jTA=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "9f7e99119ece7705299595299f3b031f39356de1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"mk-shell-bin": {
|
||||
"locked": {
|
||||
"lastModified": 1677004959,
|
||||
"narHash": "sha256-/uEkr1UkJrh11vD02aqufCxtbF5YnhRTIKlx5kyvf+I=",
|
||||
"owner": "rrbutani",
|
||||
"repo": "nix-mk-shell-bin",
|
||||
"rev": "ff5d8bd4d68a347be5042e2f16caee391cd75887",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rrbutani",
|
||||
"repo": "nix-mk-shell-bin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"flake-parts": [
|
||||
"devenv",
|
||||
"flake-parts"
|
||||
],
|
||||
"git-hooks-nix": [
|
||||
"devenv",
|
||||
"git-hooks"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"nixpkgs-23-11": [
|
||||
"devenv"
|
||||
],
|
||||
"nixpkgs-regression": [
|
||||
"devenv"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785349663,
|
||||
"narHash": "sha256-JSD8lPe5kalvPKx5X+inX8ZZdGLeXkAbd3Jiv7UDf+I=",
|
||||
"owner": "cachix",
|
||||
"repo": "nix",
|
||||
"rev": "f33db89fd6db6edc337d93212f6628ab6d25f407",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "devenv-2.34",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix2container": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1775487831,
|
||||
"narHash": "sha256-2lguQpLPQaxpQCJjXhmEEAfabwsAhkP29Z7fgLzHARA=",
|
||||
"owner": "nlewo",
|
||||
"repo": "nix2container",
|
||||
"rev": "76be9608a7f4d6c985d28b0e7be903ae2547df3e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nlewo",
|
||||
"repo": "nix2container",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixd": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
"devenv",
|
||||
"flake-parts"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783935112,
|
||||
"narHash": "sha256-IAQ14nteIKXAz4cd75UcZrsHGEVJ7QNrkUwX9rmeZ/Y=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"rev": "a64cd33e53b316b6b092ea0a966640cd2309bf3d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772624091,
|
||||
"narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "80bdc1e5ce51f56b19791b52b2901187931f5353",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1782614948,
|
||||
"narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1785571196,
|
||||
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"mk-shell-bin": "mk-shell-bin",
|
||||
"nix2container": "nix2container",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"rust-overlay": "rust-overlay_2"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782875958,
|
||||
"narHash": "sha256-5eqDcnBjb1424HRQdnhuhNOBZguq1Z2tqSa2OMF/m2c=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "13139aefa973f3d96c60c0fbab801de058ae25ca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"rust-overlay_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785562362,
|
||||
"narHash": "sha256-J15aBa3d6B1SUUAQydQ06wFjPzrrcVceb6jkdfwfGls=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "5f29c219a7655519f8a9f8c6968064b82c17cc93",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixd",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1780220602,
|
||||
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
description = "Furumi TUI development environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
devenv.url = "github:cachix/devenv";
|
||||
devenv.inputs.nixpkgs.follows = "nixpkgs";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
nix2container.url = "github:nlewo/nix2container";
|
||||
nix2container.inputs.nixpkgs.follows = "nixpkgs";
|
||||
mk-shell-bin.url = "github:rrbutani/nix-mk-shell-bin";
|
||||
};
|
||||
|
||||
outputs = inputs@{ flake-parts, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
|
||||
imports = [ inputs.devenv.flakeModule ];
|
||||
|
||||
perSystem = { ... }: {
|
||||
devenv.shells.default = {
|
||||
imports = [ ./devenv.nix ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.97.0"
|
||||
profile = "minimal"
|
||||
components = ["clippy", "rust-src", "rustfmt"]
|
||||
@@ -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(),
|
||||
|
||||
@@ -17,6 +17,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 +97,11 @@ 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;
|
||||
state.search.similarity_source_track = None;
|
||||
state.search.similarity_tracks.clear();
|
||||
state.search.similarity_stats = None;
|
||||
state.search.similarity_error = None;
|
||||
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let query = state.search.query.clone();
|
||||
if query.is_empty() {
|
||||
@@ -135,6 +150,53 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn schedule_similarity_search(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
track: &crate::library::models::TrackItem,
|
||||
) {
|
||||
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let artist = track.artist_line();
|
||||
state.search.query = if artist.is_empty() {
|
||||
track.title.clone()
|
||||
} else {
|
||||
format!("{} — {artist}", track.title)
|
||||
};
|
||||
state.search.similarity_source = Some(track.id);
|
||||
state.search.similarity_source_track = Some(track.clone());
|
||||
state.search.similarity_tracks.clear();
|
||||
state.search.similarity_stats = None;
|
||||
state.search.similarity_error = None;
|
||||
state.search.loading = true;
|
||||
state.search.results = None;
|
||||
state.search.fed_tracks.clear();
|
||||
state.search.fed_artists.clear();
|
||||
state.search.fed_loading = false;
|
||||
state.active_tab = crate::app::state::Tab::Global;
|
||||
if let Some(crate::app::state::GlobalView::Search { cursor }) = state.global.stack.last_mut() {
|
||||
*cursor = 0;
|
||||
} else {
|
||||
state
|
||||
.global
|
||||
.stack
|
||||
.push(crate::app::state::GlobalView::Search { cursor: 0 });
|
||||
}
|
||||
|
||||
let similarity = Arc::clone(&runtime.similarity);
|
||||
let tx = runtime.event_tx.clone();
|
||||
let track_id = track.id;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = similarity
|
||||
.search_track(track_id, 49)
|
||||
.map(|(matches, query)| (matches, query));
|
||||
let (result, query) = match result {
|
||||
Ok((results, query)) => (Ok(results), Some(query)),
|
||||
Err(err) => (Err(format!("{err:#}")), None),
|
||||
};
|
||||
let _ = tx.send(AppEvent::SimilaritySearchLoaded { seq, result, query });
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh only the local-library half of an already open search.
|
||||
///
|
||||
/// Library/device sync notifications can arrive while federated search
|
||||
@@ -142,6 +204,9 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
/// federation rows and bump the shared sequence, causing valid network
|
||||
/// 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 +227,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 +339,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.
|
||||
|
||||
@@ -32,6 +32,21 @@ 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<Vec<crate::similarity::SimilarTrack>, String>,
|
||||
query: Option<crate::similarity::QueryVector>,
|
||||
},
|
||||
/// Federated candidates and diagnostics for a track-seeded search.
|
||||
FedSimilaritySearchLoaded {
|
||||
seq: u64,
|
||||
result: Result<crate::federation::FedSimilaritySearchResults, String>,
|
||||
},
|
||||
SimilarityStatus(crate::similarity::SimilarityStatus),
|
||||
/// `None` is emitted after clearing every stored embedding.
|
||||
SimilarityProfileActivated(Option<String>),
|
||||
/// Artwork loaded and decoded for the shared art cache.
|
||||
ArtLoaded {
|
||||
key: String,
|
||||
@@ -56,6 +71,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 {
|
||||
|
||||
+448
-18
@@ -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;
|
||||
@@ -40,8 +41,13 @@ pub struct Runtime {
|
||||
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>,
|
||||
/// Keeps the last successful local-data snapshot visible while a newer
|
||||
/// one is calculated and collapses bursts of library-change events.
|
||||
pub local_library_stats_refreshing: Arc<std::sync::atomic::AtomicBool>,
|
||||
pub local_library_stats_refresh_requested: Arc<std::sync::atomic::AtomicBool>,
|
||||
pub library_network_refresh_at: Option<std::time::Instant>,
|
||||
pub library_network_refreshing: Arc<std::sync::atomic::AtomicBool>,
|
||||
pub library_network_cursors:
|
||||
@@ -61,6 +67,8 @@ 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,
|
||||
@@ -98,11 +106,49 @@ fn refresh_local_content_ids(runtime: &Runtime) {
|
||||
}
|
||||
|
||||
fn refresh_local_library_stats(runtime: &Runtime) {
|
||||
runtime
|
||||
.local_library_stats_refresh_requested
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
if runtime
|
||||
.local_library_stats_refreshing
|
||||
.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let tx = runtime.event_tx.clone();
|
||||
let refreshing = Arc::clone(&runtime.local_library_stats_refreshing);
|
||||
let requested = Arc::clone(&runtime.local_library_stats_refresh_requested);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = library.local_stats().map_err(err_string);
|
||||
let _ = tx.send(AppEvent::LocalLibraryStatsLoaded(result));
|
||||
loop {
|
||||
requested.store(false, std::sync::atomic::Ordering::Release);
|
||||
let result = library.local_stats().map_err(err_string);
|
||||
let _ = tx.send(AppEvent::LocalLibraryStatsLoaded(result));
|
||||
if requested.load(std::sync::atomic::Ordering::Acquire) {
|
||||
continue;
|
||||
}
|
||||
refreshing.store(false, std::sync::atomic::Ordering::Release);
|
||||
if requested.swap(false, std::sync::atomic::Ordering::AcqRel)
|
||||
&& !refreshing.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,6 +290,8 @@ 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:#}"));
|
||||
}
|
||||
@@ -251,11 +299,20 @@ pub async fn run(
|
||||
let devices = crate::devices::DeviceSync::new(Arc::clone(&library))?;
|
||||
devices.set_event_tx(event_tx.clone());
|
||||
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() {
|
||||
@@ -272,7 +329,10 @@ pub async fn run(
|
||||
devices,
|
||||
jam,
|
||||
federation,
|
||||
similarity,
|
||||
fed_status_at: None,
|
||||
local_library_stats_refreshing: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
local_library_stats_refresh_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
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())),
|
||||
@@ -288,6 +348,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));
|
||||
@@ -298,6 +359,9 @@ pub async fn run(
|
||||
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);
|
||||
@@ -318,11 +382,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 {
|
||||
@@ -347,6 +430,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;
|
||||
@@ -356,6 +443,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);
|
||||
@@ -453,6 +566,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 {
|
||||
@@ -1331,6 +1445,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() {
|
||||
@@ -1349,6 +1471,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();
|
||||
@@ -1478,6 +1624,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;
|
||||
@@ -1733,6 +1888,7 @@ fn is_controlled_playback_effect(effect: &Effect) -> bool {
|
||||
| Effect::SetOptions
|
||||
| Effect::RemoveQueueIndices { .. }
|
||||
| Effect::PlaybackQueueChanged
|
||||
| Effect::QueueOrderChanged { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1766,7 +1922,9 @@ 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 {
|
||||
@@ -2618,7 +2776,12 @@ fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) {
|
||||
// until then.
|
||||
state.likes_loaded = false;
|
||||
state.local_content_ids_loaded = false;
|
||||
state.local_library_stats = None;
|
||||
// Refresh in place: status cards keep the last successful snapshot
|
||||
// instead of flashing `loading` for every background library event.
|
||||
if state.local_library_stats.is_none() {
|
||||
state.local_library_stats = Some(state::Loadable::Loading);
|
||||
}
|
||||
refresh_local_library_stats(runtime);
|
||||
|
||||
// Fresh copies of whatever sits in the queue. Federated placeholders
|
||||
// and ephemeral tracks (negative ids) are not library rows and keep
|
||||
@@ -2678,6 +2841,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");
|
||||
@@ -2939,6 +3104,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);
|
||||
}
|
||||
@@ -3018,6 +3225,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());
|
||||
}
|
||||
@@ -3117,6 +3325,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
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());
|
||||
}
|
||||
@@ -3139,6 +3348,39 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
Err(message) => tracing::warn!(%message, "federated search failed"),
|
||||
}
|
||||
}
|
||||
AppEvent::FedSimilaritySearchLoaded { seq, result } => {
|
||||
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
|
||||
return;
|
||||
}
|
||||
state.search.fed_loading = false;
|
||||
let selected_key = state.global.stack.last().and_then(|view| match view {
|
||||
state::GlobalView::Search { cursor } => state.search.similarity_key(*cursor),
|
||||
_ => None,
|
||||
});
|
||||
match result {
|
||||
Ok(results) => {
|
||||
let remote = results.tracks.into_iter().map(|hit| {
|
||||
state::SimilaritySearchHit::Federated {
|
||||
track: hit.track,
|
||||
score: hit.score,
|
||||
embedding_signature: hit.embedding_signature,
|
||||
}
|
||||
});
|
||||
state.search.similarity_tracks.extend(remote);
|
||||
rank_similarity_search_tracks(
|
||||
&mut state.search.similarity_tracks,
|
||||
state.similarity.settings.max_tracks_per_artist,
|
||||
);
|
||||
state.search.similarity_stats = Some(results.stats);
|
||||
state.search.similarity_error = None;
|
||||
}
|
||||
Err(message) => {
|
||||
tracing::warn!(%message, "federated similarity search failed");
|
||||
state.search.similarity_error = Some(message);
|
||||
}
|
||||
}
|
||||
restore_similarity_cursor(state, selected_key.as_deref());
|
||||
}
|
||||
AppEvent::FedTrackResolved {
|
||||
placeholder_id,
|
||||
resolve_key,
|
||||
@@ -3273,6 +3515,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);
|
||||
@@ -3374,6 +3617,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),
|
||||
@@ -3479,6 +3723,53 @@ 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.similarity_tracks = results
|
||||
.into_iter()
|
||||
.map(|hit| state::SimilaritySearchHit::Local {
|
||||
track: hit.track,
|
||||
score: hit.score,
|
||||
embedding_signature: hit.embedding_signature,
|
||||
})
|
||||
.collect();
|
||||
rank_similarity_search_tracks(
|
||||
&mut state.search.similarity_tracks,
|
||||
state.similarity.settings.max_tracks_per_artist,
|
||||
);
|
||||
}
|
||||
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::FedSimilaritySearchLoaded { 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),
|
||||
@@ -3516,6 +3807,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());
|
||||
@@ -3581,15 +3873,17 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
tracing::warn!(%message, "local content id load failed");
|
||||
}
|
||||
},
|
||||
AppEvent::LocalLibraryStatsLoaded(result) => {
|
||||
state.local_library_stats = Some(match result {
|
||||
Ok(stats) => state::Loadable::Ready(stats),
|
||||
Err(message) => {
|
||||
tracing::warn!(%message, "local library stats load failed");
|
||||
state::Loadable::Failed(message)
|
||||
AppEvent::LocalLibraryStatsLoaded(result) => match result {
|
||||
Ok(stats) => {
|
||||
state.local_library_stats = Some(state::Loadable::Ready(stats));
|
||||
}
|
||||
Err(message) => {
|
||||
tracing::warn!(%message, "local library stats load failed");
|
||||
if !matches!(state.local_library_stats, Some(state::Loadable::Ready(_))) {
|
||||
state.local_library_stats = Some(state::Loadable::Failed(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
AppEvent::LocalContentAvailable { content_id } => {
|
||||
if let Some(content_id) = music_dht::normalize_content_id(&content_id) {
|
||||
state.local_content_ids.insert(content_id);
|
||||
@@ -3648,9 +3942,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 {
|
||||
@@ -3699,6 +3997,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);
|
||||
}
|
||||
@@ -3752,6 +4053,135 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
}
|
||||
}
|
||||
|
||||
fn rank_similarity_search_tracks(
|
||||
tracks: &mut Vec<state::SimilaritySearchHit>,
|
||||
max_tracks_per_artist: usize,
|
||||
) {
|
||||
const RESULT_LIMIT: usize = 49;
|
||||
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||
|
||||
tracks.sort_by(|left, right| right.score().total_cmp(&left.score()));
|
||||
let candidates = std::mem::take(tracks);
|
||||
let mut content = std::collections::HashSet::new();
|
||||
let mut signatures = Vec::new();
|
||||
let mut artist_counts: std::collections::HashMap<String, usize> = Default::default();
|
||||
for hit in candidates {
|
||||
if !content.insert(hit.content_key()) {
|
||||
continue;
|
||||
}
|
||||
if hit.embedding_signature().is_some_and(|candidate| {
|
||||
signatures.iter().any(|existing| {
|
||||
music_dht::similarity::signature_distance(&candidate, existing)
|
||||
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||
})
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let artist = hit.primary_artist_key();
|
||||
let count = artist_counts.entry(artist.clone()).or_default();
|
||||
if !artist.is_empty() && *count >= max_tracks_per_artist.clamp(1, RESULT_LIMIT) {
|
||||
continue;
|
||||
}
|
||||
*count += 1;
|
||||
if let Some(signature) = hit.embedding_signature() {
|
||||
signatures.push(signature);
|
||||
}
|
||||
tracks.push(hit);
|
||||
if tracks.len() >= RESULT_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_similarity_cursor(state: &mut AppState, selected_key: Option<&str>) {
|
||||
let selected_index = selected_key.and_then(|key| state.search.similarity_index_for_key(key));
|
||||
let len = state.search.similarity_len();
|
||||
if let Some(state::GlobalView::Search { cursor }) = state.global.stack.last_mut() {
|
||||
*cursor = selected_index.unwrap_or(*cursor).min(len.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod similarity_search_tests {
|
||||
use super::*;
|
||||
use crate::library::models::{ArtistRef, TrackItem};
|
||||
|
||||
fn local_hit(id: i64, artist: &str, score: f32, signature: u8) -> state::SimilaritySearchHit {
|
||||
state::SimilaritySearchHit::Local {
|
||||
track: TrackItem {
|
||||
id,
|
||||
title: format!("local {id}"),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 1.0,
|
||||
artists: vec![ArtistRef {
|
||||
id,
|
||||
name: artist.to_string(),
|
||||
}],
|
||||
featured_artists: Vec::new(),
|
||||
release_id: id,
|
||||
release_title: "release".to_string(),
|
||||
release_year: None,
|
||||
file_path: format!("/music/{id}"),
|
||||
content_id: Some(format!("local-{id}")),
|
||||
cover_path: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
},
|
||||
score,
|
||||
embedding_signature: [signature; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES],
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_hit(artist: &str, score: f32, signature: u8) -> state::SimilaritySearchHit {
|
||||
state::SimilaritySearchHit::Federated {
|
||||
track: crate::federation::FedTrack {
|
||||
item_id: format!("remote-{signature}"),
|
||||
owner: "peer".to_string(),
|
||||
own: false,
|
||||
title: format!("remote {signature}"),
|
||||
artist_names: vec![artist.to_string()],
|
||||
featured_artist_names: Vec::new(),
|
||||
year: None,
|
||||
duration_seconds: Some(1),
|
||||
content_id: Some(format!("remote-{signature}")),
|
||||
release_title: None,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
},
|
||||
score,
|
||||
embedding_signature: Some(
|
||||
[signature; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_results_rank_local_and_remote_together_with_one_artist_cap() {
|
||||
let mut tracks = vec![
|
||||
local_hit(1, "same artist", 0.70, 1),
|
||||
remote_hit("other artist", 0.90, 2),
|
||||
remote_hit("same artist", 0.80, 3),
|
||||
local_hit(2, "same artist", 0.60, 4),
|
||||
];
|
||||
|
||||
rank_similarity_search_tracks(&mut tracks, 1);
|
||||
|
||||
assert_eq!(tracks.len(), 2);
|
||||
assert_eq!(tracks[0].score(), 0.90);
|
||||
assert_eq!(tracks[1].score(), 0.80);
|
||||
assert!(matches!(
|
||||
tracks[0],
|
||||
state::SimilaritySearchHit::Federated { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror the playback state to the OS now-playing surface. `force` skips
|
||||
/// the position throttle (track switches, pauses).
|
||||
fn push_media_update(state: &AppState, runtime: &mut Runtime, force: bool) {
|
||||
|
||||
+179
-7
@@ -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,
|
||||
@@ -502,6 +570,58 @@ 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::SimilarityMinimumScore => match value.parse::<f32>() {
|
||||
Ok(score) if score.is_finite() && (0.0..=1.0).contains(&score) => {
|
||||
state.similarity.settings.minimum_score = score;
|
||||
super::perform_effect(
|
||||
state,
|
||||
runtime,
|
||||
crate::app::update::Effect::SimilarityApplySettings,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
state.status_message =
|
||||
Some("minimum similarity must be a number from 0.00 to 1.00".into());
|
||||
state.popup = Some(Popup::FedInput { field, input });
|
||||
}
|
||||
},
|
||||
FedInputField::SimilarityMaxTracksPerArtist => match value.parse::<usize>() {
|
||||
Ok(limit @ 1..=50) => {
|
||||
state.similarity.settings.max_tracks_per_artist = limit;
|
||||
super::perform_effect(
|
||||
state,
|
||||
runtime,
|
||||
crate::app::update::Effect::SimilarityApplySettings,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
state.status_message =
|
||||
Some("tracks per artist must be a number from 1 to 50".into());
|
||||
state.popup = Some(Popup::FedInput { field, input });
|
||||
}
|
||||
},
|
||||
FedInputField::ConnectTicket => {
|
||||
if value.is_empty() {
|
||||
state.status_message = Some("ticket is empty".into());
|
||||
@@ -551,6 +671,31 @@ fn handle_fed_input(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_music_directory_confirmation(
|
||||
state: &mut AppState,
|
||||
runtime: &mut Runtime,
|
||||
path: std::path::PathBuf,
|
||||
key: KeyEvent,
|
||||
) {
|
||||
let move_existing = match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => return,
|
||||
KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => true,
|
||||
KeyCode::Char('n') | KeyCode::Char('N') => false,
|
||||
_ => {
|
||||
state.popup = Some(Popup::ConfirmMusicDirectory { path });
|
||||
return;
|
||||
}
|
||||
};
|
||||
super::perform_effect(
|
||||
state,
|
||||
runtime,
|
||||
crate::app::update::Effect::ChangeMusicDirectory {
|
||||
path,
|
||||
move_existing,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_device_pairing(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
@@ -934,10 +1079,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()),
|
||||
}
|
||||
}
|
||||
@@ -947,6 +1097,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,
|
||||
|
||||
+304
-1
@@ -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 {
|
||||
@@ -508,6 +509,8 @@ pub enum TrackSelectionScope {
|
||||
Release(i64),
|
||||
Playlist(i64),
|
||||
Queue,
|
||||
/// The unified local + federated similar-track result list.
|
||||
SimilaritySearch,
|
||||
/// The federated section of the search results (its tracks).
|
||||
FedSearch,
|
||||
/// The tracklist of the open federated release view.
|
||||
@@ -686,6 +689,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 +723,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,6 +817,10 @@ impl StatusDetailFocus {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FedInputField {
|
||||
MusicDirectory,
|
||||
SimilarityWorkers,
|
||||
SimilarityMinimumScore,
|
||||
SimilarityMaxTracksPerArtist,
|
||||
NetworkId,
|
||||
ConnectTicket,
|
||||
DeviceName,
|
||||
@@ -811,6 +831,10 @@ pub enum FedInputField {
|
||||
impl FedInputField {
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
FedInputField::MusicDirectory => "Music save directory",
|
||||
FedInputField::SimilarityWorkers => "Similarity background workers",
|
||||
FedInputField::SimilarityMinimumScore => "Minimum similarity score",
|
||||
FedInputField::SimilarityMaxTracksPerArtist => "Tracks per artist",
|
||||
FedInputField::NetworkId => "Network ID",
|
||||
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
|
||||
FedInputField::DeviceName => "Device name",
|
||||
@@ -821,6 +845,18 @@ impl FedInputField {
|
||||
|
||||
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::SimilarityMinimumScore => {
|
||||
"Enter the minimum cosine similarity from 0.00 to 1.00. Lower values show broader matches; higher values hide weak matches. Embeddings are not recalculated."
|
||||
}
|
||||
FedInputField::SimilarityMaxTracksPerArtist => {
|
||||
"Enter how many tracks by one primary artist may appear in similarity results, from 1 to 50. Embeddings are not recalculated."
|
||||
}
|
||||
FedInputField::NetworkId => {
|
||||
"A unique network id. It must match exactly on every client that should see and connect to the same peers."
|
||||
}
|
||||
@@ -851,6 +887,29 @@ pub enum FedRow {
|
||||
Connect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SimilarityRow {
|
||||
Toggle,
|
||||
Model,
|
||||
Profile,
|
||||
MinimumScore,
|
||||
MaxTracksPerArtist,
|
||||
Workers,
|
||||
Clear,
|
||||
}
|
||||
|
||||
impl SimilarityRow {
|
||||
pub const ALL: [SimilarityRow; 7] = [
|
||||
SimilarityRow::Toggle,
|
||||
SimilarityRow::Model,
|
||||
SimilarityRow::Profile,
|
||||
SimilarityRow::MinimumScore,
|
||||
SimilarityRow::MaxTracksPerArtist,
|
||||
SimilarityRow::Workers,
|
||||
SimilarityRow::Clear,
|
||||
];
|
||||
}
|
||||
|
||||
impl FedRow {
|
||||
pub const ALL: [FedRow; 6] = [
|
||||
FedRow::Toggle,
|
||||
@@ -867,6 +926,8 @@ impl FedRow {
|
||||
/// the config directory.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettingsRow {
|
||||
MusicDirectory,
|
||||
Similarity(SimilarityRow),
|
||||
Federation(FedRow),
|
||||
StatusDetails,
|
||||
DeviceName,
|
||||
@@ -1004,7 +1065,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);
|
||||
@@ -1043,6 +1105,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)> {
|
||||
@@ -1064,9 +1132,180 @@ 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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SimilaritySearchHit {
|
||||
Local {
|
||||
track: TrackItem,
|
||||
score: f32,
|
||||
embedding_signature: [u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES],
|
||||
},
|
||||
Federated {
|
||||
track: crate::federation::FedTrack,
|
||||
score: f32,
|
||||
embedding_signature: Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SimilaritySearchHit {
|
||||
pub fn score(&self) -> f32 {
|
||||
match self {
|
||||
Self::Local { score, .. } | Self::Federated { score, .. } => *score,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn embedding_signature(
|
||||
&self,
|
||||
) -> Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]> {
|
||||
match self {
|
||||
Self::Local {
|
||||
embedding_signature,
|
||||
..
|
||||
} => Some(*embedding_signature),
|
||||
Self::Federated {
|
||||
embedding_signature,
|
||||
..
|
||||
} => *embedding_signature,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn track_item(&self) -> TrackItem {
|
||||
match self {
|
||||
Self::Local { track, .. } => track.clone(),
|
||||
Self::Federated { track, .. } => crate::federation::pending_track(track),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn federated_track(&self) -> Option<&crate::federation::FedTrack> {
|
||||
match self {
|
||||
Self::Federated { track, .. } => Some(track),
|
||||
Self::Local { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn primary_artist_key(&self) -> String {
|
||||
match self {
|
||||
Self::Local { track, .. } => track
|
||||
.artists
|
||||
.first()
|
||||
.map(|artist| music_dht::normalize_name(&artist.name))
|
||||
.unwrap_or_default(),
|
||||
Self::Federated { track, .. } => track
|
||||
.artist_names
|
||||
.first()
|
||||
.map(|artist| music_dht::normalize_name(artist))
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content_key(&self) -> String {
|
||||
match self {
|
||||
Self::Local { track, .. } => track
|
||||
.content_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("local:{}", track.id)),
|
||||
Self::Federated { track, .. } => track
|
||||
.content_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("remote:{}:{}", track.owner, track.item_id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SearchState {
|
||||
pub query: String,
|
||||
@@ -1079,6 +1318,62 @@ 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>,
|
||||
/// The source is pinned at row zero; candidates below it are globally
|
||||
/// ranked across the local library and federation.
|
||||
pub similarity_source_track: Option<TrackItem>,
|
||||
pub similarity_tracks: Vec<SimilaritySearchHit>,
|
||||
pub similarity_stats: Option<crate::federation::SimilaritySearchStats>,
|
||||
pub similarity_error: Option<String>,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
pub fn similarity_len(&self) -> usize {
|
||||
usize::from(self.similarity_source_track.is_some()) + self.similarity_tracks.len()
|
||||
}
|
||||
|
||||
pub fn similarity_track(&self, index: usize) -> Option<TrackItem> {
|
||||
if index == 0 {
|
||||
return self.similarity_source_track.clone();
|
||||
}
|
||||
self.similarity_tracks
|
||||
.get(index.checked_sub(1)?)
|
||||
.map(SimilaritySearchHit::track_item)
|
||||
}
|
||||
|
||||
pub fn similarity_fed_track(&self, index: usize) -> Option<&crate::federation::FedTrack> {
|
||||
self.similarity_tracks
|
||||
.get(index.checked_sub(1)?)?
|
||||
.federated_track()
|
||||
}
|
||||
|
||||
pub fn similarity_key(&self, index: usize) -> Option<String> {
|
||||
if index == 0 {
|
||||
return self
|
||||
.similarity_source_track
|
||||
.as_ref()
|
||||
.map(|track| format!("source:{}", track.id));
|
||||
}
|
||||
self.similarity_tracks
|
||||
.get(index.checked_sub(1)?)
|
||||
.map(SimilaritySearchHit::content_key)
|
||||
}
|
||||
|
||||
pub fn similarity_index_for_key(&self, key: &str) -> Option<usize> {
|
||||
if self
|
||||
.similarity_source_track
|
||||
.as_ref()
|
||||
.is_some_and(|track| key == format!("source:{}", track.id))
|
||||
{
|
||||
return Some(0);
|
||||
}
|
||||
self.similarity_tracks
|
||||
.iter()
|
||||
.position(|hit| hit.content_key() == key)
|
||||
.map(|index| index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -1169,6 +1464,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,
|
||||
@@ -1194,6 +1492,7 @@ impl Default for PlayerBar {
|
||||
Self {
|
||||
queue: Vec::new(),
|
||||
queue_pos: 0,
|
||||
play_next_end: None,
|
||||
current: None,
|
||||
playing: false,
|
||||
paused: false,
|
||||
@@ -1283,6 +1582,9 @@ 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,
|
||||
@@ -1308,6 +1610,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>)>,
|
||||
|
||||
+331
-43
@@ -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.
|
||||
@@ -180,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.
|
||||
@@ -275,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 => {
|
||||
@@ -283,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 { .. })) {
|
||||
@@ -365,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 {
|
||||
@@ -418,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;
|
||||
@@ -440,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()
|
||||
@@ -860,6 +905,9 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
|
||||
TrackSelectionScope::Queue => {
|
||||
state.queue_tab.cursor = value;
|
||||
}
|
||||
TrackSelectionScope::SimilaritySearch => {
|
||||
set_view_cursor(state, value);
|
||||
}
|
||||
TrackSelectionScope::FedSearch => {
|
||||
let base = state.search.results.as_ref().map_or(0, |r| r.len())
|
||||
+ state.search.fed_artists.len();
|
||||
@@ -907,6 +955,14 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
||||
_ => None,
|
||||
},
|
||||
GlobalView::Search { cursor } => {
|
||||
if state.search.similarity_source.is_some() {
|
||||
let len = state.search.similarity_len();
|
||||
return (*cursor < len).then_some((
|
||||
TrackSelectionScope::SimilaritySearch,
|
||||
*cursor,
|
||||
len,
|
||||
));
|
||||
}
|
||||
// Only the federated tracks section is selectable here.
|
||||
let base = state.search.results.as_ref().map_or(0, |r| r.len())
|
||||
+ state.search.fed_artists.len();
|
||||
@@ -999,6 +1055,20 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, V
|
||||
}
|
||||
|
||||
pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
|
||||
if state.active_tab == Tab::Global
|
||||
&& state.search.similarity_source.is_some()
|
||||
&& let Some(GlobalView::Search { cursor }) = state.global.stack.last()
|
||||
{
|
||||
let len = state.search.similarity_len();
|
||||
let indices = state
|
||||
.track_selection
|
||||
.indices(&TrackSelectionScope::SimilaritySearch, len)
|
||||
.unwrap_or_else(|| vec![(*cursor).min(len.saturating_sub(1))]);
|
||||
return indices
|
||||
.into_iter()
|
||||
.filter_map(|index| state.search.similarity_track(index))
|
||||
.collect();
|
||||
}
|
||||
// Federated contexts produce queueable placeholders that behave like
|
||||
// regular tracks (queue, info, playback-on-demand).
|
||||
{
|
||||
@@ -1030,6 +1100,68 @@ fn selected_queue_indices(state: &AppState) -> Vec<usize> {
|
||||
.unwrap_or_else(|| vec![state.queue_tab.cursor.min(state.player.queue.len() - 1)])
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -1081,6 +1213,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;
|
||||
|
||||
@@ -1107,6 +1243,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,
|
||||
@@ -1134,6 +1271,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,
|
||||
@@ -1163,6 +1301,9 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||
_ => None,
|
||||
},
|
||||
GlobalView::Search { cursor } => {
|
||||
if state.search.similarity_source.is_some() {
|
||||
return state.search.similarity_track(*cursor);
|
||||
}
|
||||
let results = state.search.results.as_ref()?;
|
||||
let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?;
|
||||
match results.tracks.get(offset) {
|
||||
@@ -1267,7 +1408,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}")
|
||||
@@ -1278,7 +1419,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 });
|
||||
@@ -1372,19 +1513,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 {
|
||||
@@ -1394,14 +1538,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
|
||||
@@ -1426,6 +1584,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)
|
||||
}
|
||||
|
||||
@@ -1464,9 +1623,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;
|
||||
@@ -1744,10 +1905,13 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
||||
refresh_track_selection_cursor(state);
|
||||
}
|
||||
Some(GlobalView::Search { cursor }) => {
|
||||
// Local results plus the federated section below them.
|
||||
let total = (state.search.results.as_ref().map_or(0, |r| r.len())
|
||||
+ state.search.fed_artists.len()
|
||||
+ state.search.fed_tracks.len()) as isize;
|
||||
let total = if state.search.similarity_source.is_some() {
|
||||
state.search.similarity_len()
|
||||
} else {
|
||||
state.search.results.as_ref().map_or(0, |r| r.len())
|
||||
+ state.search.fed_artists.len()
|
||||
+ state.search.fed_tracks.len()
|
||||
} as isize;
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -1867,9 +2031,13 @@ fn current_view_len(state: &AppState) -> usize {
|
||||
_ => 0,
|
||||
},
|
||||
Some(GlobalView::Search { .. }) => {
|
||||
state.search.results.as_ref().map_or(0, |r| r.len())
|
||||
+ state.search.fed_artists.len()
|
||||
+ state.search.fed_tracks.len()
|
||||
if state.search.similarity_source.is_some() {
|
||||
state.search.similarity_len()
|
||||
} else {
|
||||
state.search.results.as_ref().map_or(0, |r| r.len())
|
||||
+ state.search.fed_artists.len()
|
||||
+ state.search.fed_tracks.len()
|
||||
}
|
||||
}
|
||||
Some(GlobalView::FedArtist { .. }) => fed_card_len(state),
|
||||
Some(GlobalView::FedRelease { index, .. }) => fed_card_release(state, *index)
|
||||
@@ -2076,6 +2244,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 {
|
||||
@@ -2158,31 +2327,47 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
},
|
||||
_ => Outcome::Nothing,
|
||||
},
|
||||
Some(GlobalView::Search { cursor }) => match &state.search.results {
|
||||
Some(results) => {
|
||||
let artists = results.artists.len();
|
||||
let releases = results.releases.len();
|
||||
if cursor < artists {
|
||||
Outcome::Push(GlobalView::Artist {
|
||||
id: results.artists[cursor].id,
|
||||
cursor: 0,
|
||||
})
|
||||
} else if cursor < artists + releases {
|
||||
Outcome::Push(GlobalView::Release {
|
||||
id: results.releases[cursor - artists].id,
|
||||
cursor: 0,
|
||||
})
|
||||
} else if results.tracks.get(cursor - artists - releases).is_some() {
|
||||
Outcome::Play {
|
||||
tracks: results.tracks.clone(),
|
||||
start: cursor - artists - releases,
|
||||
}
|
||||
Some(GlobalView::Search { cursor }) => {
|
||||
if state.search.similarity_source.is_some() {
|
||||
let tracks = (0..state.search.similarity_len())
|
||||
.filter_map(|index| state.search.similarity_track(index))
|
||||
.collect::<Vec<_>>();
|
||||
if tracks.is_empty() {
|
||||
Outcome::Nothing
|
||||
} else {
|
||||
fed_outcome(state, cursor - artists - releases - results.tracks.len())
|
||||
Outcome::Play {
|
||||
start: cursor.min(tracks.len() - 1),
|
||||
tracks,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match &state.search.results {
|
||||
Some(results) => {
|
||||
let artists = results.artists.len();
|
||||
let releases = results.releases.len();
|
||||
if cursor < artists {
|
||||
Outcome::Push(GlobalView::Artist {
|
||||
id: results.artists[cursor].id,
|
||||
cursor: 0,
|
||||
})
|
||||
} else if cursor < artists + releases {
|
||||
Outcome::Push(GlobalView::Release {
|
||||
id: results.releases[cursor - artists].id,
|
||||
cursor: 0,
|
||||
})
|
||||
} else if results.tracks.get(cursor - artists - releases).is_some() {
|
||||
Outcome::Play {
|
||||
tracks: results.tracks.clone(),
|
||||
start: cursor - artists - releases,
|
||||
}
|
||||
} else {
|
||||
fed_outcome(state, cursor - artists - releases - results.tracks.len())
|
||||
}
|
||||
}
|
||||
None => fed_outcome(state, cursor),
|
||||
}
|
||||
}
|
||||
None => fed_outcome(state, cursor),
|
||||
},
|
||||
}
|
||||
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
|
||||
Some((_, Loadable::Ready(card))) => {
|
||||
let release_indices = fed_artist_visible_release_indices(state, card);
|
||||
@@ -2358,6 +2543,20 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
|
||||
if state.active_tab != Tab::Global {
|
||||
return Vec::new();
|
||||
}
|
||||
if state.search.similarity_source.is_some()
|
||||
&& let Some(GlobalView::Search { cursor }) = state.global.stack.last()
|
||||
{
|
||||
let scope = TrackSelectionScope::SimilaritySearch;
|
||||
let len = state.search.similarity_len();
|
||||
let indices = state
|
||||
.track_selection
|
||||
.indices(&scope, len)
|
||||
.unwrap_or_else(|| vec![(*cursor).min(len.saturating_sub(1))]);
|
||||
return indices
|
||||
.into_iter()
|
||||
.filter_map(|index| state.search.similarity_fed_track(index).cloned())
|
||||
.collect();
|
||||
}
|
||||
// An active Shift-V range in a federated scope.
|
||||
if let Some(scope) = state.track_selection.scope.clone() {
|
||||
match scope {
|
||||
@@ -2614,8 +2813,86 @@ 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::MinimumScore) => {
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::SimilarityMinimumScore,
|
||||
input: crate::app::input::LineEdit::new(format!(
|
||||
"{:.2}",
|
||||
state.similarity.settings.minimum_score
|
||||
)),
|
||||
});
|
||||
None
|
||||
}
|
||||
SettingsRow::Similarity(SimilarityRow::MaxTracksPerArtist) => {
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::SimilarityMaxTracksPerArtist,
|
||||
input: crate::app::input::LineEdit::new(
|
||||
state.similarity.settings.max_tracks_per_artist.to_string(),
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
SettingsRow::Similarity(SimilarityRow::Workers) => {
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::SimilarityWorkers,
|
||||
input: crate::app::input::LineEdit::new(
|
||||
state.similarity.settings.workers.to_string(),
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
SettingsRow::Similarity(SimilarityRow::Clear) => {
|
||||
state.popup = Some(Popup::ConfirmClearEmbeddings);
|
||||
None
|
||||
}
|
||||
SettingsRow::Federation(FedRow::Toggle) => {
|
||||
let settings = &mut state.federation.settings;
|
||||
if !settings.enabled && settings.network_id.trim().is_empty() {
|
||||
@@ -2625,7 +2902,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) => {
|
||||
@@ -2789,6 +3076,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()));
|
||||
|
||||
+79
-3
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
+111
-38
@@ -1,55 +1,64 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LibrarySourceMode {
|
||||
Local,
|
||||
My,
|
||||
#[default]
|
||||
Global,
|
||||
pub use furumi_library::{LibraryFilters, LibrarySourceMode};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SimilaritySettings {
|
||||
/// Local embedding/search master switch. Network participation follows
|
||||
/// federation and additionally requires the explicit privacy consent.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_similarity_model")]
|
||||
pub model: String,
|
||||
#[serde(default = "default_similarity_profile")]
|
||||
pub profile: String,
|
||||
#[serde(default = "default_similarity_workers")]
|
||||
pub workers: usize,
|
||||
/// Requester-side cosine score floor. This is search policy, not part of
|
||||
/// the embedding profile, so changing it never invalidates vectors.
|
||||
#[serde(default = "default_similarity_minimum_score")]
|
||||
pub minimum_score: f32,
|
||||
/// Requester-side diversity cap applied independently to local and
|
||||
/// federated candidates.
|
||||
#[serde(default = "default_similarity_max_tracks_per_artist")]
|
||||
pub max_tracks_per_artist: usize,
|
||||
#[serde(default)]
|
||||
pub federation_consent: bool,
|
||||
/// Exact fingerprint of the last fully usable profile. Keeping this
|
||||
/// separate from the selected target lets an old index serve searches
|
||||
/// while a newly selected model/profile is being calculated.
|
||||
#[serde(default)]
|
||||
pub active_profile: Option<String>,
|
||||
}
|
||||
|
||||
impl LibrarySourceMode {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
LibrarySourceMode::Local => "Local",
|
||||
LibrarySourceMode::My => "My",
|
||||
LibrarySourceMode::Global => "Global",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn includes_network(self) -> bool {
|
||||
!matches!(self, LibrarySourceMode::Local)
|
||||
}
|
||||
|
||||
pub fn includes_global_peers(self) -> bool {
|
||||
matches!(self, LibrarySourceMode::Global)
|
||||
}
|
||||
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
LibrarySourceMode::Local => LibrarySourceMode::My,
|
||||
LibrarySourceMode::My => LibrarySourceMode::Global,
|
||||
LibrarySourceMode::Global => LibrarySourceMode::Local,
|
||||
impl Default for SimilaritySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
model: default_similarity_model(),
|
||||
profile: default_similarity_profile(),
|
||||
workers: default_similarity_workers(),
|
||||
minimum_score: default_similarity_minimum_score(),
|
||||
max_tracks_per_artist: default_similarity_max_tracks_per_artist(),
|
||||
federation_consent: false,
|
||||
active_profile: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LibraryFilters {
|
||||
#[serde(default)]
|
||||
pub hide_featured_only: bool,
|
||||
#[serde(default)]
|
||||
pub source_mode: LibrarySourceMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, 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 +66,8 @@ impl Default for AppSettings {
|
||||
Self {
|
||||
volume: default_volume(),
|
||||
library: LibraryFilters::default(),
|
||||
music_dir: default_music_dir(),
|
||||
similarity: SimilaritySettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +75,21 @@ 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);
|
||||
if !self.similarity.minimum_score.is_finite() {
|
||||
self.similarity.minimum_score = default_similarity_minimum_score();
|
||||
}
|
||||
self.similarity.minimum_score = self.similarity.minimum_score.clamp(0.0, 1.0);
|
||||
self.similarity.max_tracks_per_artist = self.similarity.max_tracks_per_artist.clamp(1, 50);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -72,6 +98,36 @@ 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)
|
||||
}
|
||||
|
||||
fn default_similarity_minimum_score() -> f32 {
|
||||
0.70
|
||||
}
|
||||
|
||||
fn default_similarity_max_tracks_per_artist() -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
/// The historical permanent-download location, kept as the default for
|
||||
/// backward compatibility with existing installations.
|
||||
pub fn default_music_dir() -> PathBuf {
|
||||
crate::config::project_dirs()
|
||||
.map(|dirs| dirs.data_dir().join("federation-media"))
|
||||
.unwrap_or_else(|| PathBuf::from("federation-media"))
|
||||
}
|
||||
|
||||
pub fn load() -> (AppSettings, Option<String>) {
|
||||
let Some(path) = settings_path() else {
|
||||
return (AppSettings::default(), None);
|
||||
@@ -129,5 +185,22 @@ hide_featured_only = true
|
||||
assert_eq!(settings.volume, 100);
|
||||
assert!(settings.library.hide_featured_only);
|
||||
assert_eq!(settings.library.source_mode, LibrarySourceMode::Global);
|
||||
assert_eq!(settings.music_dir, default_music_dir());
|
||||
assert_eq!(settings.similarity.minimum_score, 0.70);
|
||||
assert_eq!(settings.similarity.max_tracks_per_artist, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_search_policy_is_normalized_without_changing_the_profile() {
|
||||
let mut settings = AppSettings::default();
|
||||
let profile = settings.similarity.profile.clone();
|
||||
settings.similarity.minimum_score = f32::NAN;
|
||||
settings.similarity.max_tracks_per_artist = 0;
|
||||
|
||||
let settings = settings.normalized();
|
||||
|
||||
assert_eq!(settings.similarity.minimum_score, 0.70);
|
||||
assert_eq!(settings.similarity.max_tracks_per_artist, 1);
|
||||
assert_eq!(settings.similarity.profile, profile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,8 @@ mod tests {
|
||||
"music_dht",
|
||||
"catalog",
|
||||
"audio",
|
||||
"similarity",
|
||||
"similarity_dht",
|
||||
"device_sync",
|
||||
"jam",
|
||||
] {
|
||||
|
||||
+295
-109
@@ -15,6 +15,7 @@
|
||||
mod audio;
|
||||
mod capabilities;
|
||||
pub mod catalog;
|
||||
mod similarity;
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -24,6 +25,8 @@ use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::similarity_dht::SimilarityDht;
|
||||
use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN;
|
||||
use music_dht::{
|
||||
ByteStream, ByteStreamConnectionStats, EndpointId, ItemKind, ItemSpec, LibraryItem,
|
||||
MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats, RendezvousConfig,
|
||||
@@ -39,6 +42,7 @@ 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);
|
||||
@@ -334,44 +338,29 @@ pub struct FedSearchResults {
|
||||
pub tracks: Vec<FedTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoredFedTrack {
|
||||
pub track: FedTrack,
|
||||
pub score: f32,
|
||||
pub embedding_signature: Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SimilaritySearchStats {
|
||||
pub tracks: usize,
|
||||
pub artists: usize,
|
||||
pub peers_queried: usize,
|
||||
pub elapsed_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FedSimilaritySearchResults {
|
||||
pub tracks: Vec<ScoredFedTrack>,
|
||||
pub stats: SimilaritySearchStats,
|
||||
}
|
||||
|
||||
/// A track found through federated search.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FedTrack {
|
||||
/// Hex item id in the DHT (the key audio is requested by).
|
||||
pub item_id: String,
|
||||
/// Hex endpoint id of the owning peer.
|
||||
pub owner: String,
|
||||
/// The item is published by this very instance.
|
||||
pub own: bool,
|
||||
pub title: String,
|
||||
pub artist_names: Vec<String>,
|
||||
pub featured_artist_names: Vec<String>,
|
||||
pub year: Option<i32>,
|
||||
pub duration_seconds: Option<i64>,
|
||||
/// Stable audio content id (`b3:<64 hex>`) when the owner published it.
|
||||
pub content_id: Option<String>,
|
||||
/// Release context, known when the track came from an artist card.
|
||||
pub release_title: Option<String>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
}
|
||||
|
||||
impl FedTrack {
|
||||
pub fn artist_line(&self) -> String {
|
||||
artist_line(&self.artist_names, &self.featured_artist_names)
|
||||
}
|
||||
|
||||
pub fn owner_short(&self) -> String {
|
||||
self.owner.chars().take(10).collect()
|
||||
}
|
||||
|
||||
pub fn duration_label(&self) -> String {
|
||||
match self.duration_seconds {
|
||||
Some(total) => format!("{}:{:02}", total / 60, total % 60),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use furumi_library::FederatedTrack as FedTrack;
|
||||
|
||||
/// Live status snapshot rendered on the Federation tab.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -411,6 +400,7 @@ pub struct NetworkLibrarySource {
|
||||
|
||||
struct Running {
|
||||
service: Arc<MusicDhtService>,
|
||||
similarity_dht: Arc<SimilarityDht>,
|
||||
network_name: String,
|
||||
network_id: NetworkId,
|
||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
@@ -420,9 +410,12 @@ 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>>,
|
||||
@@ -524,6 +517,8 @@ impl Federation {
|
||||
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
|
||||
@@ -534,10 +529,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| {
|
||||
@@ -545,13 +536,16 @@ 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),
|
||||
@@ -566,6 +560,40 @@ 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;
|
||||
@@ -657,10 +685,15 @@ 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 have their own
|
||||
// versioned contract and survive catalog-schema upgrades.
|
||||
.schema_independent_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)
|
||||
// Signed LSH summaries form their own upgrade-safe DHT overlay.
|
||||
.schema_independent_stream_protocol(SIMILARITY_DHT_ALPN)
|
||||
// Informational application/protocol versions.
|
||||
.schema_independent_stream_protocol(capabilities::CAPABILITIES_ALPN)
|
||||
.build()
|
||||
@@ -675,6 +708,25 @@ impl Federation {
|
||||
"federation started"
|
||||
);
|
||||
|
||||
let similarity_dht = SimilarityDht::open(
|
||||
Arc::clone(&service),
|
||||
self.data_dir.join("similarity-routing.sqlite3"),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to start the similarity DHT: {err}"))?;
|
||||
let similarity_dht_acceptor = service
|
||||
.stream_acceptor(SIMILARITY_DHT_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take similarity DHT acceptor: {err}"))?;
|
||||
let similarity_dht_serve_task =
|
||||
tokio::spawn(Arc::clone(&similarity_dht).serve(similarity_dht_acceptor));
|
||||
let similarity_dht_maintenance_task =
|
||||
tokio::spawn(Arc::clone(&similarity_dht).maintenance());
|
||||
let similarity_dht_sync_task = tokio::spawn(similarity_route_sync_loop(
|
||||
Arc::clone(&similarity_dht),
|
||||
Arc::clone(&self.similarity),
|
||||
Arc::clone(&self.library),
|
||||
));
|
||||
|
||||
// Drain DHT events into the log; the channel is bounded.
|
||||
let event_task = tokio::spawn(async move {
|
||||
while let Some(event) = events.recv().await {
|
||||
@@ -711,6 +763,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}"))?;
|
||||
@@ -746,6 +807,7 @@ impl Federation {
|
||||
|
||||
*guard = Some(Running {
|
||||
service,
|
||||
similarity_dht,
|
||||
network_name,
|
||||
network_id,
|
||||
tasks: vec![
|
||||
@@ -753,12 +815,16 @@ 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,
|
||||
similarity_dht_serve_task,
|
||||
similarity_dht_maintenance_task,
|
||||
similarity_dht_sync_task,
|
||||
],
|
||||
});
|
||||
self.set_error(None);
|
||||
@@ -783,6 +849,20 @@ impl Federation {
|
||||
.context("federation is not running")
|
||||
}
|
||||
|
||||
async fn similarity_services(&self) -> Result<(Arc<MusicDhtService>, Arc<SimilarityDht>)> {
|
||||
self.running
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|running| {
|
||||
(
|
||||
Arc::clone(&running.service),
|
||||
Arc::clone(&running.similarity_dht),
|
||||
)
|
||||
})
|
||||
.context("federation is not running")
|
||||
}
|
||||
|
||||
fn ensure_connected_devices_enabled(&self) -> Result<()> {
|
||||
let settings = self.settings();
|
||||
anyhow::ensure!(
|
||||
@@ -1112,6 +1192,30 @@ 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<FedSimilaritySearchResults> {
|
||||
anyhow::ensure!(
|
||||
self.similarity.network_allowed(),
|
||||
"similarity federation has no consent"
|
||||
);
|
||||
let (service, similarity_dht) = self.similarity_services().await?;
|
||||
let settings = self.similarity.settings();
|
||||
similarity::search(
|
||||
service,
|
||||
similarity_dht,
|
||||
query,
|
||||
limit,
|
||||
settings.minimum_score,
|
||||
Arc::clone(&self.transport_stats),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolves a share-link content id to one playable federated track.
|
||||
///
|
||||
/// Resolution order: the in-session metadata cache, the DHT content key,
|
||||
@@ -1403,29 +1507,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"] {
|
||||
@@ -1489,7 +1585,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
|
||||
}
|
||||
|
||||
@@ -1504,7 +1600,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
|
||||
}
|
||||
|
||||
@@ -1558,6 +1654,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;
|
||||
|
||||
@@ -1636,8 +1737,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
|
||||
};
|
||||
@@ -1698,8 +1801,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
|
||||
@@ -1711,7 +1820,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");
|
||||
}
|
||||
@@ -1938,20 +2053,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)?;
|
||||
@@ -1960,6 +2073,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) {
|
||||
@@ -2441,28 +2564,6 @@ fn push_artist_once(names: &mut Vec<String>, name: &str) {
|
||||
names.push(name.to_string());
|
||||
}
|
||||
|
||||
fn artist_line(artists: &[String], featured_artists: &[String]) -> String {
|
||||
let mut main = Vec::new();
|
||||
for artist in artists {
|
||||
push_artist_once(&mut main, artist);
|
||||
}
|
||||
let mut featured = Vec::new();
|
||||
for artist in featured_artists {
|
||||
if !main
|
||||
.iter()
|
||||
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
|
||||
{
|
||||
push_artist_once(&mut featured, artist);
|
||||
}
|
||||
}
|
||||
match (main.is_empty(), featured.is_empty()) {
|
||||
(false, false) => format!("{} feat. {}", main.join(", "), featured.join(", ")),
|
||||
(false, true) => main.join(", "),
|
||||
(true, false) => format!("feat. {}", featured.join(", ")),
|
||||
(true, true) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_fed_appearances(appearances: &mut [FedAppearsOn]) {
|
||||
appearances.sort_by(|a, b| {
|
||||
b.year
|
||||
@@ -2479,6 +2580,77 @@ fn sort_fed_appearances(appearances: &mut [FedAppearsOn]) {
|
||||
});
|
||||
}
|
||||
|
||||
async fn similarity_route_sync_loop(
|
||||
routing: Arc<SimilarityDht>,
|
||||
similarity: Arc<crate::similarity::Manager>,
|
||||
library: Arc<Library>,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut published_marker: Option<(String, blake3::Hash)> = None;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if !similarity.network_allowed() {
|
||||
if published_marker.take().is_some() {
|
||||
routing.clear_local_signatures();
|
||||
tracing::info!("local similarity DHT publication disabled");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let status = similarity.status();
|
||||
let Some(profile_id) = status.active_profile else {
|
||||
continue;
|
||||
};
|
||||
if status.phase != crate::similarity::Phase::Ready {
|
||||
continue;
|
||||
}
|
||||
let library = Arc::clone(&library);
|
||||
let profile_for_task = profile_id.clone();
|
||||
let loaded = tokio::task::spawn_blocking(move || {
|
||||
let signatures = library.similarity_routing_signatures(&profile_for_task)?;
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for signature in &signatures {
|
||||
hasher.update(signature);
|
||||
}
|
||||
Ok::<_, anyhow::Error>((signatures, hasher.finalize()))
|
||||
})
|
||||
.await;
|
||||
let (signatures, fingerprint) = match loaded {
|
||||
Ok(Ok(loaded)) => loaded,
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(%error, %profile_id, "similarity routing signatures unavailable");
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "similarity routing signature task failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let marker = (profile_id.clone(), fingerprint);
|
||||
if published_marker.as_ref() == Some(&marker) {
|
||||
continue;
|
||||
}
|
||||
match routing
|
||||
.sync_local_signatures(profile_id.clone(), signatures)
|
||||
.await
|
||||
{
|
||||
Ok(stats) => {
|
||||
tracing::info!(
|
||||
profile = %profile_id,
|
||||
records = stats.records,
|
||||
keys = stats.keys,
|
||||
remote_nodes = stats.remote_nodes,
|
||||
"local similarity DHT index synchronized"
|
||||
);
|
||||
published_marker = Some(marker);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, %profile_id, "similarity DHT synchronization failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_running(running: Option<Running>) {
|
||||
let Some(running) = running else { return };
|
||||
for task in &running.tasks {
|
||||
@@ -2546,29 +2718,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
//! Furumi policy and local-index adapter for the shared similarity protocol.
|
||||
//!
|
||||
//! `music_dht::similarity` owns the versioned wire contract and framing. This
|
||||
//! module owns application policy: consent, peer fan-out, local index access,
|
||||
//! result conversion, deduplication, and ranking limits.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use futures_util::stream::{self, StreamExt as _};
|
||||
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
||||
use music_dht::similarity_dht::SimilarityDht;
|
||||
use music_dht::{
|
||||
ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor,
|
||||
};
|
||||
|
||||
use crate::federation::{
|
||||
FedSimilaritySearchResults, FedTrack, ScoredFedTrack, SimilaritySearchStats, TransportStats,
|
||||
};
|
||||
use crate::similarity::{Manager, QueryVector};
|
||||
|
||||
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||
|
||||
const INITIAL_QUERY_PEERS: usize = 16;
|
||||
const MAX_QUERY_PEERS: usize = 48;
|
||||
const QUERY_CONCURRENCY: usize = 8;
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const ROUTING_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||
|
||||
pub async fn serve_peers(
|
||||
mut acceptor: StreamAcceptor,
|
||||
similarity: Arc<Manager>,
|
||||
own: EndpointId,
|
||||
transport: Arc<TransportStats>,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let similarity = Arc::clone(&similarity);
|
||||
let transport = Arc::clone(&transport);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_one(stream, similarity, own, transport).await {
|
||||
tracing::warn!(peer = %peer, "similarity request failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(
|
||||
mut stream: ByteStream,
|
||||
similarity: Arc<Manager>,
|
||||
own: EndpointId,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<()> {
|
||||
super::record_stream_transport(&transport, "similarity", "inbound", "open", &stream);
|
||||
let request = wire::read_request(&mut stream).await?;
|
||||
let response = if !similarity.network_allowed() {
|
||||
SimilarityResponse::refused("similarity federation is disabled or has no privacy consent")?
|
||||
} else {
|
||||
let profile = request.profile_id;
|
||||
let vector = request.vector;
|
||||
let limit = request.limit;
|
||||
let matches = tokio::task::spawn_blocking(move || {
|
||||
similarity.search_vector_for_peer(&profile, &vector, limit)
|
||||
})
|
||||
.await
|
||||
.context("local similarity task failed")
|
||||
.and_then(|result| result);
|
||||
match matches {
|
||||
Ok(matches) => {
|
||||
let hits = matches
|
||||
.into_iter()
|
||||
.filter_map(|found| {
|
||||
let track = found.track;
|
||||
let hit = SimilarityHit {
|
||||
score: found.score,
|
||||
item_id: super::audio::hex_encode(
|
||||
ItemId::derive(
|
||||
&own,
|
||||
ItemKind::Track,
|
||||
&format!("track:{}", track.id),
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
title: track.title,
|
||||
artist_names: track
|
||||
.artists
|
||||
.into_iter()
|
||||
.map(|artist| artist.name)
|
||||
.collect(),
|
||||
featured_artist_names: track
|
||||
.featured_artists
|
||||
.into_iter()
|
||||
.map(|artist| artist.name)
|
||||
.collect(),
|
||||
year: track.release_year,
|
||||
duration_seconds: Some(track.duration_seconds.round() as i64),
|
||||
content_id: track.content_id,
|
||||
release_title: Some(track.release_title),
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
embedding_signature: Some(found.embedding_signature),
|
||||
};
|
||||
match hit.validate() {
|
||||
Ok(()) => Some(hit),
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "invalid local similarity metadata skipped");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
SimilarityResponse::success(hits)?
|
||||
}
|
||||
Err(err) => {
|
||||
SimilarityResponse::refused(format!("similarity query is unavailable: {err:#}"))?
|
||||
}
|
||||
}
|
||||
};
|
||||
wire::write_response(&mut stream, &response).await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
super::record_stream_transport(&transport, "similarity", "inbound", "done", &stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
service: Arc<MusicDhtService>,
|
||||
routing: Arc<SimilarityDht>,
|
||||
query: QueryVector,
|
||||
limit: usize,
|
||||
minimum_score: f32,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<FedSimilaritySearchResults> {
|
||||
let started = Instant::now();
|
||||
let own = service.endpoint_id();
|
||||
let routed = match tokio::time::timeout(
|
||||
ROUTING_TIMEOUT,
|
||||
routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(peers)) => peers,
|
||||
Err(_) => {
|
||||
tracing::debug!("similarity DHT lookup timed out; using known peers");
|
||||
Vec::new()
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
tracing::debug!(%error, "similarity DHT lookup unavailable; using known peers");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let mut seen = HashSet::new();
|
||||
let mut peers: Vec<QueryPeer> = routed
|
||||
.into_iter()
|
||||
.filter_map(|ticket| {
|
||||
let owner = ticket.endpoint_id();
|
||||
(owner != own && seen.insert(owner)).then_some(QueryPeer {
|
||||
owner,
|
||||
ticket: Some(ticket),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for peer in service
|
||||
.connected_peers()
|
||||
.into_iter()
|
||||
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
||||
{
|
||||
if peer != own && seen.insert(peer) {
|
||||
peers.push(QueryPeer {
|
||||
owner: peer,
|
||||
ticket: None,
|
||||
});
|
||||
}
|
||||
if peers.len() >= MAX_QUERY_PEERS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let query_signature = wire::embedding_signature(&query.vector)?;
|
||||
let request = Arc::new(SimilarityRequest::new(
|
||||
query.profile_id,
|
||||
query.vector,
|
||||
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
||||
)?);
|
||||
|
||||
let mut hits = Vec::new();
|
||||
let initial = peers.len().min(INITIAL_QUERY_PEERS);
|
||||
let responses = query_peers(
|
||||
Arc::clone(&service),
|
||||
&peers[..initial],
|
||||
Arc::clone(&request),
|
||||
Arc::clone(&transport),
|
||||
)
|
||||
.await;
|
||||
let mut successful = 0usize;
|
||||
let mut peers_queried = initial;
|
||||
for response in responses {
|
||||
match response {
|
||||
Ok(peer_hits) => {
|
||||
successful += 1;
|
||||
hits.extend(peer_hits);
|
||||
}
|
||||
Err(err) => tracing::debug!(%err, "similarity peer query skipped"),
|
||||
}
|
||||
}
|
||||
if initial < peers.len() && (hits.len() < limit || successful < initial.min(4)) {
|
||||
peers_queried = peers.len();
|
||||
for response in query_peers(
|
||||
Arc::clone(&service),
|
||||
&peers[initial..],
|
||||
Arc::clone(&request),
|
||||
Arc::clone(&transport),
|
||||
)
|
||||
.await
|
||||
{
|
||||
match response {
|
||||
Ok(peer_hits) => hits.extend(peer_hits),
|
||||
Err(err) => tracing::debug!(%err, "fallback similarity peer query skipped"),
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||
let mut dedup = HashSet::new();
|
||||
let mut embedding_signatures = vec![query_signature];
|
||||
let mut tracks = Vec::new();
|
||||
for (track, score, embedding_signature) in hits {
|
||||
if score < minimum_score {
|
||||
break;
|
||||
}
|
||||
if query
|
||||
.source_content_id
|
||||
.as_deref()
|
||||
.is_some_and(|source| track.content_id.as_deref() == Some(source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let key = track
|
||||
.content_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}:{}", track.owner, track.item_id));
|
||||
if !dedup.insert(key) {
|
||||
continue;
|
||||
}
|
||||
if embedding_signature.is_some_and(|candidate| {
|
||||
embedding_signatures.iter().any(|existing| {
|
||||
wire::signature_distance(&candidate, existing)
|
||||
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||
})
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if let Some(signature) = embedding_signature {
|
||||
embedding_signatures.push(signature);
|
||||
}
|
||||
tracks.push(ScoredFedTrack {
|
||||
track,
|
||||
score,
|
||||
embedding_signature,
|
||||
});
|
||||
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let artists = tracks
|
||||
.iter()
|
||||
.filter_map(|hit| hit.track.artist_names.first())
|
||||
.map(|name| music_dht::normalize_name(name))
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect::<HashSet<_>>()
|
||||
.len();
|
||||
let elapsed_ms = started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
|
||||
Ok(FedSimilaritySearchResults {
|
||||
stats: SimilaritySearchStats {
|
||||
tracks: tracks.len(),
|
||||
artists,
|
||||
peers_queried,
|
||||
elapsed_ms,
|
||||
},
|
||||
tracks,
|
||||
})
|
||||
}
|
||||
|
||||
type PeerHits = Vec<(
|
||||
FedTrack,
|
||||
f32,
|
||||
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
||||
)>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct QueryPeer {
|
||||
owner: EndpointId,
|
||||
ticket: Option<PeerTicket>,
|
||||
}
|
||||
|
||||
async fn query_peers(
|
||||
service: Arc<MusicDhtService>,
|
||||
peers: &[QueryPeer],
|
||||
request: Arc<SimilarityRequest>,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Vec<Result<PeerHits>> {
|
||||
stream::iter(peers.iter().cloned().map(|peer| {
|
||||
let service = Arc::clone(&service);
|
||||
let request = Arc::clone(&request);
|
||||
let transport = Arc::clone(&transport);
|
||||
async move {
|
||||
tokio::time::timeout(
|
||||
QUERY_TIMEOUT,
|
||||
query_peer(service, peer, &request, transport),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(QUERY_CONCURRENCY)
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn query_peer(
|
||||
service: Arc<MusicDhtService>,
|
||||
peer: QueryPeer,
|
||||
request: &SimilarityRequest,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<PeerHits> {
|
||||
let owner = peer.owner;
|
||||
let mut stream = match peer.ticket {
|
||||
Some(ticket) => service.open_stream_to(&ticket, SIMILARITY_ALPN).await,
|
||||
None => service.open_stream(owner, SIMILARITY_ALPN).await,
|
||||
}
|
||||
.map_err(|err| anyhow::anyhow!("cannot reach similarity peer: {err}"))?;
|
||||
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
|
||||
let response = wire::exchange(&mut stream, request).await?;
|
||||
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
||||
anyhow::ensure!(
|
||||
response.ok,
|
||||
"peer refused similarity query: {}",
|
||||
response.error.unwrap_or_default()
|
||||
);
|
||||
Ok(response
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| {
|
||||
let score = hit.score;
|
||||
let embedding_signature = hit.embedding_signature;
|
||||
(
|
||||
FedTrack {
|
||||
item_id: hit.item_id,
|
||||
owner: owner.to_string(),
|
||||
own: false,
|
||||
title: hit.title,
|
||||
artist_names: hit.artist_names,
|
||||
featured_artist_names: hit.featured_artist_names,
|
||||
year: hit.year,
|
||||
duration_seconds: hit.duration_seconds,
|
||||
content_id: hit.content_id,
|
||||
release_title: hit.release_title,
|
||||
track_number: hit.track_number,
|
||||
disc_number: hit.disc_number,
|
||||
},
|
||||
score,
|
||||
embedding_signature,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -1,561 +0,0 @@
|
||||
//! Importing audio files into the library: directory scanning, tag reading
|
||||
//! (via lofty) and cover extraction. Importing the same file again updates
|
||||
//! its metadata instead of duplicating it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use lofty::file::{AudioFile as _, TaggedFileExt as _};
|
||||
use lofty::picture::MimeType;
|
||||
use lofty::tag::{Accessor as _, ItemKey};
|
||||
use rusqlite::{OptionalExtension as _, params};
|
||||
|
||||
use super::{Library, audio_content_id, find_or_create_artist};
|
||||
|
||||
/// Extensions the playback engine can decode (rodio/symphonia feature set).
|
||||
const AUDIO_EXTENSIONS: [&str; 8] = ["mp3", "flac", "ogg", "oga", "wav", "m4a", "mp4", "aac"];
|
||||
|
||||
/// Everything known about one audio file, ready to be written to the DB.
|
||||
#[derive(Debug)]
|
||||
pub struct TrackImport {
|
||||
pub file_path: String,
|
||||
pub title: String,
|
||||
pub artists: Vec<String>,
|
||||
pub featured_artists: Vec<String>,
|
||||
pub album_artists: Vec<String>,
|
||||
pub release_title: String,
|
||||
/// Release type ("album", "single", ...) when known from a richer
|
||||
/// source than file tags (e.g. federation metadata); None = "album".
|
||||
pub release_type: Option<String>,
|
||||
pub year: Option<i32>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
pub duration_seconds: f64,
|
||||
pub audio_format: Option<String>,
|
||||
pub audio_bitrate: Option<i32>,
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
pub audio_bit_depth: Option<i32>,
|
||||
pub file_size_bytes: Option<i64>,
|
||||
/// Embedded cover art (bytes, file extension), if any.
|
||||
pub cover: Option<(Vec<u8>, &'static str)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ImportOutcome {
|
||||
pub added: usize,
|
||||
pub updated: usize,
|
||||
pub failed: Vec<(PathBuf, String)>,
|
||||
}
|
||||
|
||||
impl ImportOutcome {
|
||||
pub fn summary(&self) -> String {
|
||||
let mut message = format!("imported {} track(s)", self.added);
|
||||
if self.updated > 0 {
|
||||
message.push_str(&format!(", updated {}", self.updated));
|
||||
}
|
||||
if !self.failed.is_empty() {
|
||||
message.push_str(&format!(", {} failed", self.failed.len()));
|
||||
}
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a file or a directory (recursively). `progress(done, total, name)`
|
||||
/// is called after every file.
|
||||
pub fn import_path(
|
||||
library: &Library,
|
||||
path: &Path,
|
||||
mut progress: impl FnMut(usize, usize, &str),
|
||||
) -> Result<ImportOutcome> {
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.with_context(|| format!("{} does not exist", path.display()))?;
|
||||
let mut files = Vec::new();
|
||||
collect_audio_files(&path, &mut files);
|
||||
anyhow::ensure!(
|
||||
!files.is_empty(),
|
||||
"no audio files found at {} (supported: {})",
|
||||
path.display(),
|
||||
AUDIO_EXTENSIONS.join(", ")
|
||||
);
|
||||
files.sort();
|
||||
|
||||
let total = files.len();
|
||||
let mut outcome = ImportOutcome::default();
|
||||
for (index, file) in files.iter().enumerate() {
|
||||
match read_file(file).and_then(|import| upsert_track(library, &import)) {
|
||||
Ok((_, created)) => {
|
||||
if created {
|
||||
outcome.added += 1;
|
||||
} else {
|
||||
outcome.updated += 1;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(file = %file.display(), %err, "import failed");
|
||||
outcome.failed.push((file.clone(), format!("{err:#}")));
|
||||
}
|
||||
}
|
||||
let name = file
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
progress(index + 1, total, &name);
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn collect_audio_files(path: &Path, files: &mut Vec<PathBuf>) {
|
||||
if path.is_dir() {
|
||||
let Ok(entries) = std::fs::read_dir(path) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
collect_audio_files(&entry.path(), files);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase());
|
||||
if extension.is_some_and(|ext| AUDIO_EXTENSIONS.contains(&ext.as_str())) {
|
||||
files.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
/// Read tags and audio properties from one file.
|
||||
pub fn read_file(path: &Path) -> Result<TrackImport> {
|
||||
let tagged = lofty::read_from_path(path).context("cannot read tags")?;
|
||||
let properties = tagged.properties();
|
||||
let tag = tagged.primary_tag().or_else(|| tagged.first_tag());
|
||||
|
||||
let fallback_title = path
|
||||
.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let (mut title, artist_raw, album, year, track_number, disc_number, album_artist_raw, cover) =
|
||||
match tag {
|
||||
Some(tag) => (
|
||||
tag.title()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback_title),
|
||||
tag.artist().map(|value| value.into_owned()),
|
||||
tag.album()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
tag.year().and_then(|value| i32::try_from(value).ok()),
|
||||
tag.track().and_then(|value| i32::try_from(value).ok()),
|
||||
tag.disk().and_then(|value| i32::try_from(value).ok()),
|
||||
tag.get_string(&ItemKey::AlbumArtist)
|
||||
.map(|value| value.to_string()),
|
||||
tag.pictures().first().map(|picture| {
|
||||
let extension = match picture.mime_type() {
|
||||
Some(MimeType::Png) => "png",
|
||||
Some(MimeType::Gif) => "gif",
|
||||
Some(MimeType::Bmp) => "bmp",
|
||||
_ => "jpg",
|
||||
};
|
||||
(picture.data().to_vec(), extension)
|
||||
}),
|
||||
),
|
||||
None => (fallback_title, None, None, None, None, None, None, None),
|
||||
};
|
||||
|
||||
let (mut artists, mut featured) = split_artist_tag(artist_raw.as_deref().unwrap_or(""));
|
||||
// "Song (feat. X)" in the title moves X into the featured list.
|
||||
if let Some((clean_title, feat)) = extract_title_feat(&title) {
|
||||
title = clean_title;
|
||||
for name in feat {
|
||||
if !featured.iter().any(|f| f.eq_ignore_ascii_case(&name)) {
|
||||
featured.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if artists.is_empty() {
|
||||
artists.push("Unknown Artist".to_string());
|
||||
}
|
||||
let album_artists = match album_artist_raw.as_deref().map(split_artist_tag) {
|
||||
Some((main, _)) if !main.is_empty() => main,
|
||||
_ => artists.clone(),
|
||||
};
|
||||
|
||||
let metadata = std::fs::metadata(path).ok();
|
||||
Ok(TrackImport {
|
||||
file_path: path.to_string_lossy().into_owned(),
|
||||
title,
|
||||
artists,
|
||||
featured_artists: featured,
|
||||
album_artists,
|
||||
release_title: album.unwrap_or_else(|| "Unknown Album".to_string()),
|
||||
release_type: None,
|
||||
year,
|
||||
track_number,
|
||||
disc_number,
|
||||
duration_seconds: properties.duration().as_secs_f64(),
|
||||
audio_format: path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase()),
|
||||
audio_bitrate: properties
|
||||
.audio_bitrate()
|
||||
.and_then(|value| i32::try_from(value).ok()),
|
||||
audio_sample_rate: properties
|
||||
.sample_rate()
|
||||
.and_then(|value| i32::try_from(value).ok()),
|
||||
audio_bit_depth: properties.bit_depth().map(i32::from),
|
||||
file_size_bytes: metadata.map(|meta| meta.len() as i64),
|
||||
cover,
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert or update one track (matching by file path). Returns the track id
|
||||
/// and whether a new row was created.
|
||||
pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, bool)> {
|
||||
let content_id = audio_content_id(&import.file_path);
|
||||
let mut conn = library.lock();
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
// Release, keyed by (title, first album artist).
|
||||
let album_artist_id = find_or_create_artist(
|
||||
&tx,
|
||||
import
|
||||
.album_artists
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or("Unknown Artist"),
|
||||
)?;
|
||||
let release_id: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT r.id FROM releases r
|
||||
JOIN release_artists ra ON ra.release_id = r.id
|
||||
WHERE r.title = ?1 COLLATE NOCASE AND ra.artist_id = ?2",
|
||||
params![import.release_title, album_artist_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let release_id = match release_id {
|
||||
Some(id) => {
|
||||
// Fill in the year if this file is the first one to know it.
|
||||
if import.year.is_some() {
|
||||
tx.execute(
|
||||
"UPDATE releases SET year = COALESCE(year, ?2) WHERE id = ?1",
|
||||
params![id, import.year],
|
||||
)?;
|
||||
}
|
||||
id
|
||||
}
|
||||
None => {
|
||||
tx.execute(
|
||||
"INSERT INTO releases (title, release_type, year) VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
import.release_title,
|
||||
import.release_type.as_deref().unwrap_or("album"),
|
||||
import.year,
|
||||
],
|
||||
)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
for (position, name) in import.album_artists.iter().enumerate() {
|
||||
let artist_id = find_or_create_artist(&tx, name)?;
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO release_artists (release_id, artist_id, position)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![id, artist_id, position as i64],
|
||||
)?;
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
let existing: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT id FROM tracks WHERE file_path = ?1",
|
||||
[&import.file_path],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let (track_id, created) = match existing {
|
||||
Some(id) => {
|
||||
tx.execute(
|
||||
"UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4,
|
||||
duration_seconds = ?5, release_id = ?6, audio_format = ?7,
|
||||
audio_bitrate = ?8, audio_sample_rate = ?9, audio_bit_depth = ?10,
|
||||
file_size_bytes = ?11, content_id = ?12
|
||||
WHERE id = ?1",
|
||||
params![
|
||||
id,
|
||||
import.title,
|
||||
import.track_number,
|
||||
import.disc_number,
|
||||
import.duration_seconds,
|
||||
release_id,
|
||||
import.audio_format,
|
||||
import.audio_bitrate,
|
||||
import.audio_sample_rate,
|
||||
import.audio_bit_depth,
|
||||
import.file_size_bytes,
|
||||
content_id.as_deref(),
|
||||
],
|
||||
)?;
|
||||
tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?;
|
||||
(id, false)
|
||||
}
|
||||
None => {
|
||||
tx.execute(
|
||||
"INSERT INTO tracks (title, track_number, disc_number, duration_seconds,
|
||||
release_id, file_path, audio_format, audio_bitrate, audio_sample_rate,
|
||||
audio_bit_depth, file_size_bytes, content_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
import.title,
|
||||
import.track_number,
|
||||
import.disc_number,
|
||||
import.duration_seconds,
|
||||
release_id,
|
||||
import.file_path,
|
||||
import.audio_format,
|
||||
import.audio_bitrate,
|
||||
import.audio_sample_rate,
|
||||
import.audio_bit_depth,
|
||||
import.file_size_bytes,
|
||||
content_id.as_deref(),
|
||||
],
|
||||
)?;
|
||||
(tx.last_insert_rowid(), true)
|
||||
}
|
||||
};
|
||||
for (position, name) in import.artists.iter().enumerate() {
|
||||
let artist_id = find_or_create_artist(&tx, name)?;
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position)
|
||||
VALUES (?1, ?2, 'main', ?3)",
|
||||
params![track_id, artist_id, position as i64],
|
||||
)?;
|
||||
}
|
||||
for (position, name) in import.featured_artists.iter().enumerate() {
|
||||
let artist_id = find_or_create_artist(&tx, name)?;
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position)
|
||||
VALUES (?1, ?2, 'featured', ?3)",
|
||||
params![track_id, artist_id, position as i64],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Cover: a release keeps the first cover found — an image file next to
|
||||
// the audio, or the embedded picture saved into the covers directory.
|
||||
let has_cover: bool = tx
|
||||
.query_row(
|
||||
"SELECT cover_path IS NOT NULL FROM releases WHERE id = ?1",
|
||||
[release_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if !has_cover && let Some(cover_path) = resolve_cover(library, release_id, import) {
|
||||
tx.execute(
|
||||
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
|
||||
params![release_id, cover_path],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok((track_id, created))
|
||||
}
|
||||
|
||||
/// Find a cover image for the release: a cover/folder/front image in the
|
||||
/// audio file's directory, or the embedded picture written to disk.
|
||||
fn resolve_cover(library: &Library, release_id: i64, import: &TrackImport) -> Option<String> {
|
||||
let directory = Path::new(&import.file_path).parent()?;
|
||||
if let Ok(entries) = std::fs::read_dir(directory) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(|stem| stem.to_ascii_lowercase());
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase());
|
||||
let is_image = matches!(
|
||||
extension.as_deref(),
|
||||
Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "gif")
|
||||
);
|
||||
if is_image
|
||||
&& matches!(
|
||||
stem.as_deref(),
|
||||
Some("cover" | "folder" | "front" | "album")
|
||||
)
|
||||
{
|
||||
return Some(path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let (data, extension) = import.cover.as_ref()?;
|
||||
let covers_dir = library.covers_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(covers_dir) {
|
||||
tracing::warn!(%err, "cannot create covers directory");
|
||||
return None;
|
||||
}
|
||||
let path = covers_dir.join(format!("release_{release_id}.{extension}"));
|
||||
match std::fs::write(&path, data) {
|
||||
Ok(()) => Some(path.to_string_lossy().into_owned()),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, path = %path.display(), "cannot save embedded cover");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split an artist tag into (main artists, featured artists).
|
||||
/// Separators: ";" and "/" between main artists; "feat."/"ft."/"featuring"
|
||||
/// starts the featured list.
|
||||
pub fn split_artist_tag(raw: &str) -> (Vec<String>, Vec<String>) {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
let (main_part, feat_part) = match find_feat_marker(raw) {
|
||||
Some((at, marker_len)) => {
|
||||
let main = raw[..at].trim_end_matches(['(', '[', ' ', ',', '-']);
|
||||
let feat = raw[at + marker_len..].trim_end_matches([')', ']']);
|
||||
(main, feat)
|
||||
}
|
||||
None => (raw, ""),
|
||||
};
|
||||
(split_names(main_part), split_names(feat_part))
|
||||
}
|
||||
|
||||
/// The earliest "feat."/"ft."/"featuring" marker that stands as its own
|
||||
/// word — preceded by a separator and followed by a space — so artist names
|
||||
/// like "Daft Punk" are not split on the "ft" inside them.
|
||||
fn find_feat_marker(raw: &str) -> Option<(usize, usize)> {
|
||||
let lowered = raw.to_lowercase();
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for marker in ["featuring", "feat.", "feat", "ft.", "ft"] {
|
||||
for (at, _) in lowered.match_indices(marker) {
|
||||
let before_ok = raw[..at]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|c| matches!(c, ' ' | '(' | '[' | ',' | '-'));
|
||||
let after_ok = raw[at + marker.len()..].starts_with(' ');
|
||||
if before_ok && after_ok && best.is_none_or(|(current, _)| at < current) {
|
||||
best = Some((at, marker.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn split_names(raw: &str) -> Vec<String> {
|
||||
raw.split([';', '/'])
|
||||
.flat_map(|part| part.split(" & "))
|
||||
.map(|name| name.trim().trim_matches(',').trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract "(feat. X)" / "[ft. Y]" from a track title.
|
||||
fn extract_title_feat(title: &str) -> Option<(String, Vec<String>)> {
|
||||
let lowered = title.to_lowercase();
|
||||
for marker in ["(feat.", "(feat ", "(ft.", "[feat.", "[ft."] {
|
||||
if let Some(start) = lowered.find(marker) {
|
||||
let closer = if marker.starts_with('(') { ')' } else { ']' };
|
||||
let rest = &title[start + marker.len()..];
|
||||
let end = rest.find(closer)?;
|
||||
let names = split_names(&rest[..end]);
|
||||
if names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut clean = title[..start].trim_end().to_string();
|
||||
clean.push_str(rest[end + 1..].trim_end());
|
||||
return Some((clean.trim().to_string(), names));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal valid WAV file: 0.1s of silence at 8kHz mono 16-bit.
|
||||
fn write_test_wav(path: &Path) {
|
||||
let samples: u32 = 800;
|
||||
let data_len = samples * 2;
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"RIFF");
|
||||
bytes.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
bytes.extend_from_slice(b"WAVEfmt ");
|
||||
bytes.extend_from_slice(&16u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes()); // mono
|
||||
bytes.extend_from_slice(&8000u32.to_le_bytes()); // sample rate
|
||||
bytes.extend_from_slice(&16000u32.to_le_bytes()); // byte rate
|
||||
bytes.extend_from_slice(&2u16.to_le_bytes()); // block align
|
||||
bytes.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
|
||||
bytes.extend_from_slice(b"data");
|
||||
bytes.extend_from_slice(&data_len.to_le_bytes());
|
||||
bytes.resize(bytes.len() + data_len as usize, 0);
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_a_real_audio_file_end_to_end() {
|
||||
let dir = std::env::temp_dir().join(format!("furumi-import-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let wav = dir.join("My Song.wav");
|
||||
write_test_wav(&wav);
|
||||
|
||||
let db = dir.join("library.db");
|
||||
let library = Library::open(&db).unwrap();
|
||||
let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap();
|
||||
assert_eq!(outcome.added, 1);
|
||||
assert!(outcome.failed.is_empty());
|
||||
|
||||
// Untagged files fall back to the file name and placeholder names.
|
||||
let results = library.search("My Song", 10).unwrap();
|
||||
assert_eq!(results.tracks.len(), 1);
|
||||
let track = &results.tracks[0];
|
||||
assert_eq!(track.title, "My Song");
|
||||
assert_eq!(track.artists[0].name, "Unknown Artist");
|
||||
assert_eq!(track.release_title, "Unknown Album");
|
||||
assert!(track.duration_seconds > 0.05);
|
||||
assert_eq!(track.audio_sample_rate, Some(8000));
|
||||
assert!(std::fs::File::open(&track.file_path).is_ok());
|
||||
|
||||
// Re-importing the same directory only updates.
|
||||
let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap();
|
||||
assert_eq!((outcome.added, outcome.updated), (0, 1));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_plain_artist() {
|
||||
let (main, feat) = split_artist_tag("Daft Punk");
|
||||
assert_eq!(main, vec!["Daft Punk"]);
|
||||
assert!(feat.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_multiple_and_featured() {
|
||||
let (main, feat) = split_artist_tag("A; B feat. C & D");
|
||||
assert_eq!(main, vec!["A", "B"]);
|
||||
assert_eq!(feat, vec!["C", "D"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_commas_inside_names() {
|
||||
let (main, _) = split_artist_tag("Tyler, The Creator");
|
||||
assert_eq!(main, vec!["Tyler, The Creator"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_feat_from_title() {
|
||||
let (title, names) = extract_title_feat("Song (feat. X & Y)").unwrap();
|
||||
assert_eq!(title, "Song");
|
||||
assert_eq!(names, vec!["X", "Y"]);
|
||||
assert!(extract_title_feat("Plain Song").is_none());
|
||||
}
|
||||
}
|
||||
+2
-2870
File diff suppressed because it is too large
Load Diff
@@ -1,255 +0,0 @@
|
||||
//! Data shapes the views render. They mirror what the furumusic API used to
|
||||
//! return, but every field is now filled from the local SQLite library.
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum Availability {
|
||||
#[default]
|
||||
Local,
|
||||
Mixed,
|
||||
Remote,
|
||||
}
|
||||
|
||||
impl Availability {
|
||||
pub fn is_remoteish(self) -> bool {
|
||||
matches!(self, Availability::Mixed | Availability::Remote)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArtistCard {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// Path to a local image file, if one is set for the artist.
|
||||
pub image_path: Option<String>,
|
||||
pub release_count: i64,
|
||||
pub track_count: i64,
|
||||
pub availability: Availability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArtistRef {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackItem {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
pub duration_seconds: f64,
|
||||
pub artists: Vec<ArtistRef>,
|
||||
pub featured_artists: Vec<ArtistRef>,
|
||||
pub release_id: i64,
|
||||
pub release_title: String,
|
||||
pub release_year: Option<i32>,
|
||||
/// Absolute path to the local audio file.
|
||||
pub file_path: String,
|
||||
/// Stable audio content id (`b3:<64 hex>`) when known.
|
||||
pub content_id: Option<String>,
|
||||
/// Path to a local cover image (the release cover).
|
||||
pub cover_path: Option<String>,
|
||||
pub audio_format: Option<String>,
|
||||
pub audio_bitrate: Option<i32>,
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
pub audio_bit_depth: Option<i32>,
|
||||
pub file_size_bytes: Option<i64>,
|
||||
/// Completed local plays, from the history table.
|
||||
pub play_count: i64,
|
||||
/// Set for federated tracks that are not in the local library (yet):
|
||||
/// carries everything needed to download them from the owning peer.
|
||||
/// With an empty `file_path` the player resolves the track on demand.
|
||||
pub fed: Option<crate::federation::FedTrack>,
|
||||
}
|
||||
|
||||
impl TrackItem {
|
||||
/// A federated track that still needs downloading before playback.
|
||||
pub fn is_fed_pending(&self) -> bool {
|
||||
self.fed.is_some() && self.file_path.is_empty()
|
||||
}
|
||||
|
||||
pub fn artist_line(&self) -> String {
|
||||
let artists = self
|
||||
.artists
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let featured = self
|
||||
.featured_artists
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
match (artists.is_empty(), featured.is_empty()) {
|
||||
(false, false) => format!("{artists} feat. {featured}"),
|
||||
(false, true) => artists,
|
||||
(true, false) => format!("feat. {featured}"),
|
||||
(true, true) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn duration_label(&self) -> String {
|
||||
let total = self.duration_seconds.round() as i64;
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
|
||||
/// Full tech line for the status bar, including the sample rate.
|
||||
pub fn tech_label_full(&self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(format) = &self.audio_format {
|
||||
parts.push(format.to_uppercase());
|
||||
}
|
||||
if let Some(bitrate) = self.audio_bitrate {
|
||||
parts.push(format!("{bitrate}kbps"));
|
||||
}
|
||||
if let Some(rate) = self.audio_sample_rate {
|
||||
parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0));
|
||||
}
|
||||
if let Some(bytes) = self.file_size_bytes {
|
||||
parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReleaseCard {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub cover_path: Option<String>,
|
||||
pub track_count: i64,
|
||||
pub availability: Availability,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ArtistDetail {
|
||||
#[allow(dead_code, reason = "cache key is held by the caller")]
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub image_path: Option<String>,
|
||||
pub total_track_count: i64,
|
||||
pub total_play_count: i64,
|
||||
pub top_tracks: Vec<TrackItem>,
|
||||
pub releases: Vec<ReleaseCard>,
|
||||
/// Tracks where this artist is featured (the only content for artists
|
||||
/// without own releases).
|
||||
pub featured_tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReleaseDetail {
|
||||
#[allow(dead_code, reason = "cache key is held by the caller")]
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub cover_path: Option<String>,
|
||||
pub artists: Vec<ArtistRef>,
|
||||
pub tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlaylistCard {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub track_count: i64,
|
||||
/// "normal" for user playlists, "likes" for the virtual Likes playlist.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PlaylistDetail {
|
||||
#[allow(dead_code, reason = "cache key is held by the caller")]
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
#[allow(dead_code, reason = "shown in a detail header later")]
|
||||
pub description: Option<String>,
|
||||
pub tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SearchResults {
|
||||
pub artists: Vec<ArtistCard>,
|
||||
pub releases: Vec<ReleaseCard>,
|
||||
pub tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
impl SearchResults {
|
||||
pub fn len(&self) -> usize {
|
||||
self.artists.len() + self.releases.len() + self.tracks.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ArtistsPage {
|
||||
pub items: Vec<ArtistCard>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Edited values submitted from the track edit form. `None` numbers clear
|
||||
/// the column.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackEdit {
|
||||
pub title: String,
|
||||
pub artists: Vec<String>,
|
||||
pub featured_artists: Vec<String>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
/// Cover image path; the cover lives on the track's release (the same
|
||||
/// image every view shows for the track). None clears it.
|
||||
pub cover_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReleaseEdit {
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub artists: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn artist(name: &str) -> ArtistRef {
|
||||
ArtistRef {
|
||||
id: 1,
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_line_formats_featured_artists() {
|
||||
let track = TrackItem {
|
||||
id: 1,
|
||||
title: "Track".into(),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 1.0,
|
||||
artists: vec![artist("Main")],
|
||||
featured_artists: vec![artist("Guest"), artist("Other")],
|
||||
release_id: 1,
|
||||
release_title: "Release".into(),
|
||||
release_year: None,
|
||||
file_path: "/tmp/track.mp3".into(),
|
||||
content_id: None,
|
||||
cover_path: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
};
|
||||
|
||||
assert_eq!(track.artist_line(), "Main feat. Guest, Other");
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
fn test_library() -> Library {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
register_norm_function(&conn).unwrap();
|
||||
conn.execute_batch(SCHEMA).unwrap();
|
||||
Library {
|
||||
conn: Mutex::new(conn),
|
||||
db_path: std::env::temp_dir().join("furumi-test-library.db"),
|
||||
covers_dir: std::env::temp_dir().join("furumi-test-covers-unused"),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 {
|
||||
add_track_with_featured(lib, title, artist, &[], album)
|
||||
}
|
||||
|
||||
fn add_track_with_featured(
|
||||
lib: &Library,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
featured: &[&str],
|
||||
album: &str,
|
||||
) -> i64 {
|
||||
let import = import::TrackImport {
|
||||
release_type: None,
|
||||
file_path: format!("/music/{artist}/{album}/{title}.mp3"),
|
||||
title: title.to_string(),
|
||||
artists: vec![artist.to_string()],
|
||||
featured_artists: featured.iter().map(|name| (*name).to_string()).collect(),
|
||||
album_artists: vec![artist.to_string()],
|
||||
release_title: album.to_string(),
|
||||
year: Some(2020),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 60.0,
|
||||
audio_format: Some("mp3".into()),
|
||||
audio_bitrate: Some(320),
|
||||
audio_sample_rate: Some(44100),
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: Some(1),
|
||||
cover: None,
|
||||
};
|
||||
let id = import::upsert_track(lib, &import).unwrap().0;
|
||||
let content_id = format!("b3:{}", blake3::hash(import.file_path.as_bytes()).to_hex());
|
||||
lib.lock()
|
||||
.execute(
|
||||
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
|
||||
params![id, content_id],
|
||||
)
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn artist_filters(hide_featured_only: bool) -> crate::config::settings::LibraryFilters {
|
||||
crate::config::settings::LibraryFilters {
|
||||
hide_featured_only,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_stats_counts_library_rows_and_audio_bytes() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "One", "Artist", "First");
|
||||
add_track(&lib, "Two", "Artist", "Second");
|
||||
|
||||
let stats = lib.local_stats().unwrap();
|
||||
assert_eq!(stats.artist_count, 1);
|
||||
assert_eq!(stats.release_count, 2);
|
||||
assert_eq!(stats.track_count, 2);
|
||||
assert_eq!(stats.audio_bytes, 2);
|
||||
assert_eq!(stats.tracks_without_size, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artists_page_prioritizes_releases_then_tracks() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Solo", "Zed", "Zed Album");
|
||||
add_track_with_featured(&lib, "Guest One", "A Host", &["Guest"], "A Host Album");
|
||||
add_track_with_featured(&lib, "Guest Two", "B Host", &["Guest"], "B Host Album");
|
||||
|
||||
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
|
||||
let zed_pos = page
|
||||
.items
|
||||
.iter()
|
||||
.position(|artist| artist.name == "Zed")
|
||||
.unwrap();
|
||||
let guest_pos = page
|
||||
.items
|
||||
.iter()
|
||||
.position(|artist| artist.name == "Guest")
|
||||
.unwrap();
|
||||
let guest = &page.items[guest_pos];
|
||||
|
||||
assert_eq!(guest.release_count, 0);
|
||||
assert_eq!(guest.track_count, 2);
|
||||
assert!(zed_pos < guest_pos);
|
||||
|
||||
let filtered = lib.artists(1, 10, artist_filters(true)).unwrap();
|
||||
assert!(filtered.items.iter().all(|artist| artist.release_count > 0));
|
||||
assert!(!filtered.items.iter().any(|artist| artist.name == "Guest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_artist_image_hint_becomes_local_image_after_fetch() {
|
||||
let lib = test_library();
|
||||
let artist_key = music_dht::normalize_name("Remote Artist");
|
||||
lib.replace_network_artist_cache(
|
||||
"peer-a",
|
||||
"personal",
|
||||
&[NetworkArtistPreview {
|
||||
artist_key: artist_key.clone(),
|
||||
name: "Remote Artist".into(),
|
||||
image_path: Some("peer-local/image.jpg".into()),
|
||||
release_count: 1,
|
||||
track_count: 3,
|
||||
}],
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let filters = crate::config::settings::LibraryFilters {
|
||||
source_mode: crate::config::settings::LibrarySourceMode::My,
|
||||
..Default::default()
|
||||
};
|
||||
let page = lib.artists(1, 10, filters).unwrap();
|
||||
assert_eq!(page.items[0].image_path, None);
|
||||
|
||||
let requests = lib
|
||||
.network_artist_image_requests(filters, &["Remote Artist".into()], 8)
|
||||
.unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].source_id, "peer-a");
|
||||
assert_eq!(requests[0].artist_key, artist_key);
|
||||
|
||||
lib.set_network_artist_image("peer-a", &artist_key, "/tmp/remote-artist.jpg")
|
||||
.unwrap();
|
||||
let page = lib.artists(1, 10, filters).unwrap();
|
||||
assert_eq!(
|
||||
page.items[0].image_path.as_deref(),
|
||||
Some("/tmp/remote-artist.jpg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_creates_artist_release_track() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.items[0].name, "Artist");
|
||||
assert_eq!(page.items[0].track_count, 1);
|
||||
|
||||
let detail = lib.artist(page.items[0].id).unwrap();
|
||||
assert_eq!(detail.releases.len(), 1);
|
||||
assert_eq!(detail.top_tracks.len(), 1);
|
||||
|
||||
let release = lib.release(detail.releases[0].id).unwrap();
|
||||
assert_eq!(release.tracks.len(), 1);
|
||||
assert_eq!(release.tracks[0].id, track_id);
|
||||
assert_eq!(release.tracks[0].artists[0].name, "Artist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reimport_updates_instead_of_duplicating() {
|
||||
let lib = test_library();
|
||||
let first = add_track(&lib, "Song", "Artist", "Album");
|
||||
let second = add_track(&lib, "Song", "Artist", "Album");
|
||||
assert_eq!(first, second);
|
||||
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
|
||||
assert_eq!(page.items[0].track_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_id_backfill_hashes_missing_track_ids() {
|
||||
let lib = test_library();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"furumi-content-id-test-{}-{}.bin",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::write(&path, b"portable content id").unwrap();
|
||||
let file_path = path.to_string_lossy().into_owned();
|
||||
let import = import::TrackImport {
|
||||
release_type: None,
|
||||
file_path: file_path.clone(),
|
||||
title: "Portable".to_string(),
|
||||
artists: vec!["Artist".to_string()],
|
||||
featured_artists: Vec::new(),
|
||||
album_artists: vec!["Artist".to_string()],
|
||||
release_title: "Album".to_string(),
|
||||
year: Some(2026),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 60.0,
|
||||
audio_format: Some("bin".into()),
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: Some(19),
|
||||
cover: None,
|
||||
};
|
||||
let track_id = import::upsert_track(&lib, &import).unwrap().0;
|
||||
let expected = audio_content_id(&file_path).unwrap();
|
||||
{
|
||||
let conn = lib.lock();
|
||||
conn.execute(
|
||||
"UPDATE tracks SET content_id = NULL WHERE id = ?1",
|
||||
[track_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let stats = lib.backfill_missing_content_ids().unwrap();
|
||||
assert_eq!(stats.hashed, 1);
|
||||
assert_eq!(stats.updated(), 1);
|
||||
assert_eq!(
|
||||
lib.track_content_id_by_id(track_id).unwrap().as_deref(),
|
||||
Some(expected.as_str())
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_all_kinds() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Neon Lights", "Neon Artist", "Neon Album");
|
||||
let results = lib.search("neon", 10).unwrap();
|
||||
assert_eq!(results.artists.len(), 1);
|
||||
assert_eq!(results.releases.len(), 1);
|
||||
assert_eq!(results.tracks.len(), 1);
|
||||
// LIKE wildcards in the query must not match everything.
|
||||
assert_eq!(lib.search("%", 10).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_ranks_exact_names_first() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "A Needle", "A Needle Artist", "A Needle Album");
|
||||
add_track(&lib, "Needle", "Needle", "Needle");
|
||||
|
||||
let results = lib.search("needle", 10).unwrap();
|
||||
assert_eq!(results.artists[0].name, "Needle");
|
||||
assert_eq!(results.releases[0].title, "Needle");
|
||||
assert_eq!(results.tracks[0].title, "Needle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_folds_case_beyond_ascii() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Nothing Else Matters", "Металлика", "Чёрный альбом");
|
||||
// SQLite's LIKE/NOCASE only fold ASCII; norm() folds every script.
|
||||
assert_eq!(lib.search("металлика", 10).unwrap().artists.len(), 1);
|
||||
assert_eq!(lib.search("МЕТАЛЛИКА", 10).unwrap().artists.len(), 1);
|
||||
assert_eq!(lib.search("чёрный", 10).unwrap().releases.len(), 1);
|
||||
assert_eq!(lib.search("matters", 10).unwrap().tracks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlists_and_likes_round_trip() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
let playlist = lib.create_playlist("Mix").unwrap();
|
||||
lib.add_tracks_to_playlist(playlist.id, &[track_id])
|
||||
.unwrap();
|
||||
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1);
|
||||
|
||||
let content_id = lib.track_content_id_by_id(track_id).unwrap().unwrap();
|
||||
assert!(lib.toggle_like_by_content_id(&content_id).unwrap());
|
||||
assert_eq!(lib.liked_content_ids().unwrap(), vec![content_id.clone()]);
|
||||
assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1);
|
||||
assert!(!lib.toggle_like_by_content_id(&content_id).unwrap());
|
||||
|
||||
lib.remove_tracks_from_playlist(playlist.id, &[track_id])
|
||||
.unwrap();
|
||||
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0);
|
||||
lib.delete_playlist(playlist.id).unwrap();
|
||||
// Only the virtual Likes playlist remains.
|
||||
assert_eq!(lib.playlists().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn likes_playlist_orders_local_and_federated_by_liked_at() {
|
||||
let lib = test_library();
|
||||
let old_id = add_track(&lib, "Old Local", "Artist", "Album");
|
||||
let new_id = add_track(&lib, "New Local", "Artist", "Album");
|
||||
let old_content_id = lib.track_content_id_by_id(old_id).unwrap().unwrap();
|
||||
let new_content_id = lib.track_content_id_by_id(new_id).unwrap().unwrap();
|
||||
let content_id = format!("b3:{}", "c".repeat(64));
|
||||
let fed = crate::federation::FedTrack {
|
||||
item_id: "fed_item_order".to_string(),
|
||||
owner: "fed_owner_order".to_string(),
|
||||
own: false,
|
||||
title: "Middle Fed".to_string(),
|
||||
artist_names: vec!["Remote Artist".to_string()],
|
||||
featured_artist_names: Vec::new(),
|
||||
year: Some(2026),
|
||||
duration_seconds: Some(123),
|
||||
content_id: Some(content_id),
|
||||
release_title: Some("Remote Release".to_string()),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
};
|
||||
|
||||
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
|
||||
assert!(lib.toggle_like_by_content_id(&new_content_id).unwrap());
|
||||
assert!(lib.toggle_fed_like(&fed).unwrap());
|
||||
{
|
||||
let conn = lib.lock();
|
||||
conn.execute(
|
||||
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
|
||||
params![old_id, "2026-01-01 00:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
|
||||
params![new_id, "2026-01-02 00:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE fed_likes SET liked_at = ?2 WHERE item_id = ?1",
|
||||
params![fed.item_id, "2026-01-03 00:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let titles: Vec<String> = lib
|
||||
.playlist(LIKES_PLAYLIST_ID)
|
||||
.unwrap()
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| track.title)
|
||||
.collect();
|
||||
assert_eq!(titles, vec!["Middle Fed", "New Local", "Old Local"]);
|
||||
|
||||
assert!(!lib.toggle_like_by_content_id(&old_content_id).unwrap());
|
||||
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
|
||||
{
|
||||
let conn = lib.lock();
|
||||
conn.execute(
|
||||
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
|
||||
params![old_id, "2026-01-04 00:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let titles: Vec<String> = lib
|
||||
.playlist(LIKES_PLAYLIST_ID)
|
||||
.unwrap()
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| track.title)
|
||||
.collect();
|
||||
assert_eq!(titles, vec!["Old Local", "Middle Fed", "New Local"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_playlist_can_show_federated_pending_tracks() {
|
||||
let lib = test_library();
|
||||
let playlist = lib.create_playlist("Remote Mix").unwrap();
|
||||
let sync_id = lib.ensure_playlist_sync_id(playlist.id).unwrap();
|
||||
let content_id = format!("b3:{}", "a".repeat(64));
|
||||
let fed = crate::federation::FedTrack {
|
||||
item_id: "fed_item_1".to_string(),
|
||||
owner: "fed_owner_1".to_string(),
|
||||
own: false,
|
||||
title: "Remote Song".to_string(),
|
||||
artist_names: vec!["Remote Artist".to_string()],
|
||||
featured_artist_names: vec!["Remote Guest".to_string()],
|
||||
year: Some(2026),
|
||||
duration_seconds: Some(123),
|
||||
content_id: Some(content_id.clone()),
|
||||
release_title: Some("Remote Release".to_string()),
|
||||
track_number: Some(2),
|
||||
disc_number: Some(1),
|
||||
};
|
||||
|
||||
assert!(lib.upsert_fed_playlist_track(&sync_id, &fed, 4).unwrap());
|
||||
assert!(
|
||||
lib.has_playlist_content_reference(&sync_id, &content_id)
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let detail = lib.playlist(playlist.id).unwrap();
|
||||
assert_eq!(detail.tracks.len(), 1);
|
||||
let track = &detail.tracks[0];
|
||||
assert!(track.is_fed_pending());
|
||||
assert_eq!(track.title, "Remote Song");
|
||||
assert_eq!(track.artist_line(), "Remote Artist feat. Remote Guest");
|
||||
assert_eq!(track.release_title, "Remote Release");
|
||||
assert_eq!(track.content_id.as_deref(), Some(content_id.as_str()));
|
||||
|
||||
let card = lib
|
||||
.playlists()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|card| card.id == playlist.id)
|
||||
.unwrap();
|
||||
assert_eq!(card.track_count, 1);
|
||||
|
||||
lib.remove_content_ids_from_playlist(playlist.id, std::slice::from_ref(&content_id))
|
||||
.unwrap();
|
||||
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0);
|
||||
assert!(
|
||||
lib.fed_playlist_track_by_content_id(&sync_id, &content_id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_federated_pending_track_to_playlist_records_position() {
|
||||
let lib = test_library();
|
||||
let local_id = add_track(&lib, "Local Song", "Artist", "Album");
|
||||
let playlist = lib.create_playlist("Remote Mix").unwrap();
|
||||
let content_id = format!("b3:{}", "b".repeat(64));
|
||||
let fed = crate::federation::FedTrack {
|
||||
item_id: "fed_item_2".to_string(),
|
||||
owner: "fed_owner_2".to_string(),
|
||||
own: false,
|
||||
title: "Remote Song".to_string(),
|
||||
artist_names: vec!["Remote Artist".to_string()],
|
||||
featured_artist_names: Vec::new(),
|
||||
year: Some(2026),
|
||||
duration_seconds: Some(123),
|
||||
content_id: Some(content_id.clone()),
|
||||
release_title: Some("Remote Release".to_string()),
|
||||
track_number: Some(2),
|
||||
disc_number: Some(1),
|
||||
};
|
||||
|
||||
lib.add_tracks_to_playlist(playlist.id, &[local_id])
|
||||
.unwrap();
|
||||
lib.add_fed_tracks_to_playlist(playlist.id, std::slice::from_ref(&fed))
|
||||
.unwrap();
|
||||
|
||||
let position = lib
|
||||
.playlist_content_position(playlist.id, &content_id)
|
||||
.unwrap();
|
||||
assert_eq!(position, Some(1));
|
||||
let detail = lib.playlist(playlist.id).unwrap();
|
||||
assert_eq!(
|
||||
detail
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| track.title)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Local Song", "Remote Song"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_edit_relinks_artists() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
lib.update_track(
|
||||
track_id,
|
||||
&TrackEdit {
|
||||
title: "Renamed".into(),
|
||||
artists: vec!["Other".into()],
|
||||
featured_artists: vec!["Guest".into()],
|
||||
track_number: Some(2),
|
||||
disc_number: None,
|
||||
cover_path: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
||||
assert_eq!(track.title, "Renamed");
|
||||
assert_eq!(track.artists[0].name, "Other");
|
||||
assert_eq!(track.featured_artists[0].name, "Guest");
|
||||
assert_eq!(track.track_number, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_artist_cleans_up_own_content() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Song", "Solo", "Solo Album");
|
||||
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
|
||||
lib.delete_artist(page.items[0].id).unwrap();
|
||||
assert_eq!(lib.artists(1, 10, artist_filters(false)).unwrap().total, 0);
|
||||
assert_eq!(lib.search("Song", 10).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_track_drops_empty_release() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Only", "Artist", "Album");
|
||||
lib.delete_track(track_id).unwrap();
|
||||
let detail = lib
|
||||
.artist(lib.artists(1, 10, artist_filters(false)).unwrap().items[0].id)
|
||||
.unwrap();
|
||||
assert!(detail.releases.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_counts_completed_plays() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
let content_id = lib
|
||||
.tracks_by_ids(&[track_id])
|
||||
.unwrap()
|
||||
.remove(0)
|
||||
.content_id
|
||||
.unwrap();
|
||||
let event = music_dht::device_sync::ListenEvent {
|
||||
listen_id: "listen-1".to_string(),
|
||||
content_id,
|
||||
started_at_ms: 1_700_000_000_000,
|
||||
listened_ms: 60_000,
|
||||
track_duration_ms: Some(60_000),
|
||||
ended_reason: music_dht::device_sync::ListenEndReason::Finished,
|
||||
track: music_dht::device_sync::ListenTrackMetadata {
|
||||
title: "Song".to_string(),
|
||||
artist_names: vec!["Artist".to_string()],
|
||||
featured_artist_names: Vec::new(),
|
||||
release_title: Some("Album".to_string()),
|
||||
},
|
||||
};
|
||||
assert!(lib.apply_listen_event(&event, "device-a").unwrap());
|
||||
assert!(!lib.apply_listen_event(&event, "device-a").unwrap());
|
||||
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
||||
assert_eq!(track.play_count, 1);
|
||||
let history = lib.listen_history(20).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].listen_id, "listen-1");
|
||||
assert_eq!(history[0].title, "Song");
|
||||
assert_eq!(history[0].artist, "Artist");
|
||||
assert_eq!(history[0].origin_device_id, "device-a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_history_hides_unqualified_events_and_keeps_remote_metadata() {
|
||||
let lib = test_library();
|
||||
let event = music_dht::device_sync::ListenEvent {
|
||||
listen_id: "remote-listen".to_string(),
|
||||
content_id: format!("b3:{}", "a".repeat(64)),
|
||||
started_at_ms: 1_700_000_000_000,
|
||||
listened_ms: 10_000,
|
||||
track_duration_ms: Some(120_000),
|
||||
ended_reason: music_dht::device_sync::ListenEndReason::Skipped,
|
||||
track: music_dht::device_sync::ListenTrackMetadata {
|
||||
title: "Remote song".to_string(),
|
||||
artist_names: vec!["Remote artist".to_string()],
|
||||
featured_artist_names: vec!["Guest".to_string()],
|
||||
release_title: None,
|
||||
},
|
||||
};
|
||||
assert!(lib.apply_listen_event(&event, "remote-device").unwrap());
|
||||
assert!(lib.listen_history(20).unwrap().is_empty());
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod library;
|
||||
mod media;
|
||||
mod player;
|
||||
mod share;
|
||||
mod similarity;
|
||||
mod status;
|
||||
mod streaming;
|
||||
mod ui;
|
||||
|
||||
+52
-1
@@ -99,9 +99,10 @@ fn create_controls() -> Option<MediaControls> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let hwnd = None;
|
||||
|
||||
let dbus_name = platform_dbus_name();
|
||||
let config = PlatformConfig {
|
||||
display_name: "Furumi",
|
||||
dbus_name: "cy.hexor.furumi",
|
||||
dbus_name: &dbus_name,
|
||||
hwnd,
|
||||
};
|
||||
match MediaControls::new(config) {
|
||||
@@ -113,6 +114,41 @@ fn create_controls() -> Option<MediaControls> {
|
||||
}
|
||||
}
|
||||
|
||||
/// MPRIS well-known names must be unique on the session bus. Developers often
|
||||
/// run a checkout next to an installed Furumi, and souvlaki 0.8.3 reports a
|
||||
/// duplicate name by panicking in its private service thread. Ratatui's global
|
||||
/// panic hook then restores the terminal even though the app thread is still
|
||||
/// alive, leaving a frozen UI in canonical/echo mode.
|
||||
///
|
||||
/// Give every Unix MPRIS instance its own valid bus-name component. Other
|
||||
/// backends ignore `dbus_name`, so retain the stable application identifier
|
||||
/// there.
|
||||
fn platform_dbus_name() -> String {
|
||||
#[cfg(all(
|
||||
unix,
|
||||
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||
))]
|
||||
{
|
||||
mpris_dbus_name(std::process::id())
|
||||
}
|
||||
|
||||
#[cfg(not(all(
|
||||
unix,
|
||||
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||
)))]
|
||||
{
|
||||
"cy.hexor.furumi".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
unix,
|
||||
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||
))]
|
||||
fn mpris_dbus_name(process_id: u32) -> String {
|
||||
format!("cy.hexor.furumi.instance{process_id}")
|
||||
}
|
||||
|
||||
/// An invisible top-level window owning the SMTC session. Created on the
|
||||
/// main thread, which also pumps its messages in `pump_platform_events`.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -217,3 +253,18 @@ fn pump_platform_events() {
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn pump_platform_events() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(all(
|
||||
unix,
|
||||
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||
))]
|
||||
#[test]
|
||||
fn mpris_names_are_distinct_between_processes() {
|
||||
let first = super::mpris_dbus_name(41);
|
||||
let second = super::mpris_dbus_name(42);
|
||||
assert_eq!(first, "cy.hexor.furumi.instance41");
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
}
|
||||
|
||||
+1189
File diff suppressed because it is too large
Load Diff
+206
-70
@@ -7,7 +7,7 @@ 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()
|
||||
@@ -28,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),
|
||||
@@ -42,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> {
|
||||
@@ -69,6 +69,66 @@ 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::MinimumScore => (
|
||||
"Minimum similarity",
|
||||
format!("{:.2}", similarity.minimum_score),
|
||||
),
|
||||
SimilarityRow::MaxTracksPerArtist => (
|
||||
"Tracks per artist",
|
||||
similarity.max_tracks_per_artist.to_string(),
|
||||
),
|
||||
SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
|
||||
SimilarityRow::Clear => ("Clear all stored embeddings", "↵".to_string()),
|
||||
};
|
||||
draw_row(
|
||||
frame,
|
||||
area,
|
||||
state,
|
||||
&mut y,
|
||||
cursor,
|
||||
state.settings_cursor,
|
||||
label,
|
||||
value,
|
||||
);
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
y = y.saturating_add(1);
|
||||
|
||||
draw_section(frame, area, state, &mut y, "Federation");
|
||||
for row in FedRow::ALL {
|
||||
let (label, value) = match row {
|
||||
@@ -347,6 +407,8 @@ fn protocol_label(id: &str) -> &str {
|
||||
"music_dht" => "Music DHT",
|
||||
"catalog" => "Catalog",
|
||||
"audio" => "Audio transfer",
|
||||
"similarity" => "Similarity search",
|
||||
"similarity_dht" => "Similarity DHT",
|
||||
"device_sync" => "Device sync",
|
||||
"jam" => "Jam",
|
||||
other => other,
|
||||
@@ -486,6 +548,61 @@ fn short_id(id: &str) -> String {
|
||||
id.chars().take(12).collect::<String>() + "…"
|
||||
}
|
||||
|
||||
fn draw_status_column(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
draw_status(frame, area, state);
|
||||
}
|
||||
|
||||
fn draw_similarity_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
draw_summary_card(
|
||||
frame,
|
||||
area,
|
||||
state,
|
||||
" Similarity Processing ",
|
||||
similarity_summary_lines(state),
|
||||
);
|
||||
}
|
||||
|
||||
fn similarity_summary_lines(state: &AppState) -> Vec<Line<'static>> {
|
||||
let status = &state.similarity.status;
|
||||
let progress = if status.total_tracks == 0 {
|
||||
"0 / 0".to_string()
|
||||
} else {
|
||||
format!("{} / {}", status.completed_tracks, status.total_tracks)
|
||||
};
|
||||
let active = status
|
||||
.active_profile
|
||||
.as_deref()
|
||||
.map(short_id)
|
||||
.unwrap_or_else(|| "not ready".to_string());
|
||||
let target = status
|
||||
.target_profile
|
||||
.as_deref()
|
||||
.map(short_id)
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
vec![
|
||||
summary_line("State", status.phase.label().to_string()),
|
||||
summary_line("Progress", progress),
|
||||
summary_line("Active", active),
|
||||
summary_line("Processing", target),
|
||||
summary_line(
|
||||
"Stored",
|
||||
format!(
|
||||
"{} vectors / {}",
|
||||
status.stored_vectors,
|
||||
short_bytes_label(status.stored_bytes)
|
||||
),
|
||||
),
|
||||
summary_line(
|
||||
"Current",
|
||||
status
|
||||
.current_track
|
||||
.clone()
|
||||
.or_else(|| status.last_error.clone())
|
||||
.unwrap_or_else(|| format!("{} errors", status.failed_tracks)),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
@@ -500,69 +617,81 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
return;
|
||||
}
|
||||
|
||||
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([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Length(1),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.areas(top_area);
|
||||
let [transport_area, _, devices_area] = Layout::horizontal([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Length(1),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.areas(bottom_area);
|
||||
draw_summary_card(
|
||||
frame,
|
||||
status_area,
|
||||
state,
|
||||
" Status ",
|
||||
node_summary_lines(state),
|
||||
);
|
||||
draw_summary_card(
|
||||
frame,
|
||||
local_area,
|
||||
state,
|
||||
" Local Data ",
|
||||
local_data_summary_lines(state),
|
||||
);
|
||||
draw_summary_card(
|
||||
frame,
|
||||
transport_area,
|
||||
state,
|
||||
" Iroh Transport ",
|
||||
transport_summary_lines(state),
|
||||
);
|
||||
draw_summary_card(
|
||||
frame,
|
||||
devices_area,
|
||||
state,
|
||||
" 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.width >= 60 {
|
||||
let paired_width = area.width.saturating_sub(1) / 2;
|
||||
let paired_height =
|
||||
protocol_card_height(state, paired_width.saturating_sub(2), area.height).max(8);
|
||||
if area.height >= 16 + paired_height {
|
||||
let [top_area, _, middle_area, _, paired_area, _] = Layout::vertical([
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(paired_height),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(area);
|
||||
let [status_area, _, local_area] = Layout::horizontal([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Length(1),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.areas(top_area);
|
||||
let [transport_area, _, devices_area] = Layout::horizontal([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Length(1),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.areas(middle_area);
|
||||
let [similarity_area, _, protocols_area] = Layout::horizontal([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Length(1),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.areas(paired_area);
|
||||
draw_summary_card(
|
||||
frame,
|
||||
status_area,
|
||||
state,
|
||||
" Status ",
|
||||
node_summary_lines(state),
|
||||
);
|
||||
draw_summary_card(
|
||||
frame,
|
||||
local_area,
|
||||
state,
|
||||
" Local Data ",
|
||||
local_data_summary_lines(state),
|
||||
);
|
||||
draw_summary_card(
|
||||
frame,
|
||||
transport_area,
|
||||
state,
|
||||
" Iroh Transport ",
|
||||
transport_summary_lines(state),
|
||||
);
|
||||
draw_summary_card(
|
||||
frame,
|
||||
devices_area,
|
||||
state,
|
||||
" Connected Devices ",
|
||||
device_summary_lines(state),
|
||||
);
|
||||
draw_similarity_status(frame, similarity_area, state);
|
||||
draw_summary_card(
|
||||
frame,
|
||||
protocols_area,
|
||||
state,
|
||||
" Protocol Versions ",
|
||||
protocol_summary_lines(state, protocols_area.width.saturating_sub(2)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if area.height < 39 {
|
||||
let protocols_height = protocol_card_height(state, area.width.saturating_sub(2), area.height);
|
||||
let required_vertical_height = 41u16.saturating_add(protocols_height);
|
||||
if area.height < required_vertical_height {
|
||||
frame.render_widget(
|
||||
Paragraph::new(compact_status_lines(state))
|
||||
.wrap(ratatui::widgets::Wrap { trim: false }),
|
||||
@@ -580,6 +709,8 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
_,
|
||||
local_area,
|
||||
_,
|
||||
similarity_area,
|
||||
_,
|
||||
protocols_area,
|
||||
_,
|
||||
] = Layout::vertical([
|
||||
@@ -591,11 +722,9 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(protocol_card_height(
|
||||
state,
|
||||
area.width.saturating_sub(2),
|
||||
area.height,
|
||||
)),
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(protocols_height),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(area);
|
||||
@@ -628,6 +757,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
" Local Data ",
|
||||
local_data_summary_lines(state),
|
||||
);
|
||||
draw_similarity_status(frame, similarity_area, state);
|
||||
draw_summary_card(
|
||||
frame,
|
||||
protocols_area,
|
||||
@@ -651,6 +781,12 @@ fn compact_status_lines(state: &AppState) -> Vec<Line<'static>> {
|
||||
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(
|
||||
"Similarity Processing",
|
||||
theme::header_for(state),
|
||||
));
|
||||
lines.extend(similarity_summary_lines(state).into_iter().take(3));
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::styled("Protocol Versions", theme::header_for(state)));
|
||||
lines.extend(protocol_summary_lines(state, 0).into_iter().take(3));
|
||||
lines
|
||||
|
||||
+160
-2
@@ -890,11 +890,19 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
||||
|
||||
fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
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… ");
|
||||
}
|
||||
let inner = bordered(frame, area, state, title);
|
||||
if search.similarity_source.is_some() {
|
||||
return draw_similarity_search(frame, inner, state, cursor);
|
||||
}
|
||||
|
||||
let empty_results = SearchResults::default();
|
||||
let results = match &search.results {
|
||||
@@ -919,7 +927,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
return centered_line(frame, inner, line);
|
||||
}
|
||||
};
|
||||
if results.len() == 0
|
||||
if results.is_empty()
|
||||
&& state.search.fed_tracks.is_empty()
|
||||
&& state.search.fed_artists.is_empty()
|
||||
&& !state.search.fed_loading
|
||||
@@ -1097,6 +1105,156 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_similarity_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
let search = &state.search;
|
||||
let status = if search.fed_loading {
|
||||
super::loading_line(state, "searching federation…")
|
||||
} else if let Some(stats) = &search.similarity_stats {
|
||||
let elapsed = if stats.elapsed_ms < 1_000 {
|
||||
format!("{} ms", stats.elapsed_ms)
|
||||
} else {
|
||||
format!("{:.2} s", stats.elapsed_ms as f64 / 1_000.0)
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled("Federation · ", theme::accent_for(state)),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{} tracks · {} artists · {} peers queried · {elapsed}",
|
||||
stats.tracks, stats.artists, stats.peers_queried
|
||||
),
|
||||
theme::dim(),
|
||||
),
|
||||
])
|
||||
} else if search.similarity_error.is_some() {
|
||||
Line::styled(
|
||||
"Federation search failed · showing local results",
|
||||
error_style(),
|
||||
)
|
||||
} else if search.loading {
|
||||
super::loading_line(state, "preparing local similarity search…")
|
||||
} else {
|
||||
Line::styled("Federation disabled · showing local results", theme::dim())
|
||||
};
|
||||
let [status_area, content] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
|
||||
frame.render_widget(Paragraph::new(status), status_area);
|
||||
|
||||
let mut rows: Vec<(Line, Option<String>, Option<usize>)> = Vec::new();
|
||||
rows.push((Line::styled("Tracks", theme::header_for(state)), None, None));
|
||||
if let Some(track) = &search.similarity_source_track {
|
||||
let heart = if state.track_liked(track) {
|
||||
Span::styled("♥ ", theme::accent_for(state))
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
heart,
|
||||
Span::raw(track.title.clone()),
|
||||
Span::styled(
|
||||
format!(" {} · {}", track.artist_line(), track.release_title),
|
||||
theme::dim(),
|
||||
),
|
||||
]),
|
||||
Some(super::track_meta_suffix(track, true)),
|
||||
Some(0),
|
||||
));
|
||||
}
|
||||
for (offset, hit) in search.similarity_tracks.iter().enumerate() {
|
||||
let index = offset + 1;
|
||||
match hit {
|
||||
crate::app::state::SimilaritySearchHit::Local { track, .. } => {
|
||||
let heart = if state.track_liked(track) {
|
||||
Span::styled("♥ ", theme::accent_for(state))
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
heart,
|
||||
Span::raw(track.title.clone()),
|
||||
Span::styled(
|
||||
format!(" {} · {}", track.artist_line(), track.release_title),
|
||||
theme::dim(),
|
||||
),
|
||||
]),
|
||||
Some(super::track_meta_suffix(track, true)),
|
||||
Some(index),
|
||||
));
|
||||
}
|
||||
crate::app::state::SimilaritySearchHit::Federated { track, .. } => {
|
||||
let heart = if state.fed_track_liked(track) {
|
||||
Span::styled("♥ ", theme::accent_for(state))
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
let origin = if track.own {
|
||||
"your library".to_string()
|
||||
} else {
|
||||
format!("peer {}…", track.owner_short())
|
||||
};
|
||||
let mut meta = track.duration_label();
|
||||
if let Some(year) = track.year {
|
||||
if !meta.is_empty() {
|
||||
meta.push_str(" · ");
|
||||
}
|
||||
meta.push_str(&year.to_string());
|
||||
}
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
heart,
|
||||
fed_track_availability_prefix(state, track),
|
||||
Span::raw(track.title.clone()),
|
||||
Span::styled(
|
||||
format!(" {} · {origin}", track.artist_line()),
|
||||
theme::dim(),
|
||||
),
|
||||
]),
|
||||
Some(meta),
|
||||
Some(index),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scope = crate::app::state::TrackSelectionScope::SimilaritySearch;
|
||||
let mut selected = std::collections::HashSet::new();
|
||||
if state.track_selection.is_active_for(&scope)
|
||||
&& let Some(indices) = state
|
||||
.track_selection
|
||||
.indices(&scope, search.similarity_len())
|
||||
{
|
||||
selected.extend(indices);
|
||||
}
|
||||
let cursor_row = rows
|
||||
.iter()
|
||||
.position(|(_, _, row_cursor)| *row_cursor == Some(cursor))
|
||||
.unwrap_or(0);
|
||||
let visible = usize::from(content.height.max(1));
|
||||
let first = cursor_row
|
||||
.saturating_sub(visible / 2)
|
||||
.min(rows.len().saturating_sub(visible));
|
||||
for (offset, (line, right, row_cursor)) in
|
||||
rows.into_iter().enumerate().skip(first).take(visible)
|
||||
{
|
||||
let rect = Rect {
|
||||
x: content.x,
|
||||
y: content.y + (offset - first) as u16,
|
||||
width: content.width,
|
||||
height: 1,
|
||||
};
|
||||
if let Some(row_index) = row_cursor
|
||||
&& selected.contains(&row_index)
|
||||
&& row_index != cursor
|
||||
{
|
||||
frame
|
||||
.buffer_mut()
|
||||
.set_style(rect, theme::selection_for(state));
|
||||
}
|
||||
draw_row(frame, rect, state, line, right, row_cursor == Some(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Federated artist card (assembled from peer catalogs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -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()
|
||||
))
|
||||
|
||||
+132
-12
@@ -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(),
|
||||
@@ -838,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,
|
||||
@@ -862,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;
|
||||
@@ -894,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,
|
||||
);
|
||||
}
|
||||
@@ -1228,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"
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user