3 Commits
Author SHA1 Message Date
Ultradesu cced546cd5 Fixed invite logic to be lightweight 2026-07-29 14:24:22 +01:00
Ultradesu 52c2f7f7fe Fixed invite logic to be lightweight 2026-07-29 14:21:01 +01:00
Ultradesu 9b9f5d9ce5 Global is default mode 2026-07-29 10:52:57 +01:00
7 changed files with 96 additions and 27 deletions
Generated
+1 -1
View File
@@ -1565,7 +1565,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi_tui" name = "furumi_tui"
version = "0.2.3" version = "0.2.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"blake3", "blake3",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumi_tui" name = "furumi_tui"
version = "0.2.3" version = "0.2.4"
edition = "2024" edition = "2024"
rust-version = "1.97" rust-version = "1.97"
description = "A federated P2P player for personal music libraries" description = "A federated P2P player for personal music libraries"
+1 -1
View File
@@ -277,7 +277,7 @@ pub async fn run(
library_network_refreshing: Arc::new(std::sync::atomic::AtomicBool::new(false)), library_network_refreshing: Arc::new(std::sync::atomic::AtomicBool::new(false)),
library_network_cursors: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), library_network_cursors: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
library_network_done: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), library_network_done: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
library_network_mode: crate::config::settings::LibrarySourceMode::Local, library_network_mode: state.global.filters.source_mode,
library_network_art_fetching: Arc::new(std::sync::atomic::AtomicBool::new(false)), library_network_art_fetching: Arc::new(std::sync::atomic::AtomicBool::new(false)),
library_network_art_attempted: Arc::new(std::sync::Mutex::new( library_network_art_attempted: Arc::new(std::sync::Mutex::new(
std::collections::HashSet::new(), std::collections::HashSet::new(),
+4 -4
View File
@@ -158,25 +158,25 @@ fn source_mode_cycles_on_library_playlists_and_queue_tabs() {
update(&mut state, Action::CycleSourceMode), update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged) Some(Effect::SourceModeChanged)
); );
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::My); assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
state.active_tab = Tab::Playlists; state.active_tab = Tab::Playlists;
assert_eq!( assert_eq!(
update(&mut state, Action::CycleSourceMode), update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged) Some(Effect::SourceModeChanged)
); );
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global); assert_eq!(state.global.filters.source_mode, LibrarySourceMode::My);
state.active_tab = Tab::Queue; state.active_tab = Tab::Queue;
assert_eq!( assert_eq!(
update(&mut state, Action::CycleSourceMode), update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged) Some(Effect::SourceModeChanged)
); );
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local); assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global);
state.active_tab = Tab::Federation; state.active_tab = Tab::Federation;
assert_eq!(update(&mut state, Action::CycleSourceMode), None); assert_eq!(update(&mut state, Action::CycleSourceMode), None);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local); assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global);
} }
#[test] #[test]
+2 -2
View File
@@ -4,9 +4,9 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum LibrarySourceMode { pub enum LibrarySourceMode {
#[default]
Local, Local,
My, My,
#[default]
Global, Global,
} }
@@ -128,6 +128,6 @@ hide_featured_only = true
assert_eq!(settings.volume, 100); assert_eq!(settings.volume, 100);
assert!(settings.library.hide_featured_only); assert!(settings.library.hide_featured_only);
assert_eq!(settings.library.source_mode, LibrarySourceMode::Local); assert_eq!(settings.library.source_mode, LibrarySourceMode::Global);
} }
} }
+35 -18
View File
@@ -31,6 +31,9 @@ const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2); const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
const SYNC_INTERVAL: Duration = Duration::from_secs(2); const SYNC_INTERVAL: Duration = Duration::from_secs(2);
const MAX_LINE: usize = 8 * 1024 * 1024; const MAX_LINE: usize = 8 * 1024 * 1024;
/// Playback control is ephemeral. Keeping old full-queue commands in a new
/// peer's catch-up batch can make the initial pairing frame arbitrarily large.
const PLAYBACK_COMMAND_TTL_MS: i64 = 5 * 60 * 1_000;
const MAX_OPS_PER_BATCH: usize = 1000; const MAX_OPS_PER_BATCH: usize = 1000;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -2173,25 +2176,39 @@ impl DeviceSync {
LEFT JOIN sync_peer_acks a LEFT JOIN sync_peer_acks a
ON a.peer_device_id = ?1 AND a.origin_device_id = o.origin_device_id ON a.peer_device_id = ?1 AND a.origin_device_id = o.origin_device_id
WHERE o.seq > COALESCE(a.max_seq, 0) WHERE o.seq > COALESCE(a.max_seq, 0)
ORDER BY o.hlc_ms, o.op_id AND (
LIMIT ?2", o.kind != 'playback_command'
)?; OR (
let rows = stmt.query_map(params![peer_device_id, MAX_OPS_PER_BATCH as i64], |row| { o.hlc_ms >= ?2
let payload_json: String = row.get(4)?; AND json_extract(o.payload_json, '$.target_device_id') = ?1
Ok(SyncOpWire {
op_id: row.get(0)?,
origin_device_id: row.get(1)?,
seq: row.get(2)?,
hlc_ms: row.get(3)?,
payload: serde_json::from_str(&payload_json).map_err(|err| {
rusqlite::Error::FromSqlConversionFailure(
4,
rusqlite::types::Type::Text,
Box::new(err),
) )
})?, )
}) ORDER BY o.hlc_ms, o.op_id
})?; LIMIT ?3",
)?;
let rows = stmt.query_map(
params![
peer_device_id,
now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS),
MAX_OPS_PER_BATCH as i64
],
|row| {
let payload_json: String = row.get(4)?;
Ok(SyncOpWire {
op_id: row.get(0)?,
origin_device_id: row.get(1)?,
seq: row.get(2)?,
hlc_ms: row.get(3)?,
payload: serde_json::from_str(&payload_json).map_err(|err| {
rusqlite::Error::FromSqlConversionFailure(
4,
rusqlite::types::Type::Text,
Box::new(err),
)
})?,
})
},
)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
} }
+52
View File
@@ -228,6 +228,58 @@ fn playback_command_is_targeted_and_deduplicated() {
assert!(rx.try_recv().is_err()); assert!(rx.try_recv().is_err());
} }
#[test]
fn playback_commands_are_caught_up_only_by_their_target_while_fresh() {
let sync = test_sync();
let command = PlaybackCommand::SetState {
state: PlaybackStateWire {
queue: Vec::new(),
queue_pos: 0,
playing: false,
paused: false,
idle_since_ms: None,
position_secs: 0.0,
volume: 42,
shuffle: false,
repeat: PlaybackRepeat::Off,
},
seek: false,
};
sync.record_playback_command("dev_target", command.clone())
.unwrap();
sync.record_playback_command("dev_other", command).unwrap();
let target_ops = sync.ops_for_peer("dev_target").unwrap();
assert_eq!(
target_ops
.iter()
.filter(|op| matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
.count(),
1
);
assert!(
sync.ops_for_peer("dev_unknown")
.unwrap()
.iter()
.all(|op| !matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
);
lock(&sync.conn)
.execute(
"UPDATE sync_ops
SET hlc_ms = ?1
WHERE kind = 'playback_command'",
[now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS + 1)],
)
.unwrap();
assert!(
sync.ops_for_peer("dev_target")
.unwrap()
.iter()
.all(|op| !matches!(op.payload, SyncOpPayload::PlaybackCommand { .. }))
);
}
#[test] #[test]
fn newer_device_trust_reactivates_revoked_device() { fn newer_device_trust_reactivates_revoked_device() {
let sync = test_sync(); let sync = test_sync();