Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cced546cd5 | ||
|
|
52c2f7f7fe | ||
|
|
9b9f5d9ce5 |
Generated
+1
-1
@@ -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
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user