Fixed UI bugs

This commit is contained in:
Aleksandr Bogomiakov
2026-08-14 02:14:46 +01:00
parent 2dc40c2c3f
commit 0d7c9b9f5e
24 changed files with 4351 additions and 158 deletions
+199 -17
View File
@@ -3,7 +3,7 @@
use furumi_backend_api::{
BackendCommand, BackendSnapshot, PlaybackRepeat, PlaybackStatus, RequestId,
};
use furumi_domain::{ArtistKey, QueueItemId, ReleaseKey, TrackKey};
use furumi_domain::{ArtistKey, QueueItemId, Release, ReleaseKey, Track, TrackKey};
use std::time::Duration;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -19,7 +19,7 @@ pub struct Strings {
pub search: &'static str,
pub library: &'static str,
pub queue: &'static str,
pub recently_played: &'static str,
pub listening_history: &'static str,
pub made_for_listening: &'static str,
pub empty_queue: &'static str,
pub search_placeholder: &'static str,
@@ -31,7 +31,7 @@ pub const EN: Strings = Strings {
search: "Search",
library: "Your library",
queue: "Queue",
recently_played: "Recently played",
listening_history: "Listening history",
made_for_listening: "Made for listening",
empty_queue: "Your queue is empty",
search_placeholder: "Artists, albums or tracks",
@@ -52,6 +52,8 @@ pub enum Screen {
Home,
Search,
Library,
History,
Similarity,
Artist(ArtistKey),
Release(ReleaseKey, Option<ArtistKey>),
Playlist(i64),
@@ -117,6 +119,13 @@ pub enum UiAction {
},
PlayTrack(TrackKey),
PlayQueueItem(QueueItemId),
MoveQueueItem {
item_id: QueueItemId,
target_index: usize,
},
RemoveQueueItem(QueueItemId),
SearchSimilar(TrackKey),
ClearSimilarity,
PlayContext {
tracks: Vec<TrackKey>,
selected: TrackKey,
@@ -145,6 +154,13 @@ pub enum UiAction {
LibraryPathChanged(String),
FederationChanged(bool),
SaveFederatedOnListenChanged(bool),
SimilarityEnabledChanged(bool),
SimilarityModelChanged(String),
SimilarityProfileChanged(String),
SimilarityWorkersChanged(usize),
SimilarityMinimumScoreChanged(f32),
SimilarityMaxTracksPerArtistChanged(usize),
SimilarityFederationConsentChanged(bool),
LanguageChanged(String),
ShowTrackInfo(TrackKey),
CloseTrackInfo,
@@ -180,13 +196,15 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
state.next_request_id = state.next_request_id.saturating_add(1);
let request_id = RequestId::new(state.next_request_id);
match screen {
Screen::Artist(key) => find_artist(state, &key).map_or_else(Vec::new, |artist| {
send(BackendCommand::LoadArtist {
request_id,
key,
name: artist.name.clone(),
Screen::Artist(key) => {
find_artist_name(state, &key).map_or_else(Vec::new, |name| {
send(BackendCommand::LoadArtist {
request_id,
key,
name: name.to_owned(),
})
})
}),
}
Screen::Release(key, preferred) => {
find_release(state, &key).map_or_else(Vec::new, |release| {
let selected_artist = preferred
@@ -198,8 +216,8 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
.find(|artist| &artist.key == preferred)
.map(|artist| (artist.key.clone(), artist.name.clone()))
.or_else(|| {
find_artist(state, preferred)
.map(|artist| (artist.key.clone(), artist.name.clone()))
find_artist_name(state, preferred)
.map(|name| (preferred.clone(), name.to_owned()))
})
})
.or_else(|| {
@@ -271,6 +289,26 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
}
UiAction::PlayTrack(track) => send(BackendCommand::PlayTrack { track }),
UiAction::PlayQueueItem(item_id) => send(BackendCommand::PlayQueueItem { item_id }),
UiAction::MoveQueueItem {
item_id,
target_index,
} => send(BackendCommand::MoveQueueItem {
item_id,
target_index,
}),
UiAction::RemoveQueueItem(item_id) => send(BackendCommand::RemoveQueueItem { item_id }),
UiAction::SearchSimilar(track) => {
if state.frontend.screen != Screen::Similarity {
state
.frontend
.navigation_history
.push(state.frontend.screen.clone());
state.frontend.navigation_forward.clear();
}
state.frontend.screen = Screen::Similarity;
send(BackendCommand::SearchSimilar { track })
}
UiAction::ClearSimilarity => send(BackendCommand::ClearSimilarity),
UiAction::PlayContext { tracks, selected } => {
send(BackendCommand::PlayContext { tracks, selected })
}
@@ -362,6 +400,48 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
state.backend.settings.clone(),
))
}
UiAction::SimilarityEnabledChanged(enabled) => {
state.backend.settings.similarity.enabled = enabled;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::SimilarityModelChanged(model) => {
state.backend.settings.similarity.model = model;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::SimilarityProfileChanged(profile) => {
state.backend.settings.similarity.profile = profile;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::SimilarityWorkersChanged(workers) => {
state.backend.settings.similarity.workers = workers;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::SimilarityMinimumScoreChanged(minimum_score) => {
state.backend.settings.similarity.minimum_score = minimum_score;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::SimilarityMaxTracksPerArtistChanged(max_tracks_per_artist) => {
state.backend.settings.similarity.max_tracks_per_artist = max_tracks_per_artist;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::SimilarityFederationConsentChanged(federation_consent) => {
state.backend.settings.similarity.federation_consent = federation_consent;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::LanguageChanged(language) => {
state.frontend.locale = Locale::En;
state.backend.settings.language = language;
@@ -384,15 +464,64 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
}
}
fn find_artist<'a>(state: &'a AppState, key: &ArtistKey) -> Option<&'a furumi_domain::Artist> {
match &state.backend.library {
fn find_artist_name<'a>(state: &'a AppState, key: &ArtistKey) -> Option<&'a str> {
let library = match &state.backend.library {
furumi_backend_api::RemoteData::Ready(l) => Some(l),
_ => None,
};
if let Some(artist) = library
.into_iter()
.flat_map(|library| library.artists.iter())
.chain(state.backend.search.results.artists.iter())
.find(|artist| &artist.key == key)
{
return Some(artist.name.as_str());
}
.into_iter()
.flat_map(|l| l.artists.iter())
.chain(state.backend.search.results.artists.iter())
.find(|a| &a.key == key)
if let Some(name) = library
.into_iter()
.flat_map(|library| library.featured_releases.iter())
.chain(state.backend.search.results.releases.iter())
.find_map(|release| artist_name_in_release(release, key))
{
return Some(name);
}
library
.into_iter()
.flat_map(|library| {
library.recently_played.iter().chain(
library
.playlists
.iter()
.flat_map(|playlist| playlist.tracks.iter()),
)
})
.chain(state.backend.search.results.tracks.iter())
.chain(state.backend.queue.items().iter().map(|item| &item.track))
.find_map(|track| artist_name_in_track(track, key))
}
fn artist_name_in_release<'a>(release: &'a Release, key: &ArtistKey) -> Option<&'a str> {
release
.artists
.iter()
.chain(release.featured_artists.iter())
.find(|artist| &artist.key == key)
.map(|artist| artist.name.as_str())
.or_else(|| {
release
.tracks
.iter()
.find_map(|track| artist_name_in_track(track, key))
})
}
fn artist_name_in_track<'a>(track: &'a Track, key: &ArtistKey) -> Option<&'a str> {
track
.artists
.iter()
.chain(track.featured_artists.iter())
.find(|artist| &artist.key == key)
.map(|artist| artist.name.as_str())
}
fn find_release<'a>(state: &'a AppState, key: &ReleaseKey) -> Option<&'a furumi_domain::Release> {
@@ -516,6 +645,21 @@ mod tests {
));
}
#[test]
fn similarity_action_opens_results_and_starts_the_backend_search() {
let mut state = AppState::default();
let key = TrackKey::local(furumi_domain::LocalTrackId::new(42));
let effects = reduce_action(&mut state, UiAction::SearchSimilar(key.clone()));
assert_eq!(state.frontend.screen, Screen::Similarity);
assert_eq!(state.frontend.navigation_history, vec![Screen::Home]);
assert_eq!(
effects,
vec![Effect::Send(BackendCommand::SearchSimilar { track: key })]
);
}
#[test]
fn stale_backend_snapshots_are_ignored() {
let mut state = AppState::default();
@@ -563,6 +707,44 @@ mod tests {
assert_eq!(state.frontend.navigation_history, vec![Screen::Home]);
}
#[test]
fn featured_artist_reference_opens_the_standard_artist_detail() {
let mut state = AppState::default();
let artist_key = ArtistKey::Federation {
peer_id: "peer-a".into(),
id: "guest-artist".into(),
};
state.backend.search.results.releases = vec![Release {
key: ReleaseKey::Local(ReleaseId::new(7)),
source: CatalogSource::Local,
title: "Release".into(),
artists: Vec::new(),
featured_artists: vec![ArtistRef {
key: artist_key.clone(),
name: "Guest Artist".into(),
}],
release_type: "album".into(),
year: None,
artwork: Artwork::default(),
tracks: Vec::new(),
}];
let effects = reduce_action(
&mut state,
UiAction::Navigate(Screen::Artist(artist_key.clone())),
);
assert_eq!(state.frontend.screen, Screen::Artist(artist_key.clone()));
assert_eq!(
effects,
vec![Effect::Send(BackendCommand::LoadArtist {
request_id: RequestId::new(1),
key: artist_key,
name: "Guest Artist".into(),
})]
);
}
#[test]
fn release_lookup_keeps_the_preferred_artist_name_and_key_together() {
let mut state = AppState::default();
+89 -2
View File
@@ -222,7 +222,7 @@ pub struct BuildInfoSnapshot {
pub protocols: Vec<VersionEntrySnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct SettingsSnapshot {
pub network_id: String,
pub device_name: String,
@@ -230,6 +230,54 @@ pub struct SettingsSnapshot {
pub federation_enabled: bool,
pub save_federated_on_listen: bool,
pub language: String,
pub similarity: SimilaritySettingsSnapshot,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SimilaritySettingsSnapshot {
pub enabled: bool,
pub model: String,
pub profile: String,
pub workers: usize,
pub minimum_score: f32,
pub max_tracks_per_artist: usize,
pub federation_consent: bool,
pub active_profile: Option<String>,
}
impl Default for SimilaritySettingsSnapshot {
fn default() -> Self {
Self {
enabled: false,
model: "discogs-effnet-bsdynamic-1".into(),
profile: "furumi-full-track-v1".into(),
workers: std::thread::available_parallelism()
.map_or(1, |count| (count.get() / 2).clamp(1, 4)),
minimum_score: 0.70,
max_tracks_per_artist: 5,
federation_consent: false,
active_profile: None,
}
}
}
impl SimilaritySettingsSnapshot {
#[must_use]
pub fn normalized(mut self) -> Self {
if self.model.trim().is_empty() {
self.model = "discogs-effnet-bsdynamic-1".into();
}
if self.profile.trim().is_empty() {
self.profile = "furumi-full-track-v1".into();
}
self.workers = self.workers.clamp(1, 16);
if !self.minimum_score.is_finite() {
self.minimum_score = 0.70;
}
self.minimum_score = self.minimum_score.clamp(0.0, 1.0);
self.max_tracks_per_artist = self.max_tracks_per_artist.clamp(1, 50);
self
}
}
impl Default for SettingsSnapshot {
@@ -237,14 +285,40 @@ impl Default for SettingsSnapshot {
Self {
network_id: "furumi".into(),
device_name: String::new(),
library_path: "~/Music/Furumi".into(),
// The backend resolves this from the platform's shared Furumi data
// directory before publishing its first authoritative snapshot.
library_path: String::new(),
federation_enabled: true,
save_federated_on_listen: true,
language: "English".into(),
similarity: SimilaritySettingsSnapshot::default(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SimilarityStatusSnapshot {
pub phase: String,
pub active_profile: Option<String>,
pub target_profile: Option<String>,
pub model: String,
pub total_tracks: usize,
pub completed_tracks: usize,
pub failed_tracks: usize,
pub stored_vectors: usize,
pub stored_bytes: u64,
pub current_track: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SimilaritySearchSnapshot {
pub source_title: String,
pub results: Vec<Track>,
pub pending: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct BackendSnapshot {
pub revision: u64,
@@ -257,6 +331,8 @@ pub struct BackendSnapshot {
pub build_info: BuildInfoSnapshot,
pub connected_devices: ConnectedDevicesSnapshot,
pub settings: SettingsSnapshot,
pub similarity_status: SimilarityStatusSnapshot,
pub similarity_search: SimilaritySearchSnapshot,
pub playback_error: Option<String>,
pub settings_error: Option<String>,
}
@@ -286,6 +362,17 @@ pub enum BackendCommand {
PlayQueueItem {
item_id: QueueItemId,
},
SearchSimilar {
track: TrackKey,
},
ClearSimilarity,
MoveQueueItem {
item_id: QueueItemId,
target_index: usize,
},
RemoveQueueItem {
item_id: QueueItemId,
},
PlayContext {
tracks: Vec<TrackKey>,
selected: TrackKey,
+6
View File
@@ -7,6 +7,8 @@ license.workspace = true
[dependencies]
anyhow.workspace = true
blake3.workspace = true
futures-util.workspace = true
furumi-backend-api.workspace = true
furumi-domain.workspace = true
furumi-library.workspace = true
@@ -14,6 +16,10 @@ music-dht.workspace = true
directories.workspace = true
rusqlite.workspace = true
rodio.workspace = true
reqwest.workspace = true
rustfft.workspace = true
sha2.workspace = true
tract-onnx.workspace = true
tokio.workspace = true
tokio-util.workspace = true
serde_json.workspace = true
+1
View File
@@ -216,6 +216,7 @@ impl Actor {
}
urgent_targets.push(previous);
}
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
self.audio.stop();
self.device_role = DevicePlaybackRole::Control;
self.active_device_id.clone_from(&target.id);
+253 -4
View File
@@ -12,10 +12,11 @@ use std::time::Duration;
use anyhow::{Context as _, Result};
use music_dht::device_sync::{
DEFAULT_INVITE_TTL_MS, DEVICE_SYNC_PROTOCOL_VERSION, DeviceProfileWire, InviteWire,
PlaybackCommand, PlaybackSnapshot, SnapshotLike, SnapshotLikeTombstone, SnapshotPlaylist,
SnapshotPlaylistItem, SnapshotPlaylistItemTombstone, SnapshotPlaylistTombstone, SyncOpPayload,
SyncOpWire, SyncSnapshot, SyncedFedTrack, WireMessage, encode_invite, finish_response,
finish_send, hash_secret, parse_invite, random_hex, read_msg, ticket_endpoint_id, write_msg,
ListenEndReason, ListenEvent, ListenTrackMetadata, PlaybackCommand, PlaybackSnapshot,
SnapshotLike, SnapshotLikeTombstone, SnapshotPlaylist, SnapshotPlaylistItem,
SnapshotPlaylistItemTombstone, SnapshotPlaylistTombstone, SyncOpPayload, SyncOpWire,
SyncSnapshot, SyncedFedTrack, WireMessage, encode_invite, finish_response, finish_send,
hash_secret, parse_invite, random_hex, read_msg, ticket_endpoint_id, write_msg,
};
use music_dht::{ByteStream, MusicDhtService, PeerTicket, StreamAcceptor};
use rusqlite::{Connection, OptionalExtension as _, params};
@@ -120,6 +121,10 @@ impl DeviceSync {
Ok((identity.device_id, identity.name))
}
pub fn new_listen_id() -> String {
format!("{}-{}", now_ms(), random_hex(12))
}
pub fn set_device_name(&self, name: &str, endpoint_ticket: Option<&str>) -> Result<()> {
let name = if name.trim().is_empty() {
"furumi".to_string()
@@ -367,6 +372,56 @@ impl DeviceSync {
})
}
pub fn record_listen(
&self,
listen_id: String,
track: &furumi_domain::Track,
started_at_ms: i64,
listened_ms: i64,
ended_reason: ListenEndReason,
) -> Result<()> {
let Some(content_id) = track
.key
.content_id()
.and_then(|id| music_dht::normalize_content_id(id.as_str()))
else {
return Ok(());
};
let mut artist_names = track
.artists
.iter()
.map(|artist| artist.name.clone())
.filter(|artist| !artist.trim().is_empty())
.collect::<Vec<_>>();
if artist_names.is_empty() && !track.artist.trim().is_empty() {
artist_names.push(track.artist.clone());
}
let event = ListenEvent {
listen_id,
content_id,
started_at_ms,
listened_ms: listened_ms.max(0),
track_duration_ms: (track.duration_seconds > 0.0).then_some(
crate::support::seconds_to_milliseconds(track.duration_seconds),
),
ended_reason,
track: ListenTrackMetadata {
title: track.title.clone(),
artist_names,
featured_artist_names: track
.featured_artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
release_title: (!track.release.trim().is_empty()).then(|| track.release.clone()),
},
};
if event.should_record() {
self.record_op(SyncOpPayload::ListenRecorded { event })?;
}
Ok(())
}
pub fn record_playlist_created(&self, id: i64, title: &str) -> Result<()> {
let playlist_id = self.library.ensure_playlist_sync_id(id)?;
self.record_op(SyncOpPayload::PlaylistCreated {
@@ -698,6 +753,8 @@ async fn handle_pair_request(
finish_response(&mut stream, RESPONSE_DRAIN).await?;
return Ok(());
}
let requester_group_id = requester_group_id.filter(|group| !group.trim().is_empty());
let requester_group_active_devices = requester_group_active_devices.max(1);
let devices_json = serde_json::to_string(&requester_devices)?;
lock(&sync.conn).execute(
"INSERT OR IGNORE INTO sync_pending_pairing
@@ -1016,6 +1073,15 @@ fn pair_request_id(invite_id: &str, device_id: &str) -> String {
format!("pair_{}", &digest[..16])
}
fn requester_group_conflict(
local_group_id: &str,
requester_group_id: Option<&str>,
requester_group_active_devices: usize,
) -> bool {
requester_group_id.is_some_and(|group| !group.trim().is_empty() && group != local_group_id)
&& requester_group_active_devices > 1
}
fn valid_pair_request(
sync: &DeviceSync,
invite_id: &str,
@@ -1115,6 +1181,150 @@ enum PairAttempt {
mod tests {
use super::*;
static NEXT_TEST_DB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn with_test_sync(test: impl FnOnce(&DeviceSync)) {
let conn = Connection::open_in_memory().unwrap();
init_schema(&conn).unwrap();
let unique = NEXT_TEST_DB.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let library_path = std::env::temp_dir().join(format!(
"furumi-desktop-devices-test-{}-{}-{}.sqlite3",
std::process::id(),
now_ms(),
unique
));
let library = Arc::new(furumi_library::Library::open(&library_path).unwrap());
let (events, _event_rx) = tokio::sync::mpsc::channel(4);
let sync = DeviceSync {
conn: Arc::new(Mutex::new(conn)),
library,
events,
playback: Arc::new(Mutex::new(PlaybackStore::default())),
sync_requested: Arc::new(tokio::sync::Notify::new()),
};
sync.ensure_identity().unwrap();
test(&sync);
drop(sync);
for suffix in ["", "-wal", "-shm"] {
let mut path = library_path.as_os_str().to_os_string();
path.push(suffix);
let _ = std::fs::remove_file(path);
}
}
fn test_profile(device_id: &str) -> DeviceProfileWire {
DeviceProfileWire {
device_id: device_id.into(),
name: device_id.into(),
client_version: CLIENT_VERSION.into(),
protocol_version: DEVICE_SYNC_PROTOCOL_VERSION,
endpoint_id: String::new(),
endpoint_ticket: String::new(),
revoked: false,
revoke_cutoff_seq: None,
updated_at_ms: now_ms(),
}
}
#[test]
fn locally_finished_listen_enters_the_shared_history() {
with_test_sync(|sync| {
let content_id =
furumi_domain::ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap();
let track = furumi_domain::Track {
key: furumi_domain::TrackKey::remote(content_id.clone()),
title: "Shared listen".into(),
artist: "Artist".into(),
artists: vec![furumi_domain::ArtistRef {
key: furumi_domain::ArtistKey::Federation {
peer_id: "peer".into(),
id: "artist".into(),
},
name: "Artist".into(),
}],
featured_artists: Vec::new(),
release: "Release".into(),
release_id: furumi_domain::ReleaseKey::Federation {
peer_id: "peer".into(),
id: "release".into(),
},
duration_seconds: 180.0,
track_number: Some(1),
disc_number: Some(1),
cover_uri: None,
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: furumi_domain::AudioSource::Federation {
peer_id: "peer".into(),
content_id,
},
};
sync.record_listen(
"desktop-listen".into(),
&track,
now_ms(),
180_000,
ListenEndReason::Finished,
)
.unwrap();
let history = sync.library.listen_history(10).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].listen_id, "desktop-listen");
assert_eq!(history[0].title, "Shared listen");
});
}
fn insert_pending_pairing(sync: &DeviceSync, requester_group_devices: &[DeviceProfileWire]) {
lock(&sync.conn)
.execute(
"INSERT INTO sync_pending_pairing
(request_id, device_id, name, client_version, endpoint_id,
endpoint_ticket, invite_id, created_at_ms, status,
requester_group_id, requester_group_active_devices,
requester_group_devices_json)
VALUES ('request', 'dev_requester', 'Requester', ?1, '', '',
'invite', ?2, 'pending', 'grp_remote', 2, ?3)",
params![
CLIENT_VERSION,
now_ms(),
serde_json::to_string(requester_group_devices).unwrap()
],
)
.unwrap();
}
fn device_known(sync: &DeviceSync, device_id: &str) -> bool {
lock(&sync.conn)
.query_row(
"SELECT 1 FROM sync_devices WHERE device_id = ?1",
[device_id],
|_| Ok(()),
)
.optional()
.unwrap()
.is_some()
}
fn device_trusted(sync: &DeviceSync, device_id: &str) -> bool {
lock(&sync.conn)
.query_row(
"SELECT trusted_at_ms IS NOT NULL FROM sync_devices WHERE device_id = ?1",
[device_id],
|row| row.get::<_, bool>(0),
)
.optional()
.unwrap()
.unwrap_or(false)
}
#[test]
fn pairing_request_ids_are_stable_and_scoped_to_the_device() {
assert_eq!(
@@ -1126,4 +1336,43 @@ mod tests {
pair_request_id("invite", "device-b")
);
}
#[test]
fn group_choice_is_only_required_for_an_existing_different_group() {
assert!(requester_group_conflict("grp_local", Some("grp_remote"), 2));
assert!(!requester_group_conflict(
"grp_local",
Some("grp_remote"),
1
));
assert!(!requester_group_conflict("grp_local", Some("grp_local"), 3));
assert!(!requester_group_conflict("grp_local", None, 3));
assert!(!requester_group_conflict("grp_local", Some(" "), 3));
}
#[test]
fn pairing_choice_either_joins_the_requester_group_or_keeps_the_local_group() {
let requester_peer = test_profile("dev_requester_peer");
with_test_sync(|sync| {
let local_group = sync.ensure_identity().unwrap().group_id;
insert_pending_pairing(sync, std::slice::from_ref(&requester_peer));
sync.answer_pairing("request", true, false).unwrap();
assert_eq!(sync.ensure_identity().unwrap().group_id, local_group);
assert!(device_trusted(sync, "dev_requester"));
assert!(!device_known(sync, "dev_requester_peer"));
});
with_test_sync(|sync| {
insert_pending_pairing(sync, std::slice::from_ref(&requester_peer));
sync.answer_pairing("request", true, true).unwrap();
assert_eq!(sync.ensure_identity().unwrap().group_id, "grp_remote");
assert!(device_trusted(sync, "dev_requester"));
assert!(device_known(sync, "dev_requester_peer"));
});
}
}
+21 -5
View File
@@ -108,6 +108,7 @@ impl DeviceSync {
.collect::<rusqlite::Result<Vec<_>>>()?
};
let pending = {
let local_group_id = identity.group_id.as_str();
let mut stmt = conn.prepare(
"SELECT request_id, device_id, name, client_version,
requester_group_id, requester_group_active_devices
@@ -115,14 +116,25 @@ impl DeviceSync {
ORDER BY created_at_ms",
)?;
stmt.query_map([], |row| {
let requester_group_id = row.get::<_, Option<String>>(4)?;
let requester_group_active_devices =
usize::try_from(row.get::<_, i64>(5)?.max(0)).unwrap_or(usize::MAX);
let group_conflict = requester_group_conflict(
local_group_id,
requester_group_id.as_deref(),
requester_group_active_devices,
);
Ok(PendingPairing {
request_id: row.get(0)?,
device_id: row.get(1)?,
name: row.get(2)?,
client_version: row.get(3)?,
requester_group_id: row.get(4)?,
requester_group_active_devices: usize::try_from(row.get::<_, i64>(5)?.max(0))
.unwrap_or(usize::MAX),
requester_group_id: group_conflict.then_some(requester_group_id).flatten(),
requester_group_active_devices: if group_conflict {
requester_group_active_devices
} else {
0
},
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?
@@ -480,8 +492,12 @@ impl DeviceSync {
command,
} => self.apply_playback_command(target_device_id, command, &op.op_id)?,
SyncOpPayload::ListenRecorded { event } => {
self.library
.apply_listen_event(event, &op.origin_device_id)?;
if self
.library
.apply_listen_event(event, &op.origin_device_id)?
{
self.notify_library();
}
}
}
Ok(())
+71 -5
View File
@@ -14,6 +14,8 @@ use furumi_domain::{
use music_dht::catalog::{
CATALOG_ALPN, CatalogArtist, CatalogImageHeader, CatalogRequest, CatalogResponse,
};
use music_dht::similarity_dht::SimilarityDht;
use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN;
use music_dht::{
EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId,
RendezvousConfig,
@@ -82,29 +84,60 @@ const IMAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
pub struct Client {
service: Arc<MusicDhtService>,
similarity_dht: Arc<SimilarityDht>,
media_dir: PathBuf,
}
impl Client {
pub async fn start(data_dir: PathBuf, media_dir: PathBuf, network: &str) -> Result<Arc<Self>> {
pub async fn start(
data_dir: PathBuf,
media_dir: PathBuf,
network: &str,
similarity: Arc<crate::similarity::Manager>,
) -> Result<Arc<Self>> {
tokio::fs::create_dir_all(&data_dir).await?;
tokio::fs::create_dir_all(&media_dir).await?;
let similarity_routing_path = data_dir.join("similarity-routing.sqlite3");
let config = MusicDhtConfig::builder()
.data_dir(data_dir)
.data_dir(&data_dir)
.network_id(NetworkId::from_name(network))
.rendezvous(RendezvousConfig::default())
.stream_protocol(CATALOG_ALPN)
.stream_protocol(AUDIO_ALPN)
.stream_protocol(music_dht::device_sync::SYNC_ALPN_V1)
.stream_protocol(music_dht::device_sync::SYNC_ALPN_V2)
.schema_independent_stream_protocol(crate::federation_similarity::SIMILARITY_ALPN)
.schema_independent_stream_protocol(SIMILARITY_DHT_ALPN)
.build()
.context("invalid federation configuration")?;
let (service, mut events) = MusicDhtService::start(config)
.await
.context("starting federation node")?;
tokio::spawn(async move { while events.recv().await.is_some() {} });
let service = Arc::new(service);
let similarity_dht = SimilarityDht::open(Arc::clone(&service), similarity_routing_path)
.await
.context("starting similarity routing overlay")?;
let routing_acceptor = service
.stream_acceptor(SIMILARITY_DHT_ALPN)
.context("starting similarity routing listener")?;
tokio::spawn(Arc::clone(&similarity_dht).serve(routing_acceptor));
tokio::spawn(Arc::clone(&similarity_dht).maintenance());
tokio::spawn(crate::federation_similarity::sync_routes(
Arc::clone(&similarity_dht),
Arc::clone(&similarity),
));
let similarity_acceptor = service
.stream_acceptor(crate::federation_similarity::SIMILARITY_ALPN)
.context("starting similarity listener")?;
tokio::spawn(crate::federation_similarity::serve(
similarity_acceptor,
similarity,
service.endpoint_id(),
));
Ok(Arc::new(Self {
service: Arc::new(service),
service,
similarity_dht,
media_dir,
}))
}
@@ -171,6 +204,33 @@ impl Client {
Ok((results, stats))
}
pub async fn search_similar(
&self,
query: crate::similarity::QueryVector,
limit: usize,
minimum_score: f32,
max_tracks_per_artist: usize,
) -> Result<Vec<crate::federation_similarity::ScoredTrack>> {
let mut hits = crate::federation_similarity::search(
Arc::clone(&self.service),
Arc::clone(&self.similarity_dht),
query,
limit,
minimum_score,
max_tracks_per_artist,
)
.await?;
let mut results = SearchResults {
tracks: hits.iter().map(|hit| hit.track.clone()).collect(),
..SearchResults::default()
};
self.fetch_artwork(&mut results).await;
for (hit, with_artwork) in hits.iter_mut().zip(results.tracks) {
hit.track = with_artwork;
}
Ok(hits)
}
/// Resolves a portable queue entry when another connected device only
/// knows its stable audio content id.
pub async fn track_by_content_id(&self, content_id: &str) -> Result<Track> {
@@ -181,11 +241,17 @@ impl Client {
.into_iter()
.chain(outcome.network_results)
.collect::<Vec<_>>();
convert_items(&items, own)
let mut track = convert_items(&items, own)
.tracks
.into_iter()
.next()
.context("no federation peer currently publishes this track")
.context("no federation peer currently publishes this track")?;
if track.cover_uri.is_none()
&& let Some(path) = self.artwork_for_track(&track).await
{
track.cover_uri = Some(path.to_string_lossy().into_owned());
}
Ok(track)
}
pub async fn publish(&self, specs: Vec<ItemSpec>) -> Result<()> {
+378
View File
@@ -0,0 +1,378 @@
//! Local-index adapter and DHT-routed peer client for Furumi similarity.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as _, Result};
use furumi_domain::{ArtistKey, ArtistRef, AudioSource, ContentId, ReleaseKey, Track, TrackKey};
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::{EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor};
use crate::similarity::{Manager, QueryVector};
pub use music_dht::similarity::SIMILARITY_ALPN;
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 ROUTE_SYNC_INTERVAL: Duration = Duration::from_secs(30);
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
pub struct ScoredTrack {
pub track: Track,
pub score: f32,
pub embedding_signature: Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
}
pub async fn serve(mut acceptor: StreamAcceptor, manager: Arc<Manager>, own: EndpointId) {
while let Some(stream) = acceptor.accept().await {
let manager = Arc::clone(&manager);
tokio::spawn(async move {
let _ = serve_one(stream, manager, own).await;
});
}
}
async fn serve_one(
mut stream: music_dht::ByteStream,
manager: Arc<Manager>,
own: EndpointId,
) -> Result<()> {
let request = wire::read_request(&mut stream).await?;
let response = if manager.network_allowed() {
let matches = tokio::task::spawn_blocking(move || {
manager.search_vector_for_peer(&request.profile_id, &request.vector, request.limit)
})
.await
.context("local similarity task failed")
.and_then(|result| result);
match matches {
Ok(matches) => SimilarityResponse::success(
matches
.into_iter()
.filter_map(|found| {
let track = found.track;
let hit = SimilarityHit {
score: found.score,
item_id: 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(
crate::support::seconds_to_milliseconds(track.duration_seconds)
/ 1_000,
),
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),
};
hit.validate().is_ok().then_some(hit)
})
.collect(),
)?,
Err(error) => {
SimilarityResponse::refused(format!("similarity query is unavailable: {error:#}"))?
}
}
} else {
SimilarityResponse::refused("similarity federation is disabled or has no privacy consent")?
};
wire::write_response(&mut stream, &response).await?;
stream.send.finish()?;
let _ = stream.send.stopped().await;
Ok(())
}
#[allow(
clippy::too_many_lines,
reason = "bounded peer fan-out and ranking policy"
)]
pub async fn search(
service: Arc<MusicDhtService>,
routing: Arc<SimilarityDht>,
query: QueryVector,
limit: usize,
minimum_score: f32,
max_tracks_per_artist: usize,
) -> Result<Vec<ScoredTrack>> {
let own = service.endpoint_id();
let routed = tokio::time::timeout(
ROUTING_TIMEOUT,
routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS),
)
.await
.ok()
.and_then(Result::ok)
.unwrap_or_default();
let mut seen = HashSet::new();
let mut peers = routed
.into_iter()
.filter_map(|ticket| {
let owner = ticket.endpoint_id();
(owner != own && seen.insert(owner)).then_some(QueryPeer {
owner,
ticket: Some(ticket),
})
})
.collect::<Vec<_>>();
for owner in service
.connected_peers()
.into_iter()
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
{
if owner != own && seen.insert(owner) {
peers.push(QueryPeer {
owner,
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 responses = stream::iter(peers.into_iter().map(|peer| {
let service = Arc::clone(&service);
let request = Arc::clone(&request);
async move {
tokio::time::timeout(QUERY_TIMEOUT, query_peer(service, peer, &request))
.await
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
}
}))
.buffer_unordered(QUERY_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut hits = responses
.into_iter()
.filter_map(Result::ok)
.flatten()
.collect::<Vec<_>>();
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
let mut dedup = HashSet::new();
let mut signatures = vec![query_signature];
let mut artist_counts = HashMap::<String, usize>::new();
let mut results = Vec::new();
for (track, score, signature) in hits {
if score < minimum_score {
break;
}
if query.source_content_id.as_deref().is_some_and(|source| {
track
.key
.content_id()
.is_some_and(|id| id.as_str() == source)
}) {
continue;
}
let identity = track
.key
.content_id()
.map_or_else(|| format!("{:?}", track.key), |id| id.as_str().to_owned());
if !dedup.insert(identity) {
continue;
}
if signature.is_some_and(|candidate| {
signatures.iter().any(|existing| {
wire::signature_distance(&candidate, existing)
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
})
}) {
continue;
}
let artist = track
.artists
.first()
.map(|artist| music_dht::normalize_name(&artist.name))
.unwrap_or_default();
let count = artist_counts.entry(artist.clone()).or_default();
if !artist.is_empty() && *count >= max_tracks_per_artist {
continue;
}
*count += 1;
if let Some(signature) = signature {
signatures.push(signature);
}
results.push(ScoredTrack {
track,
score,
embedding_signature: signature,
});
if results.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
break;
}
}
Ok(results)
}
type PeerHits = Vec<(Track, f32, Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>)>;
#[derive(Clone)]
struct QueryPeer {
owner: EndpointId,
ticket: Option<PeerTicket>,
}
async fn query_peer(
service: Arc<MusicDhtService>,
peer: QueryPeer,
request: &SimilarityRequest,
) -> 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,
}?;
let response = wire::exchange(&mut stream, request).await?;
anyhow::ensure!(
response.ok,
"peer refused similarity query: {}",
response.error.unwrap_or_default()
);
Ok(response
.hits
.into_iter()
.filter_map(|hit| hit_to_track(owner, hit))
.collect())
}
/// Keeps the routing overlay synchronized with the active durable index.
pub async fn sync_routes(routing: Arc<SimilarityDht>, manager: Arc<Manager>) {
let mut interval = tokio::time::interval(ROUTE_SYNC_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut published_marker: Option<(String, blake3::Hash)> = None;
loop {
interval.tick().await;
let manager = Arc::clone(&manager);
let loaded = tokio::task::spawn_blocking(move || manager.routing_signatures()).await;
let Ok(Ok(snapshot)) = loaded else {
continue;
};
let Some((profile_id, signatures)) = snapshot else {
if published_marker.take().is_some() {
routing.clear_local_signatures();
}
continue;
};
let mut hasher = blake3::Hasher::new();
for signature in &signatures {
hasher.update(signature);
}
let marker = (profile_id.clone(), hasher.finalize());
if published_marker.as_ref() == Some(&marker) {
continue;
}
if routing
.sync_local_signatures(profile_id, signatures)
.await
.is_ok()
{
published_marker = Some(marker);
}
}
}
fn hit_to_track(
owner: EndpointId,
hit: SimilarityHit,
) -> Option<(Track, f32, Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>)> {
let peer_id = owner.to_string();
let content_id = hit
.content_id
.as_deref()
.and_then(|id| ContentId::parse(id).ok())?;
let refs = |names: Vec<String>| {
names
.into_iter()
.map(|name| ArtistRef {
key: ArtistKey::Federation {
peer_id: peer_id.clone(),
id: music_dht::normalize_name(&name),
},
name,
})
.collect::<Vec<_>>()
};
let artists = refs(hit.artist_names);
let featured_artists = refs(hit.featured_artist_names);
let artist = artists
.iter()
.map(|artist| artist.name.as_str())
.chain(featured_artists.iter().map(|artist| artist.name.as_str()))
.collect::<Vec<_>>()
.join(", ");
let release = hit.release_title.unwrap_or_default();
let score = hit.score;
let signature = hit.embedding_signature;
Some((
Track {
key: TrackKey::federation(peer_id.clone(), hit.item_id, Some(content_id.clone())),
title: hit.title,
artist,
artists,
featured_artists,
release: release.clone(),
release_id: ReleaseKey::Federation {
peer_id: peer_id.clone(),
id: format!("name:{}", music_dht::normalize_name(&release)),
},
duration_seconds: hit
.duration_seconds
.and_then(|value| u32::try_from(value).ok())
.map_or(0.0, f64::from),
track_number: hit.track_number.and_then(|value| u32::try_from(value).ok()),
disc_number: hit.disc_number.and_then(|value| u32::try_from(value).ok()),
cover_uri: None,
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::Federation {
peer_id,
content_id,
},
},
score,
signature,
))
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
output
}
+415 -18
View File
@@ -13,7 +13,7 @@ use furumi_backend_api::{
FederationActivitySnapshot, FederationDebugSnapshot, FederationOperation, LibrarySnapshot,
PendingPairingSnapshot, PlaybackRepeat, PlaybackStatus, PlaylistSnapshot, RemoteData,
RequestId, SearchResults, SearchSnapshot, SearchStats, SendCommandError, SettingsSnapshot,
VersionEntrySnapshot,
SimilarityStatusSnapshot, VersionEntrySnapshot,
};
use furumi_domain::{
Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId,
@@ -26,18 +26,20 @@ mod actor_devices;
mod audio;
mod devices;
mod federation;
mod federation_similarity;
mod settings;
mod similarity;
mod streaming;
mod support;
use support::{
apply_federated_metadata, expand_tilde, extrapolated_control_position, federation_specs,
find_catalog_track, library_snapshot, library_track, local_search_results,
apply_federated_metadata, extrapolated_control_position, federated_audio_directory,
federation_specs, find_catalog_track, library_snapshot, library_track, local_search_results,
merge_release_preserving_local, merge_search_results, normalize_device_name,
playback_state_acknowledges_command, portable_playback_placeholder,
remote_snapshot_has_authority, runtime_build_info, sanitize_filename, selected_track_position,
spawn_settings_worker, track_is_liked, track_to_library_fed, track_to_playback_track,
track_to_synced_fed, unix_time_ms, volume_percent,
remote_snapshot_has_authority, runtime_build_info, sanitize_filename, seconds_to_milliseconds,
selected_track_position, spawn_settings_worker, track_is_liked, track_to_library_fed,
track_to_playback_track, track_to_synced_fed, unix_time_ms, volume_percent,
};
use settings::SettingsStore;
@@ -46,6 +48,14 @@ const COMMAND_CAPACITY: usize = 64;
const INTERNAL_CAPACITY: usize = 32;
const CONTROL_COMMAND_ACK_TIMEOUT: Duration = Duration::from_secs(12);
const CONTROL_POSITION_ACK_TOLERANCE_SECONDS: f64 = 3.0;
const DESKTOP_APPLICATION_ID: &str = "furumi-desktop";
const SIMILARITY_RESULT_LIMIT: usize = 50;
type SimilarityCandidate = (
Track,
f32,
Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]>,
);
#[derive(Clone)]
pub struct BackendHandle {
@@ -81,20 +91,26 @@ impl BackendHandle {
///
/// Returns an I/O error when the runtime or its owner thread cannot be created.
pub fn spawn_backend() -> Result<BackendHandle, BackendStartupError> {
let project_dirs = directories::ProjectDirs::from("cy", "hexor", "Furumi")
// The music library deliberately keeps using furumi_library::default_db_path()
// below. Everything else belongs to this client and must not reuse the TUI's
// device identity, settings, federation node, or caches.
let project_dirs = directories::ProjectDirs::from("cy", "hexor", DESKTOP_APPLICATION_ID)
.ok_or(BackendStartupError::DataDirectoryUnavailable)?;
let settings_path = project_dirs.data_local_dir().join("furumi-desktop.sqlite3");
let federation_data_dir = project_dirs.data_local_dir().join("federation");
let federation_media_dir = project_dirs.cache_dir().join("federation-media");
let similarity_model_dir = project_dirs.cache_dir().join("similarity-models");
let devices_db_path = project_dirs
.data_local_dir()
.join("devices")
.join("sync.sqlite3");
let settings_store = SettingsStore::open(&settings_path)?;
let library_db_path =
furumi_library::default_db_path().map_err(BackendStartupError::Library)?;
let default_library_path = library_db_path.with_file_name("federation-media");
let settings_store = SettingsStore::open(&settings_path, &default_library_path)?;
let loaded_settings = settings_store.load()?;
let library_path = furumi_library::default_db_path().map_err(BackendStartupError::Library)?;
let catalog = std::sync::Arc::new(
furumi_library::Library::open(&library_path).map_err(BackendStartupError::Library)?,
furumi_library::Library::open(&library_db_path).map_err(BackendStartupError::Library)?,
);
let loaded_library = library_snapshot(&catalog).map_err(BackendStartupError::Library)?;
let runtime = tokio::runtime::Builder::new_multi_thread()
@@ -132,6 +148,7 @@ pub fn spawn_backend() -> Result<BackendHandle, BackendStartupError> {
audio,
federation_data_dir,
federation_media_dir,
similarity_model_dir,
devices,
}));
})?;
@@ -218,6 +235,10 @@ enum InternalEvent {
keys: Vec<TrackKey>,
cover_uri: Option<String>,
},
HistoryTrackResolved {
content_id: String,
result: Result<Track, String>,
},
FederationDebugUpdated(FederationDebugSnapshot),
DevicesChanged,
DeviceLibraryChanged,
@@ -226,6 +247,12 @@ enum InternalEvent {
DeviceOperationFinished(Result<DeviceOperationResult, String>),
DevicePlaybackSnapshot(music_dht::device_sync::PlaybackSnapshot),
DevicePlaybackCommand(music_dht::device_sync::PlaybackCommand),
SimilarityStatus(similarity::SimilarityStatus),
SimilarityProfileActivated(Option<String>),
SimilaritySearchFinished {
source_title: String,
result: Result<Vec<Track>, String>,
},
}
enum DeviceOperationResult {
@@ -246,8 +273,10 @@ struct Actor {
federation: Option<std::sync::Arc<federation::Client>>,
federation_data_dir: std::path::PathBuf,
federation_media_dir: std::path::PathBuf,
similarity: std::sync::Arc<similarity::Manager>,
ephemeral_audio: Option<(TrackKey, std::path::PathBuf)>,
pending_queue_artwork: HashSet<String>,
pending_history_resolutions: HashSet<String>,
federation_debug_pending: bool,
devices: std::sync::Arc<devices::DeviceSync>,
device_service: Option<std::sync::Arc<music_dht::MusicDhtService>>,
@@ -256,6 +285,14 @@ struct Actor {
active_device_name: String,
control_anchor: Option<ControlPlaybackAnchor>,
pending_control: Option<PendingControlState>,
listen_session: Option<ListenSession>,
}
#[derive(Debug, Clone)]
struct ListenSession {
id: String,
track: Track,
started_at_ms: i64,
}
#[derive(Debug, Clone)]
@@ -285,6 +322,7 @@ struct ActorBootstrap {
audio: audio::Controller,
federation_data_dir: std::path::PathBuf,
federation_media_dir: std::path::PathBuf,
similarity_model_dir: std::path::PathBuf,
devices: std::sync::Arc<devices::DeviceSync>,
}
@@ -307,6 +345,7 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
audio,
federation_data_dir,
federation_media_dir,
similarity_model_dir,
devices,
} = bootstrap;
let (settings_tx, settings_rx) = std_mpsc::channel();
@@ -336,6 +375,12 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
settings_error,
..BackendSnapshot::default()
};
let similarity = similarity::Manager::new(
std::sync::Arc::clone(&catalog),
internal_tx.clone(),
initial_state.settings.similarity.clone(),
similarity_model_dir,
);
let mut actor = Actor {
state: initial_state,
library,
@@ -349,8 +394,10 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
federation: None,
federation_data_dir,
federation_media_dir,
similarity,
ephemeral_audio: None,
pending_queue_artwork: HashSet::new(),
pending_history_resolutions: HashSet::new(),
federation_debug_pending: false,
devices,
device_service: None,
@@ -359,7 +406,12 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
active_device_name,
control_anchor: None,
pending_control: None,
listen_session: None,
};
actor.state.similarity_status = similarity_status_snapshot(&actor.similarity.status());
if actor.state.settings.similarity.enabled {
actor.similarity.start();
}
actor.refresh_connected_devices();
ActorRuntime {
actor,
@@ -368,6 +420,22 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
}
}
fn similarity_status_snapshot(status: &similarity::SimilarityStatus) -> SimilarityStatusSnapshot {
SimilarityStatusSnapshot {
phase: status.phase.label().into(),
active_profile: status.active_profile.clone(),
target_profile: status.target_profile.clone(),
model: status.model.clone(),
total_tracks: status.total_tracks,
completed_tracks: status.completed_tracks,
failed_tracks: status.failed_tracks,
stored_vectors: status.stored_vectors,
stored_bytes: status.stored_bytes,
current_track: status.current_track.clone(),
error: status.last_error.clone(),
}
}
async fn run_actor(bootstrap: ActorBootstrap) {
let ActorRuntime {
mut actor,
@@ -400,6 +468,7 @@ async fn run_actor(bootstrap: ActorBootstrap) {
if let Some((_, token)) = actor.active_search.take() {
token.cancel();
}
actor.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
actor.audio.stop();
}
@@ -416,6 +485,7 @@ impl Actor {
let data_dir = self.federation_data_dir.clone();
let media_dir = self.federation_media_dir.clone();
let network = self.state.settings.network_id.trim().to_owned();
let similarity = std::sync::Arc::clone(&self.similarity);
let internal = self.internal.clone();
self.state.federation_activity = FederationActivitySnapshot {
operation: FederationOperation::Idle,
@@ -425,7 +495,7 @@ impl Actor {
};
self.publish();
tokio::spawn(async move {
let result = federation::Client::start(data_dir, media_dir, &network)
let result = federation::Client::start(data_dir, media_dir, &network, similarity)
.await
.map_err(|error| format!("federation: {error:#}"));
let _ = internal
@@ -659,6 +729,7 @@ impl Actor {
}
}
BackendCommand::Stop => {
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
if self.device_role == DevicePlaybackRole::Active {
self.audio.stop();
}
@@ -725,6 +796,36 @@ impl Actor {
self.play_current();
}
}
BackendCommand::MoveQueueItem {
item_id,
target_index,
} => {
if self.state.queue.move_item(item_id, target_index) {
self.send_control_state(false);
self.publish();
}
}
BackendCommand::RemoveQueueItem { item_id } => {
if let Some(removed_current) = self.state.queue.remove_item(item_id) {
if removed_current {
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
self.remove_ephemeral_audio();
if self.state.queue.current().is_some() {
self.play_current();
} else {
self.audio.stop();
self.state.playback.status = PlaybackStatus::Stopped;
self.state.playback.position_seconds = 0.0;
self.state.playback.duration_seconds = 0.0;
self.send_control_state(true);
self.publish();
}
} else {
self.send_control_state(false);
self.publish();
}
}
}
BackendCommand::PlayContext { tracks, selected } => {
let tracks = self.resolve_tracks(&tracks);
if !tracks.is_empty() {
@@ -845,6 +946,8 @@ impl Actor {
}
}
BackendCommand::Search { request_id, query } => self.start_search(request_id, query),
BackendCommand::SearchSimilar { track } => self.start_similarity_search(&track),
BackendCommand::ClearSimilarity => self.similarity.clear(),
BackendCommand::CancelSearch { request_id } => self.cancel_search(request_id),
BackendCommand::LoadArtist {
request_id,
@@ -857,11 +960,13 @@ impl Actor {
artist_key,
artist_name,
} => self.load_detail(request_id, artist_key, artist_name, Some(key)),
BackendCommand::UpdateSettings(settings) => {
BackendCommand::UpdateSettings(mut settings) => {
settings.similarity = settings.similarity.normalized();
let federation_changed = self.state.settings.federation_enabled
!= settings.federation_enabled
|| self.state.settings.network_id != settings.network_id;
let device_name_changed = self.state.settings.device_name != settings.device_name;
let similarity_changed = self.state.settings.similarity != settings.similarity;
let pending_device_name = settings.device_name.clone();
self.state.settings = settings.clone();
self.state.settings_error = None;
@@ -878,6 +983,9 @@ impl Actor {
self.apply_local_device_name(&pending_device_name);
self.schedule_device_name_publish(pending_device_name);
}
if similarity_changed {
self.similarity.apply(&self.state.settings.similarity);
}
}
BackendCommand::Shutdown => {}
}
@@ -1147,6 +1255,7 @@ impl Actor {
});
}
self.federation = Some(client);
self.resolve_history_tracks();
self.state.federation_activity.pending = false;
self.resolve_queue_artwork();
self.refresh_federation_debug();
@@ -1223,6 +1332,7 @@ impl Actor {
.current()
.is_some_and(|item| item.track.key.matches(&key))
{
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
self.state.playback.status = PlaybackStatus::Stopped;
self.state.playback_error = Some(message);
self.publish();
@@ -1270,6 +1380,7 @@ impl Actor {
}
}
Err(message) => {
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
self.state.playback.status = PlaybackStatus::Stopped;
self.state.playback_error = Some(message);
self.publish();
@@ -1299,6 +1410,27 @@ impl Actor {
self.publish();
}
}
InternalEvent::HistoryTrackResolved { content_id, result } => {
self.pending_history_resolutions.remove(&content_id);
if let Ok(mut resolved) = result {
let mut changed = false;
for track in &mut self.library.recently_played {
if track
.key
.content_id()
.is_some_and(|id| id.as_str() == content_id)
{
resolved.liked = track.liked;
*track = resolved.clone();
changed = true;
}
}
if changed {
self.state.library = RemoteData::Ready(self.library.clone());
self.publish();
}
}
}
InternalEvent::FederationDebugUpdated(debug) => {
self.federation_debug_pending = false;
if self.state.federation_debug != debug {
@@ -1311,6 +1443,8 @@ impl Actor {
self.library = library.clone();
self.state.library = RemoteData::Ready(library);
self.reconcile_likes();
self.similarity.start();
self.resolve_history_tracks();
}
self.refresh_connected_devices();
self.publish();
@@ -1338,9 +1472,129 @@ impl Actor {
InternalEvent::DevicePlaybackCommand(command) => {
self.apply_device_playback_command(command);
}
InternalEvent::SimilarityStatus(status) => {
self.state.similarity_status = similarity_status_snapshot(&status);
self.publish();
}
InternalEvent::SimilarityProfileActivated(active_profile) => {
self.state.settings.similarity.active_profile = active_profile;
let _ = self.settings.send(self.state.settings.clone());
self.publish();
}
InternalEvent::SimilaritySearchFinished {
source_title,
result,
} => {
self.state.similarity_search.pending = false;
self.state.similarity_search.source_title = source_title;
match result {
Ok(mut tracks) => {
let liked = self
.catalog
.liked_content_ids()
.unwrap_or_default()
.into_iter()
.chain(self.catalog.fed_like_ids().unwrap_or_default())
.collect::<HashSet<_>>();
for track in &mut tracks {
track.liked = track_is_liked(track, &liked);
}
self.state.similarity_search.results = tracks;
self.state.similarity_search.error = None;
}
Err(error) => {
self.state.similarity_search.results.clear();
self.state.similarity_search.error = Some(error);
}
}
self.publish();
}
}
}
fn start_similarity_search(&mut self, key: &TrackKey) {
let Some(source) = self.track(key).cloned() else {
self.state.similarity_search.error = Some("Track is unavailable".into());
self.publish();
return;
};
if !self.state.settings.similarity.enabled {
self.state.similarity_search.error =
Some("Enable Similarity search in Settings first".into());
self.state.similarity_search.results.clear();
self.publish();
return;
}
let Some(track_id) = source.key.local_id().map(LocalTrackId::get) else {
self.state.similarity_search.error =
Some("Similarity search currently starts from a local track".into());
self.state.similarity_search.results.clear();
self.publish();
return;
};
self.state
.similarity_search
.source_title
.clone_from(&source.title);
self.state.similarity_search.results.clear();
self.state.similarity_search.error = None;
self.state.similarity_search.pending = true;
self.publish();
let manager = std::sync::Arc::clone(&self.similarity);
let federation = self.federation.clone();
let settings = self.state.settings.similarity.clone();
let internal = self.internal.clone();
let source_title = source.title;
tokio::spawn(async move {
let local = tokio::task::spawn_blocking(move || manager.search_track(track_id, 50))
.await
.map_err(|error| format!("similarity worker failed: {error}"))
.and_then(|result| result.map_err(|error| format!("{error:#}")));
let result = match local {
Ok((local, query)) => {
let mut scored = local
.into_iter()
.map(|found| {
(
library_track(found.track, ""),
found.score,
Some(found.embedding_signature),
)
})
.collect::<Vec<_>>();
if settings.federation_consent
&& let Some(federation) = federation
&& let Ok(remote) = federation
.search_similar(
query,
50,
settings.minimum_score,
settings.max_tracks_per_artist,
)
.await
{
scored.extend(
remote
.into_iter()
.map(|hit| (hit.track, hit.score, hit.embedding_signature)),
);
}
Ok(rank_similarity_candidates(
scored,
settings.max_tracks_per_artist,
))
}
Err(error) => Err(error),
};
let _ = internal
.send(InternalEvent::SimilaritySearchFinished {
source_title,
result,
})
.await;
});
}
fn apply_remote_playback_snapshot(
&mut self,
snapshot: music_dht::device_sync::PlaybackSnapshot,
@@ -1378,6 +1632,9 @@ impl Actor {
self.pending_control = None;
}
if self.device_role == DevicePlaybackRole::Active {
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
}
self.device_role = DevicePlaybackRole::Control;
self.active_device_id.clone_from(&snapshot.device_id);
self.active_device_name.clone_from(&snapshot.device_name);
@@ -1445,6 +1702,7 @@ impl Actor {
self.publish();
}
audio::Event::Finished => {
self.finish_listen(music_dht::device_sync::ListenEndReason::Finished);
self.remove_ephemeral_audio();
if self.advance_queue(true) {
self.play_current();
@@ -1456,6 +1714,7 @@ impl Actor {
}
}
audio::Event::Failed(message) => {
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
self.state.playback.status = PlaybackStatus::Stopped;
self.state.playback_error = Some(message);
self.publish();
@@ -1466,10 +1725,12 @@ impl Actor {
fn play_current(&mut self) {
self.remove_ephemeral_audio();
let Some(track) = self.state.queue.current().map(|item| item.track.clone()) else {
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
self.state.playback.status = PlaybackStatus::Stopped;
self.publish();
return;
};
self.begin_listen(&track);
self.state.playback.position_seconds = 0.0;
self.state.playback.duration_seconds = track.duration_seconds.max(0.0);
self.state.playback_error = None;
@@ -1495,6 +1756,46 @@ impl Actor {
self.publish();
}
fn begin_listen(&mut self, track: &Track) {
if self.device_role != DevicePlaybackRole::Active {
return;
}
if let Some(session) = &mut self.listen_session
&& session.track.key.matches(&track.key)
{
session.track = track.clone();
return;
}
self.finish_listen(music_dht::device_sync::ListenEndReason::Replaced);
if track.key.content_id().is_some() {
self.listen_session = Some(ListenSession {
id: devices::DeviceSync::new_listen_id(),
track: track.clone(),
started_at_ms: unix_time_ms(),
});
}
}
fn finish_listen(&mut self, ended_reason: music_dht::device_sync::ListenEndReason) {
let Some(session) = self.listen_session.take() else {
return;
};
let listened_ms = if ended_reason == music_dht::device_sync::ListenEndReason::Finished {
seconds_to_milliseconds(session.track.duration_seconds)
} else {
seconds_to_milliseconds(self.state.playback.position_seconds)
};
if let Err(error) = self.devices.record_listen(
session.id,
&session.track,
session.started_at_ms,
listened_ms,
ended_reason,
) {
self.state.settings_error = Some(format!("listening history: {error:#}"));
}
}
fn shuffle_new_context(&mut self) {
if self.state.playback.shuffle {
self.state.queue.shuffle_upcoming();
@@ -1514,6 +1815,8 @@ impl Actor {
fn start_federated_playback(&mut self, track: Track, peer_id: String, content_id: ContentId) {
let Some(client) = self.federation.clone() else {
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
self.state.playback.status = PlaybackStatus::Stopped;
self.state.playback_error = Some("federation is still starting".into());
self.publish();
return;
@@ -1548,11 +1851,11 @@ impl Actor {
let key = track.key.clone();
let internal = self.internal.clone();
let keep = self.state.settings.save_federated_on_listen;
let directory = if keep {
expand_tilde(&self.state.settings.library_path)
} else {
self.federation_media_dir.join("stream-cache")
};
let directory = federated_audio_directory(
&self.state.settings.library_path,
keep,
&self.federation_media_dir,
);
let stem = format!("fed-{}", sanitize_filename(&track.title));
tokio::spawn(async move {
let (tx, mut rx) = mpsc::channel(4);
@@ -1673,7 +1976,20 @@ impl Actor {
}
fn track(&self, key: &TrackKey) -> Option<&Track> {
find_catalog_track(&self.library, &self.state.search.results, key)
self.state
.queue
.items()
.iter()
.map(|item| &item.track)
.find(|track| track.key.matches(key))
.or_else(|| find_catalog_track(&self.library, &self.state.search.results, key))
.or_else(|| {
self.state
.similarity_search
.results
.iter()
.find(|track| track.key.matches(key))
})
}
fn resolve_tracks(&self, keys: &[TrackKey]) -> Vec<Track> {
@@ -1688,6 +2004,7 @@ impl Actor {
self.library = library.clone();
self.state.library = RemoteData::Ready(library);
self.reconcile_likes();
self.resolve_history_tracks();
}
Err(error) => {
self.state.settings_error = Some(format!("library: {error:#}"));
@@ -1696,6 +2013,35 @@ impl Actor {
self.publish();
}
fn resolve_history_tracks(&mut self) {
let Some(client) = self.federation.clone() else {
return;
};
let unresolved = self
.library
.recently_played
.iter()
.filter(|track| track.key.local_id().is_none() && track.cover_uri.is_none())
.filter_map(|track| track.key.content_id().map(|id| id.as_str().to_owned()))
.collect::<HashSet<_>>();
for content_id in unresolved {
if !self.pending_history_resolutions.insert(content_id.clone()) {
continue;
}
let internal = self.internal.clone();
let client = client.clone();
tokio::spawn(async move {
let result = client
.track_by_content_id(&content_id)
.await
.map_err(|error| format!("history track lookup failed: {error:#}"));
let _ = internal
.send(InternalEvent::HistoryTrackResolved { content_id, result })
.await;
});
}
}
fn reconcile_likes(&mut self) {
let liked = self
.catalog
@@ -1714,11 +2060,15 @@ impl Actor {
) {
track.liked = track_is_liked(track, &liked);
}
for track in &mut self.state.similarity_search.results {
track.liked = track_is_liked(track, &liked);
}
let replacements = self
.library
.featured_releases
.iter()
.flat_map(|release| release.tracks.iter())
.chain(self.library.recently_played.iter())
.chain(
self.library
.playlists
@@ -1848,5 +2198,52 @@ impl Actor {
}
}
fn rank_similarity_candidates(
mut candidates: Vec<SimilarityCandidate>,
max_tracks_per_artist: usize,
) -> Vec<Track> {
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
candidates.sort_by(|left, right| right.1.total_cmp(&left.1));
let mut content = HashSet::new();
let mut signatures = Vec::new();
let mut artist_counts = HashMap::<String, usize>::new();
let mut tracks = Vec::new();
for (track, _, signature) in candidates {
let identity = track
.key
.content_id()
.map_or_else(|| format!("{:?}", track.key), |id| id.as_str().to_owned());
if !content.insert(identity) {
continue;
}
if signature.is_some_and(|candidate| {
signatures.iter().any(|existing| {
music_dht::similarity::signature_distance(&candidate, existing)
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
})
}) {
continue;
}
let artist = track.artists.first().map_or_else(
|| music_dht::normalize_name(&track.artist),
|artist| music_dht::normalize_name(&artist.name),
);
let count = artist_counts.entry(artist.clone()).or_default();
if !artist.is_empty() && *count >= max_tracks_per_artist.clamp(1, SIMILARITY_RESULT_LIMIT) {
continue;
}
*count += 1;
if let Some(signature) = signature {
signatures.push(signature);
}
tracks.push(track);
if tracks.len() >= SIMILARITY_RESULT_LIMIT {
break;
}
}
tracks
}
#[cfg(test)]
mod tests;
+136 -8
View File
@@ -4,6 +4,9 @@ use std::path::Path;
use furumi_backend_api::SettingsSnapshot;
use rusqlite::{Connection, OptionalExtension, params};
const LEGACY_DEFAULT_LIBRARY_PATH: &str = "~/Music/Furumi";
const PLATFORM_LIBRARY_PATH_MIGRATION: i64 = 4;
const MIGRATIONS: &[(i64, &str)] = &[
(
1,
@@ -28,6 +31,19 @@ const MIGRATIONS: &[(i64, &str)] = &[
3,
"ALTER TABLE app_settings ADD COLUMN device_name TEXT NOT NULL DEFAULT '';",
),
(
5,
r"
ALTER TABLE app_settings ADD COLUMN similarity_enabled INTEGER NOT NULL DEFAULT 0 CHECK (similarity_enabled IN (0, 1));
ALTER TABLE app_settings ADD COLUMN similarity_model TEXT NOT NULL DEFAULT 'discogs-effnet-bsdynamic-1';
ALTER TABLE app_settings ADD COLUMN similarity_profile TEXT NOT NULL DEFAULT 'furumi-full-track-v1';
ALTER TABLE app_settings ADD COLUMN similarity_workers INTEGER NOT NULL DEFAULT 2;
ALTER TABLE app_settings ADD COLUMN similarity_minimum_score REAL NOT NULL DEFAULT 0.70;
ALTER TABLE app_settings ADD COLUMN similarity_max_tracks_per_artist INTEGER NOT NULL DEFAULT 5;
ALTER TABLE app_settings ADD COLUMN similarity_federation_consent INTEGER NOT NULL DEFAULT 0 CHECK (similarity_federation_consent IN (0, 1));
ALTER TABLE app_settings ADD COLUMN similarity_active_profile TEXT;
",
),
];
pub struct SettingsStore {
@@ -35,32 +51,47 @@ pub struct SettingsStore {
}
impl SettingsStore {
pub fn open(path: &Path) -> rusqlite::Result<Self> {
pub fn open(path: &Path, default_library_path: &Path) -> rusqlite::Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
}
let connection = Connection::open(path)?;
let mut store = Self { connection };
store.migrate()?;
store.migrate(default_library_path)?;
Ok(store)
}
#[cfg(test)]
fn in_memory() -> rusqlite::Result<Self> {
fn in_memory(default_library_path: &Path) -> rusqlite::Result<Self> {
let connection = Connection::open_in_memory()?;
let mut store = Self { connection };
store.migrate()?;
store.migrate(default_library_path)?;
Ok(store)
}
pub fn load(&self) -> rusqlite::Result<SettingsSnapshot> {
self.connection.query_row(
"SELECT network_id, library_path, federation_enabled, language,
save_federated_on_listen, device_name
save_federated_on_listen, device_name,
similarity_enabled, similarity_model, similarity_profile,
similarity_workers, similarity_minimum_score,
similarity_max_tracks_per_artist, similarity_federation_consent,
similarity_active_profile
FROM app_settings WHERE singleton_id = 1",
[],
|row| {
let similarity = furumi_backend_api::SimilaritySettingsSnapshot {
enabled: row.get::<_, i64>(6)? != 0,
model: row.get(7)?,
profile: row.get(8)?,
workers: usize::try_from(row.get::<_, i64>(9)?).unwrap_or(1),
minimum_score: row.get(10)?,
max_tracks_per_artist: usize::try_from(row.get::<_, i64>(11)?).unwrap_or(1),
federation_consent: row.get::<_, i64>(12)? != 0,
active_profile: row.get(13)?,
}
.normalized();
Ok(SettingsSnapshot {
network_id: row.get(0)?,
library_path: row.get(1)?,
@@ -68,6 +99,7 @@ impl SettingsStore {
language: row.get(3)?,
save_federated_on_listen: row.get::<_, i64>(4)? != 0,
device_name: row.get(5)?,
similarity,
})
},
)
@@ -81,7 +113,15 @@ impl SettingsStore {
federation_enabled = ?3,
language = ?4,
save_federated_on_listen = ?5,
device_name = ?6
device_name = ?6,
similarity_enabled = ?7,
similarity_model = ?8,
similarity_profile = ?9,
similarity_workers = ?10,
similarity_minimum_score = ?11,
similarity_max_tracks_per_artist = ?12,
similarity_federation_consent = ?13,
similarity_active_profile = ?14
WHERE singleton_id = 1",
params![
settings.network_id,
@@ -90,12 +130,20 @@ impl SettingsStore {
settings.language,
i64::from(settings.save_federated_on_listen),
settings.device_name,
i64::from(settings.similarity.enabled),
settings.similarity.model,
settings.similarity.profile,
i64::try_from(settings.similarity.workers).unwrap_or(16),
settings.similarity.minimum_score,
i64::try_from(settings.similarity.max_tracks_per_artist).unwrap_or(50),
i64::from(settings.similarity.federation_consent),
settings.similarity.active_profile,
],
)?;
Ok(())
}
fn migrate(&mut self) -> rusqlite::Result<()> {
fn migrate(&mut self, default_library_path: &Path) -> rusqlite::Result<()> {
self.connection.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS schema_migrations (
@@ -119,14 +167,55 @@ impl SettingsStore {
}
let transaction = self.connection.transaction()?;
transaction.execute_batch(sql)?;
if version == 5 {
transaction.execute(
"UPDATE app_settings SET similarity_workers = ?1 WHERE singleton_id = 1",
[i64::try_from(
furumi_backend_api::SimilaritySettingsSnapshot::default().workers,
)
.unwrap_or(1)],
)?;
}
transaction.execute(
"INSERT INTO schema_migrations (version) VALUES (?1)",
[version],
)?;
transaction.commit()?;
}
self.migrate_platform_library_path(default_library_path)?;
Ok(())
}
fn migrate_platform_library_path(
&mut self,
default_library_path: &Path,
) -> rusqlite::Result<()> {
let applied = self
.connection
.query_row(
"SELECT 1 FROM schema_migrations WHERE version = ?1",
[PLATFORM_LIBRARY_PATH_MIGRATION],
|_| Ok(()),
)
.optional()?
.is_some();
if applied {
return Ok(());
}
let default_library_path = default_library_path.to_string_lossy();
let transaction = self.connection.transaction()?;
transaction.execute(
"UPDATE app_settings
SET library_path = ?1
WHERE library_path = ?2 OR trim(library_path) = ''",
params![default_library_path.as_ref(), LEGACY_DEFAULT_LIBRARY_PATH],
)?;
transaction.execute(
"INSERT INTO schema_migrations (version) VALUES (?1)",
[PLATFORM_LIBRARY_PATH_MIGRATION],
)?;
transaction.commit()
}
}
#[cfg(test)]
@@ -135,19 +224,58 @@ mod tests {
#[test]
fn migration_creates_defaults_and_settings_round_trip() {
let store = SettingsStore::in_memory().unwrap();
let default_library_path = Path::new("/platform/furumi/federation-media");
let store = SettingsStore::in_memory(default_library_path).unwrap();
let mut settings = store.load().unwrap();
assert_eq!(settings.network_id, "furumi");
assert_eq!(
settings.library_path,
default_library_path.to_string_lossy()
);
assert!(settings.federation_enabled);
assert!(settings.save_federated_on_listen);
assert!(settings.device_name.is_empty());
assert!(!settings.similarity.enabled);
assert_eq!(
settings.similarity.workers,
furumi_backend_api::SimilaritySettingsSnapshot::default().workers
);
assert!((settings.similarity.minimum_score - 0.70).abs() < f32::EPSILON);
settings.network_id = "friends".into();
settings.device_name = "Studio Mac".into();
settings.library_path = "/music/library".into();
settings.federation_enabled = false;
settings.similarity.enabled = true;
settings.similarity.workers = 7;
settings.similarity.minimum_score = 0.82;
settings.similarity.max_tracks_per_artist = 9;
settings.similarity.federation_consent = true;
settings.similarity.active_profile = Some("sim1:test".into());
store.save(&settings).unwrap();
assert_eq!(store.load().unwrap(), settings);
}
#[test]
fn platform_default_migration_preserves_a_custom_library_path() {
let first_default = Path::new("/first/furumi/federation-media");
let mut store = SettingsStore::in_memory(first_default).unwrap();
let mut settings = store.load().unwrap();
settings.library_path = "/custom/music".into();
store.save(&settings).unwrap();
store
.connection
.execute(
"DELETE FROM schema_migrations WHERE version = ?1",
[PLATFORM_LIBRARY_PATH_MIGRATION],
)
.unwrap();
store
.migrate(Path::new("/second/furumi/federation-media"))
.unwrap();
assert_eq!(store.load().unwrap().library_path, "/custom/music");
}
}
File diff suppressed because it is too large Load Diff
+77 -5
View File
@@ -15,6 +15,18 @@ pub(super) fn expand_tilde(value: &str) -> std::path::PathBuf {
}
}
pub(super) fn federated_audio_directory(
library_path: &str,
keep: bool,
federation_cache_dir: &std::path::Path,
) -> std::path::PathBuf {
if keep {
expand_tilde(library_path)
} else {
federation_cache_dir.join("stream-cache")
}
}
pub(super) fn normalize_device_name(value: &str) -> String {
let value = value.trim();
if value.is_empty() {
@@ -31,10 +43,18 @@ pub(super) fn selected_track_position(tracks: &[Track], selected: &TrackKey) ->
.unwrap_or(0)
}
pub(super) fn seconds_to_milliseconds(seconds: f64) -> i64 {
if !seconds.is_finite() || seconds <= 0.0 {
return 0;
}
let duration = Duration::from_secs_f64(seconds.min(Duration::MAX.as_secs_f64()));
i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
}
pub(super) fn runtime_build_info() -> BuildInfoSnapshot {
use music_dht::capabilities::{
CATALOG_ID, CapabilityManifest, DEVICE_SYNC_ID, FEDERATION_NET_ID, MUSIC_DHT_ID,
RENDEZVOUS_ID, TICKET_ID,
RENDEZVOUS_ID, SIMILARITY_DHT_ID, SIMILARITY_ID, TICKET_ID,
};
let manifest = CapabilityManifest::frid("furumi-desktop", env!("CARGO_PKG_VERSION"));
@@ -70,6 +90,8 @@ pub(super) fn runtime_build_info() -> BuildInfoSnapshot {
protocol("Rendezvous", RENDEZVOUS_ID),
protocol("Music DHT", MUSIC_DHT_ID),
protocol("Catalog", CATALOG_ID),
protocol("Similarity search", SIMILARITY_ID),
protocol("Similarity routing", SIMILARITY_DHT_ID),
VersionEntrySnapshot {
name: "Audio transfer".into(),
version: federation::AUDIO_PROTOCOL_VERSION.to_string(),
@@ -98,6 +120,7 @@ pub(super) fn find_catalog_track<'a>(
.featured_releases
.iter()
.flat_map(|release| release.tracks.iter())
.chain(library.recently_played.iter())
.chain(
library
.playlists
@@ -785,10 +808,22 @@ pub(super) fn library_snapshot(
tracks,
});
}
let recently_played = releases
.iter()
.flat_map(|release| release.tracks.iter().cloned())
.take(12)
let recently_played = catalog
.listen_history(500)?
.into_iter()
.filter_map(|entry| {
let content_id = ContentId::parse(entry.content_id.clone()).ok()?;
let mut track = catalog
.track_by_content_id(content_id.as_str())
.ok()
.flatten()
.map_or_else(
|| history_placeholder(&entry, content_id),
|track| library_track(track, ""),
);
track.liked = track_is_liked(&track, &liked_ids);
Some(track)
})
.collect();
let mut playlists = Vec::new();
for card in catalog.playlists()? {
@@ -816,6 +851,43 @@ pub(super) fn library_snapshot(
})
}
fn history_placeholder(entry: &furumi_library::ListenHistoryEntry, content_id: ContentId) -> Track {
let peer_id = "history".to_owned();
let artist = ArtistRef {
key: ArtistKey::Federation {
peer_id: peer_id.clone(),
id: format!("name:{}", music_dht::normalize_name(&entry.artist)),
},
name: entry.artist.clone(),
};
Track {
key: TrackKey::remote(content_id.clone()),
title: entry.title.clone(),
artist: entry.artist.clone(),
artists: vec![artist],
featured_artists: Vec::new(),
release: String::new(),
release_id: ReleaseKey::Federation {
peer_id: peer_id.clone(),
id: format!("history:{}", entry.listen_id),
},
duration_seconds: 0.0,
track_number: None,
disc_number: None,
cover_uri: None,
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::Federation {
peer_id,
content_id,
},
}
}
pub(super) fn track_is_liked(track: &Track, liked_ids: &HashSet<String>) -> bool {
track
.key
+65
View File
@@ -1,5 +1,19 @@
use super::*;
#[test]
fn configured_library_path_controls_permanent_federated_audio_storage() {
let cache = std::path::Path::new("/cache/furumi-desktop/federation-media");
assert_eq!(
federated_audio_directory("/chosen/music", true, cache),
std::path::Path::new("/chosen/music")
);
assert_eq!(
federated_audio_directory("/chosen/music", false, cache),
cache.join("stream-cache")
);
}
fn merge_test_track(key: TrackKey, audio_source: AudioSource) -> Track {
Track {
key,
@@ -26,6 +40,57 @@ fn merge_test_track(key: TrackKey, audio_source: AudioSource) -> Track {
}
}
fn similarity_test_candidate(
id: i64,
artist: &str,
score: f32,
signature: u8,
) -> SimilarityCandidate {
let mut track = merge_test_track(
TrackKey::local(LocalTrackId::new(id)),
AudioSource::LocalFile(format!("{id}.flac").into()),
);
track.title = format!("Track {id}");
track.artist = artist.into();
track.artists[0].name = artist.into();
(
track,
score,
Some([signature; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]),
)
}
#[test]
fn similarity_results_apply_one_artist_cap_after_combining_sources() {
let tracks = rank_similarity_candidates(
vec![
similarity_test_candidate(1, "same artist", 0.70, 1),
similarity_test_candidate(2, "other artist", 0.90, 2),
similarity_test_candidate(3, "same artist", 0.80, 3),
similarity_test_candidate(4, "same artist", 0.60, 4),
],
1,
);
assert_eq!(tracks.len(), 2);
assert_eq!(tracks[0].title, "Track 2");
assert_eq!(tracks[1].title, "Track 3");
}
#[test]
fn similarity_results_drop_cross_source_near_duplicates() {
let tracks = rank_similarity_candidates(
vec![
similarity_test_candidate(1, "first", 0.90, 7),
similarity_test_candidate(2, "second", 0.80, 7),
],
5,
);
assert_eq!(tracks.len(), 1);
assert_eq!(tracks[0].title, "Track 1");
}
#[test]
fn merging_search_results_deduplicates_source_keys() {
let artist = Artist {
+71
View File
@@ -393,6 +393,44 @@ impl Queue {
self.play_next_end = Some(insertion + count);
}
/// Moves one concrete queue occurrence to a final visual position.
pub fn move_item(&mut self, id: QueueItemId, target_index: usize) -> bool {
let Some(source_index) = self.items.iter().position(|item| item.id == id) else {
return false;
};
let target_index = target_index.min(self.items.len().saturating_sub(1));
if source_index == target_index {
return false;
}
let current_id = self.current().map(|item| item.id);
let item = self.items.remove(source_index);
self.items.insert(target_index, item);
self.current =
current_id.and_then(|current| self.items.iter().position(|item| item.id == current));
self.play_next_end = None;
self.original_order = None;
true
}
/// Removes one concrete queue occurrence and keeps the nearest item selected.
///
/// Returns whether the removed occurrence was the current one.
pub fn remove_item(&mut self, id: QueueItemId) -> Option<bool> {
let index = self.items.iter().position(|item| item.id == id)?;
let removed_current = self.current == Some(index);
self.items.remove(index);
self.current = match self.current {
None => None,
Some(_) if self.items.is_empty() => None,
Some(current) if index < current => Some(current - 1),
Some(current) if index == current => Some(index.min(self.items.len() - 1)),
Some(current) => Some(current),
};
self.play_next_end = None;
self.original_order = None;
Some(removed_current)
}
pub fn replace_matching_track(&mut self, replacement: &Track) {
for item in &mut self.items {
if item.track.key.matches(&replacement.key) {
@@ -589,6 +627,39 @@ mod tests {
);
}
#[test]
fn moving_queue_items_preserves_the_current_occurrence() {
let mut queue = Queue::default();
queue.replace_context((1..=4).map(track).collect(), 1);
let moved = queue.items()[0].id;
let current = queue.current().unwrap().id;
assert!(queue.move_item(moved, 3));
assert_eq!(queue.current().unwrap().id, current);
assert_eq!(queue.current_index(), Some(0));
assert_eq!(
queue
.items()
.iter()
.map(|item| item.track.key.local_id().unwrap().get())
.collect::<Vec<_>>(),
vec![2, 3, 4, 1]
);
}
#[test]
fn removing_current_queue_item_selects_the_next_occurrence() {
let mut queue = Queue::default();
queue.replace_context((1..=3).map(track).collect(), 1);
let current = queue.current().unwrap().id;
assert_eq!(queue.remove_item(current), Some(true));
assert_eq!(queue.current_index(), Some(1));
assert_eq!(queue.current().unwrap().track.title, "3");
}
#[test]
fn shuffle_preserves_current_track_and_can_restore_the_tail() {
let mut queue = Queue::default();
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 3-6.7"/>
<path d="M3 4.5v5h5"/>
<path d="M12 7.5V12l3 2"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+162 -3
View File
@@ -29,7 +29,7 @@ slint::include_modules!();
mod render;
use render::{
breadcrumb_screen, contributor_lines, find_artist_key, find_release_key, model,
breadcrumb_screen, contributor_lines, find_release_key, model, parse_artist_key,
parse_track_key, release_artist_credits, render, render_catalog, render_current_track,
render_playback, render_queue, render_search, render_shell, render_track_info,
selected_release, track_context,
@@ -99,6 +99,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
let screen = match target.as_str() {
"search" => Screen::Search,
"library" => Screen::Library,
"history" => Screen::History,
_ => Screen::Home,
};
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
@@ -144,6 +145,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
"add-end" => UiAction::AddToEnd(vec![key]),
"information" => UiAction::ShowTrackInfo(key),
"playlist" => UiAction::ShowPlaylistPicker(key),
"similar" => UiAction::SearchSimilar(key),
// The remaining presentation actions terminate here until
// their backend capabilities are introduced.
_ => return,
@@ -152,6 +154,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
}
});
bind_playlist_callbacks(window, state, backend);
bind_queue_callbacks(window, state, backend);
bind_device_callbacks(window, state, backend);
bind_settings_callbacks(window, state, backend);
window.on_dismiss_error({
@@ -236,6 +239,9 @@ fn bind_playlist_callbacks(
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::ClosePlaylistPicker)
});
}
fn bind_queue_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
window.on_play_queue_item({
let window = window.as_weak();
let state = Arc::clone(state);
@@ -251,6 +257,41 @@ fn bind_playlist_callbacks(
}
}
});
window.on_move_queue_item({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |id, target_index| {
let (Ok(id), Ok(target_index)) = (id.parse::<u64>(), usize::try_from(target_index))
else {
return;
};
dispatch_action(
&window,
&state,
&backend,
UiAction::MoveQueueItem {
item_id: QueueItemId::new(id),
target_index,
},
);
}
});
window.on_remove_queue_item({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |id| {
if let Ok(id) = id.parse::<u64>() {
dispatch_action(
&window,
&state,
&backend,
UiAction::RemoveQueueItem(QueueItemId::new(id)),
);
}
}
});
}
fn bind_catalog_callbacks(
@@ -295,7 +336,7 @@ fn bind_catalog_callbacks(
let state = Arc::clone(state);
let backend = backend.clone();
move |target| {
let screen = with_state(&state, |state| breadcrumb_screen(state, &target));
let screen = breadcrumb_screen(&target);
if let Some(screen) = screen {
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
}
@@ -306,7 +347,7 @@ fn bind_catalog_callbacks(
let state = Arc::clone(state);
let backend = backend.clone();
move |key| {
let artist = with_state(&state, |state| find_artist_key(state, &key));
let artist = parse_artist_key(&key);
if let Some(key) = artist {
dispatch_action(
&window,
@@ -519,6 +560,7 @@ fn bind_settings_callbacks(
);
}
});
bind_similarity_settings_callbacks(window, state, backend);
window.on_language_changed({
let window = window.as_weak();
let state = Arc::clone(state);
@@ -534,6 +576,118 @@ fn bind_settings_callbacks(
});
}
#[allow(
clippy::too_many_lines,
reason = "one explicit binding per similarity setting keeps callback ownership obvious"
)]
fn bind_similarity_settings_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_similarity_enabled_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |enabled| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SimilarityEnabledChanged(enabled),
);
}
});
window.on_similarity_model_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |model| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SimilarityModelChanged(model.to_string()),
);
}
});
window.on_similarity_profile_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |profile| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SimilarityProfileChanged(profile.to_string()),
);
}
});
window.on_similarity_workers_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |workers| {
if let Ok(workers) = usize::try_from(workers) {
dispatch_action(
&window,
&state,
&backend,
UiAction::SimilarityWorkersChanged(workers),
);
}
}
});
window.on_similarity_minimum_score_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |minimum_score| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SimilarityMinimumScoreChanged(minimum_score),
);
}
});
window.on_similarity_max_tracks_per_artist_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |maximum| {
if let Ok(maximum) = usize::try_from(maximum) {
dispatch_action(
&window,
&state,
&backend,
UiAction::SimilarityMaxTracksPerArtistChanged(maximum),
);
}
}
});
window.on_similarity_federation_consent_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |consent| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SimilarityFederationConsentChanged(consent),
);
}
});
window.on_clear_similarity({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::ClearSimilarity)
});
}
fn bind_library_picker(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
window.on_choose_library_path({
let window = window.as_weak();
@@ -717,11 +871,13 @@ fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, event: AppEv
|| previous.federation_debug != state.backend.federation_debug
|| previous.connected_devices != state.backend.connected_devices
|| previous.settings != state.backend.settings
|| previous.similarity_status != state.backend.similarity_status
|| previous.playback_error != state.backend.playback_error
|| previous.settings_error != state.backend.settings_error;
let catalog_changed = previous.library != state.backend.library
|| previous.search != state.backend.search
|| previous.queue != state.backend.queue;
let similarity_changed = previous.similarity_search != state.backend.similarity_search;
let queue_changed = previous.queue != state.backend.queue;
if shell_changed {
render_shell(window, state);
@@ -735,6 +891,9 @@ fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, event: AppEv
render_queue(window, state);
render_current_track(window, state);
}
if similarity_changed {
render::render_similarity(window, state);
}
render_playback(window, state);
});
}
+211 -27
View File
@@ -3,6 +3,7 @@ pub(super) fn render(window: &AppWindow, state: &AppState) {
render_shell(window, state);
render_catalog(window, state);
render_search(window, state);
render_similarity(window, state);
render_queue(window, state);
render_current_track(window, state);
render_playback(window, state);
@@ -15,7 +16,7 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
window.set_search_label(strings.search.into());
window.set_library_label(strings.library.into());
window.set_queue_label(strings.queue.into());
window.set_recent_label(strings.recently_played.into());
window.set_history_label(strings.listening_history.into());
window.set_featured_label(strings.made_for_listening.into());
window.set_search_placeholder(strings.search_placeholder.into());
window.set_active_screen(
@@ -23,6 +24,8 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
Screen::Home => "home",
Screen::Search => "search",
Screen::Library => "library",
Screen::History => "history",
Screen::Similarity => "similarity",
Screen::Artist(_) => "artist",
Screen::Release(_, _) => "release",
Screen::Playlist(_) => "playlist",
@@ -40,6 +43,46 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
window.set_library_path(state.backend.settings.library_path.clone().into());
window.set_federation_enabled(state.backend.settings.federation_enabled);
window.set_save_federated_on_listen(state.backend.settings.save_federated_on_listen);
let similarity = &state.backend.settings.similarity;
window.set_similarity_enabled(similarity.enabled);
window.set_similarity_model(similarity.model.clone().into());
window.set_similarity_models(model(vec![SharedString::from(
"discogs-effnet-bsdynamic-1",
)]));
window.set_similarity_profile(similarity.profile.clone().into());
window.set_similarity_profiles(model(vec![SharedString::from("furumi-full-track-v1")]));
window.set_similarity_workers(i32::try_from(similarity.workers).unwrap_or(16));
window.set_similarity_minimum_score(similarity.minimum_score);
window.set_similarity_max_tracks_per_artist(
i32::try_from(similarity.max_tracks_per_artist).unwrap_or(50),
);
window.set_similarity_federation_consent(similarity.federation_consent);
let status = &state.backend.similarity_status;
window.set_similarity_status_phase(status.phase.clone().into());
window.set_similarity_status_progress(
format!("{} / {}", status.completed_tracks, status.total_tracks).into(),
);
window.set_similarity_status_storage(
format!(
"{} / {}",
status.stored_vectors,
compact_bytes(status.stored_bytes)
)
.into(),
);
window.set_similarity_status_current(
status
.current_track
.clone()
.or_else(|| status.error.clone())
.unwrap_or_else(|| {
status.active_profile.as_deref().map_or_else(
|| "Index is not ready".into(),
|profile| format!("Active: {profile}"),
)
})
.into(),
);
window.set_selected_language(state.backend.settings.language.clone().into());
window.set_available_languages(model(vec![SharedString::from("English")]));
window.set_search_query(state.frontend.search_query.clone().into());
@@ -47,6 +90,7 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
render_device_shell(window, state);
let (network_busy, network_text) = federation_status(state);
window.set_federation_status_busy(network_busy);
window.set_federation_status_running(state.backend.federation_debug.running);
window.set_federation_status_text(network_text.into());
render_federation_debug(window, state);
render_build_info(window, state);
@@ -62,6 +106,18 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
render_track_info(window, state);
}
fn compact_bytes(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = 1024 * KIB;
if bytes >= MIB {
format!("{}.{:01} MiB", bytes / MIB, bytes % MIB * 10 / MIB)
} else if bytes >= KIB {
format!("{}.{:01} KiB", bytes / KIB, bytes % KIB * 10 / KIB)
} else {
format!("{bytes} B")
}
}
fn render_federation_debug(window: &AppWindow, state: &AppState) {
let debug = &state.backend.federation_debug;
window.set_federation_debug_node(
@@ -184,13 +240,27 @@ fn render_device_shell(window: &AppWindow, state: &AppState) {
devices
.pending_pairings
.iter()
.map(|pending| PairingView {
request_id: pending.request_id.clone().into(),
name: pending.name.clone().into(),
details: format!("Furumi {} · {}", pending.client_version, pending.device_id)
.into(),
group_conflict: pending.requester_group_id.is_some()
&& pending.requester_group_active_devices > 1,
.map(|pending| {
let group_conflict = pending.requester_group_id.is_some()
&& pending.requester_group_active_devices > 1;
PairingView {
request_id: pending.request_id.clone().into(),
name: pending.name.clone().into(),
details: format!("Furumi {} · {}", pending.client_version, pending.device_id)
.into(),
group_conflict,
group_summary: pending
.requester_group_id
.as_ref()
.map_or_else(String::new, |group_id| {
format!(
"Their group · {} active devices · {}",
pending.requester_group_active_devices,
group_id.chars().take(24).collect::<String>()
)
})
.into(),
}
})
.collect(),
));
@@ -403,12 +473,18 @@ fn find_track<'a>(state: &'a AppState, key: &TrackKey) -> Option<&'a Track> {
.flat_map(|library| library.featured_releases.iter())
.flat_map(|release| release.tracks.iter()),
)
.chain(
ready_library(state)
.into_iter()
.flat_map(|library| library.recently_played.iter()),
)
.chain(
ready_library(state)
.into_iter()
.flat_map(|library| library.playlists.iter())
.flat_map(|playlist| playlist.tracks.iter()),
)
.chain(state.backend.similarity_search.results.iter())
.find(|track| track.key.matches(key))
}
@@ -462,11 +538,12 @@ pub(super) fn track_context(state: &AppState, context: &str) -> Vec<TrackKey> {
let tracks: Vec<Track> = match context {
"release" => selected_release(state).map_or_else(Vec::new, |release| release.tracks),
"search" => state.backend.search.results.tracks.clone(),
"recent" => ready_library(state)
"history" => ready_library(state)
.into_iter()
.flat_map(|library| library.recently_played.iter())
.cloned()
.collect(),
"similarity" => state.backend.similarity_search.results.clone(),
"artist-featured" => selected_artist(state).map_or_else(Vec::new, |artist| {
ready_library(state)
.into_iter()
@@ -492,17 +569,18 @@ pub(super) fn track_context(state: &AppState, context: &str) -> Vec<TrackKey> {
tracks.into_iter().map(|track| track.key).collect()
}
pub(super) fn breadcrumb_screen(state: &AppState, target: &SharedString) -> Option<Screen> {
pub(super) fn breadcrumb_screen(target: &SharedString) -> Option<Screen> {
match target.as_str() {
"home" => Some(Screen::Home),
"search" => Some(Screen::Search),
"library" => Some(Screen::Library),
"history" => Some(Screen::History),
value if value.starts_with("playlist:") => value
.trim_start_matches("playlist:")
.parse::<i64>()
.ok()
.map(Screen::Playlist),
_ => find_artist_key(state, target).map(Screen::Artist),
_ => parse_artist_key(target).map(Screen::Artist),
}
}
@@ -526,6 +604,14 @@ fn breadcrumbs(state: &AppState) -> Vec<BreadcrumbView> {
crumb("Home".into(), "home".into(), true),
crumb("Library".into(), String::new(), false),
],
Screen::History => vec![
crumb("Home".into(), "home".into(), true),
crumb("Listening history".into(), String::new(), false),
],
Screen::Similarity => vec![
crumb("Home".into(), "home".into(), true),
crumb("Similar tracks".into(), String::new(), false),
],
Screen::Artist(_) => vec![
crumb("Home".into(), "home".into(), true),
crumb(
@@ -588,7 +674,11 @@ pub(super) fn render_catalog(window: &AppWindow, state: &AppState) {
});
window.set_releases(model(releases));
let artists = library.map_or_else(Vec::new, |library| {
library.artists.iter().map(artist_to_view).collect()
library
.artists
.iter()
.map(|artist| artist_to_view(artist, &library.featured_releases))
.collect()
});
window.set_artists(model(artists));
@@ -660,10 +750,10 @@ pub(super) fn render_catalog(window: &AppWindow, state: &AppState) {
render_catalog_detail(window, state, selected_artist);
let recent = library.map_or_else(Vec::new, |library| {
let history = library.map_or_else(Vec::new, |library| {
tracks_to_views(&library.recently_played, state)
});
window.set_tracks(model(recent));
window.set_history_tracks(model(history));
let playlist = selected_playlist(state);
window.set_playlist_title(
playlist
@@ -741,6 +831,7 @@ pub(super) fn render_queue(window: &AppWindow, state: &AppState) {
let (artwork, has_artwork) = load_artwork(item.track.cover_uri.as_deref());
QueueView {
key: item.id.get().to_string().into(),
track_key: format_track_key(&item.track.key).into(),
title: item.track.title.clone().into(),
artist: item.track.artist.clone().into(),
artist_key: item
@@ -753,6 +844,7 @@ pub(super) fn render_queue(window: &AppWindow, state: &AppState) {
release: item.track.release.clone().into(),
release_key: format_release_key(&item.track.release_id).into(),
active: state.backend.queue.current_index() == Some(index),
liked: item.track.liked,
artwork,
has_artwork,
}
@@ -809,7 +901,12 @@ pub(super) fn render_current_track(window: &AppWindow, state: &AppState) {
pub(super) fn render_search(window: &AppWindow, state: &AppState) {
let search = &state.backend.search;
window.set_search_artists(model(
search.results.artists.iter().map(artist_to_view).collect(),
search
.results
.artists
.iter()
.map(|artist| artist_to_view(artist, &search.results.releases))
.collect(),
));
window.set_search_releases(model(
search
@@ -822,6 +919,14 @@ pub(super) fn render_search(window: &AppWindow, state: &AppState) {
window.set_search_results(model(tracks_to_views(&search.results.tracks, state)));
}
pub(super) fn render_similarity(window: &AppWindow, state: &AppState) {
let search = &state.backend.similarity_search;
window.set_similarity_source_title(search.source_title.clone().into());
window.set_similarity_pending(search.pending);
window.set_similarity_error(search.error.clone().unwrap_or_default().into());
window.set_similarity_tracks(model(tracks_to_views(&search.results, state)));
}
fn search_duration_label(milliseconds: u64) -> String {
if milliseconds < 1_000 {
format!("{milliseconds} ms")
@@ -1028,8 +1133,13 @@ pub(super) fn contributor_lines(artists: &[ArtistRef], width: f32) -> Vec<Artist
.collect()
}
fn artist_to_view(artist: &Artist) -> ArtistView {
let (artwork, has_artwork) = load_artwork(artist.artwork.uri.as_deref());
fn artist_to_view(artist: &Artist, releases: &[Release]) -> ArtistView {
let artwork_uri = artist
.artwork
.uri
.as_deref()
.or_else(|| fallback_artist_artwork(artist, releases));
let (artwork, has_artwork) = load_artwork(artwork_uri);
ArtistView {
key: format_artist_key(&artist.key).into(),
name: artist.name.clone().into(),
@@ -1044,6 +1154,30 @@ fn artist_to_view(artist: &Artist) -> ArtistView {
}
}
fn fallback_artist_artwork<'a>(artist: &Artist, releases: &'a [Release]) -> Option<&'a str> {
let belongs_to_artist = |release: &&Release| {
release
.artists
.iter()
.any(|candidate| candidate.key == artist.key)
&& release.artwork.uri.is_some()
};
let mut candidates = releases
.iter()
.filter(|release| release.is_album())
.filter(belongs_to_artist)
.collect::<Vec<_>>();
if candidates.is_empty() {
candidates.extend(releases.iter().filter(belongs_to_artist));
}
let hash = artist.name.bytes().fold(0_usize, |hash, byte| {
hash.wrapping_mul(31) ^ usize::from(byte)
});
candidates
.get(hash % candidates.len().max(1))
.and_then(|release| release.artwork.uri.as_deref())
}
fn release_to_view(release: &Release) -> ReleaseView {
let (artwork, has_artwork) = load_artwork(release.artwork.uri.as_deref());
ReleaseView {
@@ -1296,16 +1430,21 @@ fn format_release_key(key: &ReleaseKey) -> String {
}
}
pub(super) fn find_artist_key(state: &AppState, value: &SharedString) -> Option<ArtistKey> {
let local = match &state.backend.library {
RemoteData::Ready(library) => library.artists.as_slice(),
_ => &[],
};
local
.iter()
.chain(state.backend.search.results.artists.iter())
.find(|artist| format_artist_key(&artist.key) == value.as_str())
.map(|artist| artist.key.clone())
pub(super) fn parse_artist_key(value: &SharedString) -> Option<ArtistKey> {
if let Some(local) = value.strip_prefix("local-artist:") {
return local
.parse::<i64>()
.ok()
.map(|id| ArtistKey::local(furumi_domain::ArtistId::new(id)));
}
let (peer_id, id) = value.strip_prefix("fed-artist:")?.split_once(':')?;
if peer_id.is_empty() || id.is_empty() {
return None;
}
Some(ArtistKey::Federation {
peer_id: peer_id.into(),
id: id.into(),
})
}
pub(super) fn find_release_key(state: &AppState, value: &SharedString) -> Option<ReleaseKey> {
@@ -1380,6 +1519,51 @@ mod tests {
assert_eq!(decoded.size().height, 1);
}
#[test]
fn artist_link_key_is_resolved_without_a_top_level_artist_row() {
let expected = ArtistKey::Federation {
peer_id: "peer-a".into(),
id: "guest:artist".into(),
};
let encoded: SharedString = format_artist_key(&expected).into();
assert_eq!(parse_artist_key(&encoded), Some(expected));
}
#[test]
fn artist_without_an_image_uses_one_of_its_album_covers() {
let key = ArtistKey::local(furumi_domain::ArtistId::new(12));
let artist = Artist {
key: key.clone(),
source: CatalogSource::Local,
name: "Artist".into(),
artwork: furumi_domain::Artwork::default(),
release_count: 1,
track_count: 1,
};
let release = Release {
key: ReleaseKey::local(furumi_domain::ReleaseId::new(8)),
source: CatalogSource::Local,
title: "Album".into(),
artists: vec![ArtistRef {
key,
name: "Artist".into(),
}],
featured_artists: Vec::new(),
release_type: "album".into(),
year: None,
artwork: furumi_domain::Artwork {
uri: Some("/music/album-cover.jpg".into()),
},
tracks: Vec::new(),
};
assert_eq!(
fallback_artist_artwork(&artist, &[release]),
Some("/music/album-cover.jpg")
);
}
#[test]
fn release_credits_include_artists_found_only_on_tracks() {
let pasha = ArtistRef {
+365 -46
View File
@@ -1,6 +1,37 @@
import { Button, CheckBox, ComboBox, LineEdit, ScrollView, Slider } from "std-widgets.slint";
import { ArtistLinkLineView, ArtistLinkView, ArtistView, BreadcrumbView, DeviceView, PairingView, PlaylistView, QueueView, ReleaseView, TrackView, VersionView } from "models.slint";
import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, FederationBadge, IconButton, InfoField, LinkText, MarqueeText, NavButton, PlaylistNavButton, SearchArtistGrid, SearchField, SearchReleaseGrid, StaticArtistList, TrackInfoArtists, TrackList } from "components.slint";
import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, FederationBadge, IconButton, InfoField, LinkText, MarqueeText, NavButton, PlaylistNavButton, RecommendedButton, SearchArtistGrid, SearchField, SearchReleaseGrid, StaticArtistList, TrackActionButton, TrackInfoArtists, TrackList, TrackMenuItem } from "components.slint";
component FederationStateIndicator inherits Rectangle {
in property <bool> busy;
in property <bool> running;
width: 12px;
height: 100%;
horizontal-stretch: 0;
background: transparent;
if !root.busy: Rectangle {
width: 9px;
height: 9px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
background: root.running ? #55dbaa : #697080;
}
if root.busy: pulse := Rectangle {
private property <int> phase: floor(1 * mod(animation-tick(), 2s) / 250ms);
private property <int> level: self.phase < 4 ? self.phase : 7 - self.phase;
width: 12px - self.level * 2px;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: 2px;
border-color: #55dbaa;
background: transparent;
}
}
export component AppWindow inherits Window {
title: "Furumi Desktop";
@@ -17,7 +48,7 @@ export component AppWindow inherits Window {
in property <string> search-label: "Search";
in property <string> library-label: "Your library";
in property <string> queue-label: "Queue";
in property <string> recent-label: "Recently played";
in property <string> history-label: "Listening history";
in property <string> featured-label: "Made for listening";
in property <string> search-placeholder: "Artists, albums or tracks";
in-out property <string> search-query;
@@ -29,6 +60,19 @@ export component AppWindow inherits Window {
in property <string> library-path;
in property <bool> federation-enabled;
in property <bool> save-federated-on-listen;
in property <bool> similarity-enabled;
in property <string> similarity-model;
in property <[string]> similarity-models;
in property <string> similarity-profile;
in property <[string]> similarity-profiles;
in property <int> similarity-workers: 1;
in property <float> similarity-minimum-score: 0.7;
in property <int> similarity-max-tracks-per-artist: 5;
in property <bool> similarity-federation-consent;
in property <string> similarity-status-phase: "disabled";
in property <string> similarity-status-progress: "0 / 0";
in property <string> similarity-status-storage: "0 vectors";
in property <string> similarity-status-current;
in property <string> selected-language: "English";
in property <[string]> available-languages;
in property <[ReleaseView]> releases;
@@ -44,8 +88,15 @@ export component AppWindow inherits Window {
in property <string> detail-release-type;
in property <image> detail-artwork;
in property <bool> detail-has-artwork;
in property <[TrackView]> tracks;
in property <[TrackView]> history-tracks;
in property <[TrackView]> similarity-tracks;
in property <string> similarity-source-title;
in property <bool> similarity-pending;
in property <string> similarity-error;
in property <[QueueView]> queue-items;
private property <int> queue-drag-source: -1;
private property <int> queue-drag-target: -1;
private property <length> queue-drag-offset: 0px;
in property <[PlaylistView]> playlists;
in property <[TrackView]> playlist-tracks;
in property <string> playlist-title;
@@ -63,6 +114,7 @@ export component AppWindow inherits Window {
in property <[ArtistView]> search-artists;
in property <[ReleaseView]> search-releases;
in property <bool> federation-status-busy;
in property <bool> federation-status-running;
in property <string> federation-status-text: "Federation ready";
in property <string> federation-debug-node: "Stopped";
in property <string> federation-debug-peers: "0 connected · 0 known";
@@ -125,6 +177,8 @@ export component AppWindow inherits Window {
callback play-track(string);
callback play-track-context(string, string);
callback play-queue-item(string);
callback move-queue-item(string, int);
callback remove-queue-item(string);
callback track-action(string, string);
callback open-playlist(string);
callback create-playlist(string);
@@ -141,6 +195,14 @@ export component AppWindow inherits Window {
callback choose-library-path;
callback federation-changed(bool);
callback save-federated-on-listen-changed(bool);
callback similarity-enabled-changed(bool);
callback similarity-model-changed(string);
callback similarity-profile-changed(string);
callback similarity-workers-changed(int);
callback similarity-minimum-score-changed(float);
callback similarity-max-tracks-per-artist-changed(int);
callback similarity-federation-consent-changed(bool);
callback clear-similarity;
callback language-changed(string);
callback dismiss-error;
callback close-track-info;
@@ -171,6 +233,7 @@ export component AppWindow inherits Window {
NavButton { label: root.home-label; icon-source: @image-url("../assets/home.svg"); active: root.active-screen == "home"; clicked => root.navigate("home"); }
NavButton { label: root.search-label; icon-source: @image-url("../assets/search.svg"); active: root.active-screen == "search"; clicked => root.navigate("search"); }
NavButton { label: root.library-label; icon-source: @image-url("../assets/library.svg"); active: root.active-screen == "library"; clicked => root.navigate("library"); }
NavButton { label: root.history-label; icon-source: @image-url("../assets/history.svg"); active: root.active-screen == "history"; clicked => root.navigate("history"); }
Rectangle { height: 12px; background: transparent; }
HorizontalLayout {
height: 28px;
@@ -197,14 +260,6 @@ export component AppWindow inherits Window {
}
}
NavButton { label: "Settings"; icon-source: @image-url("../assets/settings.svg"); active: root.settings-open; clicked => root.toggle-settings(); }
Rectangle {
height: 58px; border-radius: 9px; background: #171a22;
HorizontalLayout {
padding: 10px; spacing: 10px;
Rectangle { width: 34px; height: 34px; border-radius: 17px; background: #353a49; Text { text: "F"; color: #fff; font-weight: 700; horizontal-alignment: center; vertical-alignment: center; } }
VerticalLayout { alignment: center; Text { text: "Local library"; color: #e9eaf0; font-size: 12px; font-weight: 600; } Text { text: "Desktop player"; color: #777d8d; font-size: 10px; } }
}
}
}
new-playlist-popup := PopupWindow {
@@ -269,7 +324,7 @@ export component AppWindow inherits Window {
border-width: 1px;
HorizontalLayout {
padding-left: 10px; padding-right: 10px; spacing: 6px;
Text { text: root.federation-status-busy ? "◌" : "●"; color: root.federation-status-busy ? #55dbaa : #697080; font-size: 10px; vertical-alignment: center; }
FederationStateIndicator { busy: root.federation-status-busy; running: root.federation-status-running; }
status-label := Text { text: root.federation-status-text; color: #9da3b2; font-size: 10px; overflow: elide; vertical-alignment: center; }
}
}
@@ -285,9 +340,18 @@ export component AppWindow inherits Window {
Text { text: "Good evening"; color: #f5f6fa; font-size: 28px; font-weight: 800; }
Text { text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; }
ArtistGrid { artists: root.artists; open(key) => root.open-artist(key); }
Rectangle { height: 8px; background: transparent; }
Text { text: root.recent-label; color: #f5f6fa; font-size: 18px; font-weight: 750; }
TrackList { tracks: root.tracks; play(key) => root.play-track-context("recent", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
if root.active-screen == "history": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 17px; alignment: start;
Text { height: 46px; vertical-stretch: 0; text: root.history-label; color: #f5f6fa; font-size: 30px; font-weight: 800; vertical-alignment: center; }
Text { height: 18px; vertical-stretch: 0; text: root.history-tracks.length + (root.history-tracks.length == 1 ? " listen" : " listens"); color: #858b9c; font-size: 12px; vertical-alignment: center; }
TrackList { tracks: root.history-tracks; show-artwork: true; play(key) => root.play-track-context("history", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
@@ -302,6 +366,20 @@ export component AppWindow inherits Window {
}
}
if root.active-screen == "similarity": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 17px; alignment: start;
Text { height: 46px; vertical-stretch: 0; text: "Similar to “" + root.similarity-source-title + "”"; color: #f5f6fa; font-size: 30px; font-weight: 800; overflow: elide; vertical-alignment: center; }
if root.similarity-pending: Text { height: 22px; text: "Searching local index and federation…"; color: #55dbaa; font-size: 12px; vertical-alignment: center; }
if root.similarity-error != "": Text { height: 40px; text: root.similarity-error; color: #ff8d98; font-size: 12px; wrap: word-wrap; }
if !root.similarity-pending && root.similarity-error == "" && root.similarity-tracks.length == 0: Text { height: 28px; text: "No matching tracks found."; color: #858b9c; font-size: 12px; vertical-alignment: center; }
TrackList { tracks: root.similarity-tracks; show-artwork: true; play(key) => root.play-track-context("similarity", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
if root.active-screen == "playlist": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
@@ -405,7 +483,7 @@ export component AppWindow inherits Window {
}
if root.queue-open: queue-panel := Rectangle {
width: 292px;
width: 370px;
background: #11141b;
border-color: #252936;
border-width: 1px;
@@ -425,24 +503,132 @@ export component AppWindow inherits Window {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
mouse-drag-pan-enabled: root.queue-drag-source < 0;
queue-list := Rectangle {
width: parent.width;
spacing: 4px;
alignment: start;
for item in root.queue-items: Rectangle {
height: 52px; border-radius: 7px; background: item.active ? #242a35 : transparent;
TouchArea { double-clicked => root.play-queue-item(item.key); }
HorizontalLayout {
padding: 7px; spacing: 10px;
Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } }
Rectangle {
width: 190px; height: parent.height; background: transparent; clip: true;
VerticalLayout {
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); }
height: root.queue-items.length > 0 ? root.queue-items.length * 56px - 4px : 0px;
background: transparent;
for item[index] in root.queue-items: queue-row := Rectangle {
private property <int> visual-index: root.queue-drag-source < 0 ? index
: index == root.queue-drag-source ? index
: root.queue-drag-source < root.queue-drag-target && index > root.queue-drag-source && index <= root.queue-drag-target ? index - 1
: root.queue-drag-target < root.queue-drag-source && index >= root.queue-drag-target && index < root.queue-drag-source ? index + 1
: index;
x: 0px;
y: self.visual-index * 56px;
width: parent.width;
height: 52px;
background: transparent;
animate y { duration: 110ms; easing: ease-out; }
queue-drag := TouchArea {
private property <length> start-y;
mouse-cursor: self.pressed ? grabbing : grab;
pointer-event(event) => {
if event.kind == PointerEventKind.cancel {
if root.queue-drag-source == index {
root.queue-drag-source = -1;
root.queue-drag-target = -1;
root.queue-drag-offset = 0px;
}
return;
}
if event.button != PointerEventButton.left {
return;
}
if event.kind == PointerEventKind.down {
self.start-y = self.mouse-y;
root.queue-drag-source = index;
root.queue-drag-target = index;
root.queue-drag-offset = 0px;
} else if event.kind == PointerEventKind.up {
if root.queue-drag-source == index && root.queue-drag-target != index {
root.move-queue-item(item.key, root.queue-drag-target);
}
root.queue-drag-source = -1;
root.queue-drag-target = -1;
root.queue-drag-offset = 0px;
}
}
moved => {
if self.pressed && root.queue-drag-source == index {
root.queue-drag-offset = self.mouse-y - self.start-y;
root.queue-drag-target = max(0, min(root.queue-items.length - 1, index + round(root.queue-drag-offset / 56px)));
}
}
double-clicked => root.play-queue-item(item.key);
}
row-visual := Rectangle {
width: parent.width;
height: parent.height;
opacity: root.queue-drag-source == index ? 0 : 1;
border-radius: 7px;
background: item.active ? #242a35 : queue-drag.has-hover ? #1b1f29 : transparent;
HorizontalLayout {
padding: 7px; spacing: 10px;
Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } }
Rectangle {
horizontal-stretch: 1; height: parent.height; background: transparent; clip: true;
VerticalLayout {
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); }
}
}
TrackActionButton { active: item.liked; icon-source: @image-url("../assets/heart.svg"); clicked => root.track-action("like", item.track-key); }
TrackActionButton { icon-source: @image-url("../assets/more.svg"); clicked => queue-menu.show(); }
IconButton { width: 28px; height: 28px; icon-source: @image-url("../assets/close.svg"); clicked => root.remove-queue-item(item.key); }
}
}
queue-menu := PopupWindow {
x: queue-row.width - self.width - 8px;
y: 46px;
width: 184px;
height: 118px;
close-policy: close-on-click-outside;
Rectangle {
border-radius: 8px;
background: #222631;
border-color: #3a3f4d;
border-width: 1px;
drop-shadow-color: #00000080;
drop-shadow-blur: 14px;
drop-shadow-offset-y: 4px;
VerticalLayout {
padding: 6px; spacing: 1px;
TrackMenuItem { icon-source: @image-url("../assets/info.svg"); label: "Track information"; clicked => { queue-menu.close(); root.track-action("information", item.track-key); } }
TrackMenuItem { icon-source: @image-url("../assets/search.svg"); label: "Find similar"; clicked => { queue-menu.close(); root.track-action("similar", item.track-key); } }
TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { queue-menu.close(); root.track-action("playlist", item.track-key); } }
}
}
}
}
if root.queue-drag-source >= 0: drag-preview := Rectangle {
private property <QueueView> item: root.queue-items[root.queue-drag-source];
x: 0px;
y: max(0px, min(parent.height - self.height, root.queue-drag-source * 56px + root.queue-drag-offset));
width: parent.width;
height: 52px;
border-radius: 7px;
background: #29362f;
border-width: 1px;
border-color: #55dbaa;
drop-shadow-color: #000000a0;
drop-shadow-blur: 16px;
drop-shadow-offset-y: 5px;
HorizontalLayout {
padding: 7px; spacing: 10px;
Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if drag-preview.item.has-artwork: Image { source: drag-preview.item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } }
Rectangle {
horizontal-stretch: 1; height: parent.height; background: transparent; clip: true;
VerticalLayout {
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
MarqueeText { width: parent.width; height: 18px; label: drag-preview.item.title; text-color: drag-preview.item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
Text { width: parent.width; height: 15px; text: drag-preview.item.artist; color: #777d8d; font-size: 10px; overflow: elide; vertical-alignment: center; }
}
}
Rectangle { width: 28px; height: 28px; border-radius: 6px; background: drag-preview.item.liked ? #255845 : transparent; Image { source: @image-url("../assets/heart.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } }
Rectangle { width: 28px; height: 28px; Image { source: @image-url("../assets/more.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } }
Rectangle { width: 28px; height: 28px; Image { source: @image-url("../assets/close.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } }
}
}
}
@@ -565,6 +751,28 @@ export component AppWindow inherits Window {
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 4px;
for pairing in root.pending-pairings: Rectangle {
height: pairing.group-conflict ? 142px : 76px; border-radius: 7px; background: #252a35;
VerticalLayout {
padding: 8px; spacing: 5px;
Text { text: pairing.name + " wants to connect"; color: #f0f1f5; font-size: 11px; font-weight: 650; overflow: elide; }
Text { text: pairing.details; color: #7f8797; font-size: 9px; overflow: elide; }
if pairing.group-conflict: Text { text: pairing.group-summary; color: #d9a9d0; font-size: 9px; overflow: elide; }
if pairing.group-conflict: Text { text: "Join theirs to keep its current peers syncing."; color: #9ca4b4; font-size: 9px; }
if pairing.group-conflict: Text { text: "Keep this group moves only this device; its old peers stop syncing."; color: #9ca4b4; font-size: 9px; }
if pairing.group-conflict: HorizontalLayout {
spacing: 7px;
RecommendedButton { text: "Join their group"; clicked => root.answer-pairing(pairing.request-id, true, true); }
Button { text: "Keep this group"; clicked => root.answer-pairing(pairing.request-id, true, false); }
Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); }
}
if !pairing.group-conflict: HorizontalLayout {
spacing: 7px;
Button { text: "Accept"; clicked => root.answer-pairing(pairing.request-id, true, false); }
Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); }
}
}
}
for device in root.connected-devices: DeviceRow {
visible: !device.active;
height: !device.active ? 48px : 0px;
@@ -572,23 +780,31 @@ export component AppWindow inherits Window {
selectable: !device.revoked && (device.online || device.is-self);
selected(id) => root.select-device(id);
}
for pairing in root.pending-pairings: Rectangle {
height: 76px; border-radius: 7px; background: #252a35;
VerticalLayout {
padding: 8px; spacing: 5px;
Text { text: pairing.name + " wants to connect"; color: #f0f1f5; font-size: 11px; font-weight: 650; overflow: elide; }
Text { text: pairing.details; color: #7f8797; font-size: 9px; overflow: elide; }
HorizontalLayout {
spacing: 7px;
Button { text: "Accept"; clicked => root.answer-pairing(pairing.request-id, true, pairing.group-conflict); }
Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); }
}
}
}
}
}
Rectangle { height: 1px; background: #303541; }
if root.device-invite != "": Text { text: root.device-invite; color: #9ca4b4; font-size: 9px; wrap: word-wrap; max-height: 36px; overflow: elide; }
if root.device-invite != "": HorizontalLayout {
height: 32px; spacing: 8px;
invite-code-frame := Rectangle {
horizontal-stretch: 1;
min-width: 0px;
height: 32px;
clip: true;
invite-code := LineEdit {
width: parent.width;
height: parent.height;
text: root.device-invite;
read-only: true;
}
}
Button {
text: "Copy";
clicked => {
invite-code.select-all();
invite-code.copy();
}
}
}
HorizontalLayout {
spacing: 8px;
device-link := LineEdit { horizontal-stretch: 1; placeholder-text: "Paste frid://i/… invite"; }
@@ -705,7 +921,7 @@ export component AppWindow inherits Window {
spacing: 8px;
LineEdit {
text: root.library-path;
placeholder-text: "~/Music/Furumi";
placeholder-text: "Choose a music directory";
horizontal-stretch: 1;
edited => root.library-path-changed(self.text);
}
@@ -747,6 +963,109 @@ export component AppWindow inherits Window {
}
}
}
Rectangle { height: 1px; background: #2a2e38; }
Text { height: 22px; text: "Similarity search"; color: #f0f2f7; font-size: 15px; font-weight: 750; vertical-alignment: center; }
HorizontalLayout {
height: 52px;
VerticalLayout {
Text { text: "Find similar tracks"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Build a private local audio index. Downloads the selected model when enabled."; color: #818797; font-size: 11px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
CheckBox {
text: "Enabled";
checked: root.similarity-enabled;
toggled => root.similarity-enabled-changed(self.checked);
}
}
HorizontalLayout {
height: 44px; spacing: 12px;
VerticalLayout {
horizontal-stretch: 1;
Text { text: "Embedding model"; color: #e8eaf0; font-size: 12px; font-weight: 650; }
Text { text: "CC BY-NC-SA 4.0 (or proprietary from MTG)"; color: #818797; font-size: 10px; }
}
ComboBox {
width: 244px;
model: root.similarity-models;
current-value: root.similarity-model;
selected(value) => root.similarity-model-changed(value);
}
}
HorizontalLayout {
height: 44px; spacing: 12px;
VerticalLayout {
horizontal-stretch: 1;
Text { text: "Preprocessing profile"; color: #e8eaf0; font-size: 12px; font-weight: 650; }
Text { text: "Full track; long tracks use balanced windows."; color: #818797; font-size: 10px; }
}
ComboBox {
width: 244px;
model: root.similarity-profiles;
current-value: root.similarity-profile;
selected(value) => root.similarity-profile-changed(value);
}
}
VerticalLayout {
spacing: 6px;
HorizontalLayout {
height: 20px;
Text { horizontal-stretch: 1; text: "Minimum similarity"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; }
Text { text: round(root.similarity-minimum-score * 100) + "%"; color: #aeb4c2; font-size: 11px; vertical-alignment: center; }
}
Slider { height: 18px; minimum: 0; maximum: 1; value: root.similarity-minimum-score; changed(value) => root.similarity-minimum-score-changed(value); }
}
VerticalLayout {
spacing: 6px;
HorizontalLayout {
height: 20px;
Text { horizontal-stretch: 1; text: "Maximum tracks per artist"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; }
Text { text: root.similarity-max-tracks-per-artist; color: #aeb4c2; font-size: 11px; vertical-alignment: center; }
}
Slider { height: 18px; minimum: 1; maximum: 50; value: root.similarity-max-tracks-per-artist; changed(value) => root.similarity-max-tracks-per-artist-changed(round(value)); }
}
VerticalLayout {
spacing: 6px;
HorizontalLayout {
height: 20px;
Text { horizontal-stretch: 1; text: "Background workers"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; }
Text { text: root.similarity-workers; color: #aeb4c2; font-size: 11px; vertical-alignment: center; }
}
Slider { height: 18px; minimum: 1; maximum: 16; value: root.similarity-workers; changed(value) => root.similarity-workers-changed(round(value)); }
}
HorizontalLayout {
height: 62px;
VerticalLayout {
Text { text: "Search federation too"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Send an anonymous numeric embedding to peers. No account identity is included."; color: #818797; font-size: 10px; }
Text { text: "A peer may infer the kind of music being searched."; color: #d4ad62; font-size: 10px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
CheckBox {
text: "I agree";
checked: root.similarity-federation-consent;
toggled => root.similarity-federation-consent-changed(self.checked);
}
}
Rectangle {
min-height: 112px; max-height: 112px; vertical-stretch: 0;
border-radius: 9px; background: #11151d; border-width: 1px; border-color: #2c3240;
VerticalLayout {
padding: 11px; spacing: 5px;
HorizontalLayout {
height: 20px;
Text { horizontal-stretch: 1; text: "Similarity index"; color: #dfe2ea; font-size: 11px; font-weight: 700; vertical-alignment: center; }
Text { text: root.similarity-status-phase; color: root.similarity-enabled ? #55dbaa : #777d8d; font-size: 10px; vertical-alignment: center; }
}
Text { height: 17px; text: "Progress " + root.similarity-status-progress + " Stored " + root.similarity-status-storage; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; }
Text { height: 17px; text: root.similarity-status-current; color: #697080; font-size: 10px; overflow: elide; vertical-alignment: center; }
HorizontalLayout {
height: 28px;
Rectangle { horizontal-stretch: 1; background: transparent; }
Button { text: "Clear stored embeddings"; clicked => root.clear-similarity(); }
}
}
}
HorizontalLayout {
height: 52px;
VerticalLayout {
+52 -4
View File
@@ -15,6 +15,49 @@ export component IconButton inherits Rectangle {
touch := TouchArea { enabled: root.enabled; clicked => root.clicked(); }
}
export component RecommendedButton inherits Rectangle {
in property <string> text;
in property <bool> enabled: true;
callback clicked;
min-width: label.preferred-width + 24px;
min-height: 32px;
horizontal-stretch: 0;
vertical-stretch: 0;
border-radius: 4px;
border-width: 1px;
border-color: root.enabled ? #65d7a9 : #49645a;
background: !root.enabled ? #31443d : touch.pressed ? #267657 : touch.has-hover ? #3aa77d : #318e6b;
opacity: root.enabled ? 1 : 0.55;
accessible-role: button;
accessible-enabled: root.enabled;
accessible-label: root.text;
accessible-action-default => { root.clicked(); }
forward-focus: focus;
label := Text {
text: root.text;
color: #ffffff;
font-size: 11px;
font-weight: 700;
horizontal-alignment: center;
vertical-alignment: center;
}
touch := TouchArea {
enabled: root.enabled;
clicked => root.clicked();
}
focus := FocusScope {
enabled: root.enabled;
key-pressed(event) => {
if event.text == " " || event.text == "\n" {
root.clicked();
return accept;
}
return reject;
}
}
}
export component DeviceRow inherits Rectangle {
in property <DeviceView> item;
in property <bool> selectable: false;
@@ -146,9 +189,14 @@ export component PlaylistNavButton inherits Rectangle {
background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent;
HorizontalLayout {
padding-left: 10px; padding-right: 8px; spacing: 9px;
Image {
source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg");
width: 15px; height: 15px;
Rectangle {
width: 15px;
background: transparent;
Image {
source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg");
width: 15px; height: 15px;
y: (parent.height - self.height) / 2;
}
}
Text { horizontal-stretch: 1; text: root.item.title; color: root.active ? #f5f6fa : #a4a9b7; font-size: 12px; overflow: elide; vertical-alignment: center; }
}
@@ -629,7 +677,7 @@ export component TrackRow inherits Rectangle {
VerticalLayout {
padding: 6px; spacing: 1px;
TrackMenuItem { icon-source: @image-url("../assets/info.svg"); label: "Track information"; clicked => { menu.close(); root.action("information", root.item.key); } }
TrackMenuItem { icon-source: @image-url("../assets/share.svg"); label: "Share"; clicked => { menu.close(); root.action("share", root.item.key); } }
TrackMenuItem { icon-source: @image-url("../assets/search.svg"); label: "Find similar"; clicked => { menu.close(); root.action("similar", root.item.key); } }
TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { menu.close(); root.action("playlist", root.item.key); } }
}
}
+3
View File
@@ -55,12 +55,14 @@ export struct TrackView {
export struct QueueView {
key: string,
track-key: string,
title: string,
artist: string,
artist-key: string,
release: string,
release-key: string,
active: bool,
liked: bool,
artwork: image,
has-artwork: bool,
}
@@ -87,4 +89,5 @@ export struct PairingView {
name: string,
details: string,
group-conflict: bool,
group-summary: string,
}