Integrate shared playback coordination and release 0.3.1

This commit is contained in:
ab
2026-09-10 17:07:41 +03:00
parent 5a193d839f
commit 532d6ee1e9
11 changed files with 473 additions and 204 deletions
+169 -39
View File
@@ -5,6 +5,7 @@
//! materialized tables, so offline clients can merge likes, playlists and
//! membership changes deterministically.
use music_dht::playback::{Checkpoint, CommandStamp, Config as PlaybackConfig, Engine};
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::str::FromStr;
@@ -24,7 +25,7 @@ use crate::library::models::{ArtistRef, TrackItem};
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const PROTOCOL_VERSION: u16 = 2;
pub const PROTOCOL_VERSION: u16 = 3;
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
@@ -245,32 +246,13 @@ pub struct PlaybackStateWire {
pub repeat: PlaybackRepeat,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlaybackSnapshot {
pub device_id: String,
pub device_name: String,
pub active: bool,
pub updated_at_ms: i64,
pub state: PlaybackStateWire,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PlaybackCommand {
SetState {
state: PlaybackStateWire,
#[serde(default)]
seek: bool,
},
ActiveChanged {
active_device_id: String,
active_device_name: String,
state: PlaybackStateWire,
},
}
pub type PlaybackSnapshot = music_dht::playback::Snapshot<PlaybackStateWire>;
pub type PlaybackCommand = music_dht::playback::Command<PlaybackStateWire>;
#[derive(Debug, Clone, Default)]
struct PlaybackShared {
engine: Option<Engine>,
engine_group: String,
local: Option<PlaybackSnapshot>,
remote: BTreeMap<String, PlaybackSnapshot>,
}
@@ -369,6 +351,8 @@ pub enum SyncOpPayload {
PlaybackCommand {
target_device_id: String,
command: PlaybackCommand,
#[serde(default)]
authority: Option<CommandStamp>,
},
ListenRecorded {
event: ListenEvent,
@@ -648,10 +632,92 @@ impl DeviceSync {
format!("{}-{}", now_ms(), random_hex(12))
}
pub fn configure_playback(&self, config: PlaybackConfig) -> Result<()> {
set_meta(
&lock(&self.conn),
"playback_config_v1",
&serde_json::to_string(&config)?,
)?;
lock(&self.playback).engine = None;
Ok(())
}
fn with_playback_engine<R>(&self, f: impl FnOnce(&mut Engine) -> R) -> Result<R> {
let identity = self.ensure_identity()?;
let mut shared = lock(&self.playback);
if shared.engine.is_none() || shared.engine_group != identity.group_id {
shared.engine_group = identity.group_id.clone();
let conn = lock(&self.conn);
let durable = get_meta(&conn, "playback_coordination_v1")?
.map(|json| serde_json::from_str::<Checkpoint>(&json))
.transpose()?
.filter(|checkpoint| checkpoint.scope == identity.group_id)
.map(|checkpoint| checkpoint.state)
.unwrap_or_default();
let config = get_meta(&conn, "playback_config_v1")?
.map(|json| serde_json::from_str::<PlaybackConfig>(&json))
.transpose()?
.unwrap_or_default();
shared.engine = Some(Engine::new(
identity.device_id,
config,
durable,
playback_clock(),
));
}
let engine = shared.engine.as_mut().expect("initialized playback engine");
let previous = engine.clone();
let result = f(engine);
if previous.durable() != engine.durable() {
let saved = set_meta(
&lock(&self.conn),
"playback_coordination_v1",
&serde_json::to_string(&Checkpoint {
scope: identity.group_id,
state: engine.durable().clone(),
})?,
);
if let Err(error) = saved {
*engine = previous;
return Err(error);
}
}
Ok(result)
}
/// Evaluate shared ownership independently of the UI's online-device list.
pub fn playback_tick(&self, available: bool, playing: bool) -> Result<Option<String>> {
self.with_playback_engine(|engine| {
engine.set_output(available, playing);
engine.heartbeat(playback_clock());
engine.tick(playback_clock());
engine.owner().map(str::to_string)
})
}
pub fn playback_command_is_current(&self, origin: &str, stamp: &CommandStamp) -> bool {
self.with_playback_engine(|engine| engine.command_is_current(origin, stamp))
.unwrap_or(false)
}
pub fn claim_playback(&self, target: &str) -> Result<()> {
self.with_playback_engine(|engine| engine.transfer(target, playback_clock()))?
.then_some(())
.context("cannot allocate playback ownership term")
}
pub fn publish_playback(&self, mut snapshot: PlaybackSnapshot) {
if snapshot.updated_at_ms <= 0 {
snapshot.updated_at_ms = now_ms();
}
let Ok(coordination) = self.with_playback_engine(|engine| engine.announcement()) else {
return;
};
snapshot.active = coordination
.claim
.as_ref()
.is_some_and(|claim| claim.owner == snapshot.device_id);
snapshot.coordination = Some(coordination);
lock(&self.playback).local = Some(snapshot);
}
@@ -663,9 +729,22 @@ impl DeviceSync {
if target_device_id.trim().is_empty() {
return Ok(());
}
let authority = self
.with_playback_engine(|engine| {
if let PlaybackCommand::ActiveChanged {
active_device_id, ..
} = &command
&& engine.owner() != Some(active_device_id.as_str())
{
engine.transfer(active_device_id, playback_clock());
}
engine.stamp()
})?
.context("no playback owner; wait for discovery or select an output")?;
self.record_local_op(SyncOpPayload::PlaybackCommand {
target_device_id: target_device_id.to_string(),
command,
authority: Some(authority),
})
}
@@ -845,7 +924,7 @@ impl DeviceSync {
if let Some(profile) = profile {
self.apply_device_profile(&profile, true)?;
if let Some(playback) = playback {
self.apply_playback_snapshot(playback)?;
self.apply_playback_snapshot(&profile.device_id, playback)?;
}
}
self.apply_device_profiles(&devices)?;
@@ -1305,7 +1384,7 @@ impl DeviceSync {
} => {
self.apply_device_profiles(&devices)?;
if let Some(playback) = playback {
self.apply_playback_snapshot(playback)?;
self.apply_playback_snapshot(&device.device_id, playback)?;
}
self.apply_snapshot(snapshot)?;
self.apply_ops(ops)?;
@@ -1636,8 +1715,15 @@ impl DeviceSync {
SyncOpPayload::PlaybackCommand {
target_device_id,
command,
authority,
} => {
self.apply_playback_command(target_device_id, command, &op.op_id)?;
self.apply_playback_command(
target_device_id,
command,
authority.as_ref(),
&op.origin_device_id,
&op.op_id,
)?;
false
}
SyncOpPayload::ListenRecorded { event } => self
@@ -1651,10 +1737,32 @@ impl DeviceSync {
&self,
target_device_id: &str,
command: &PlaybackCommand,
authority: Option<&CommandStamp>,
origin: &str,
op_id: &str,
) -> Result<()> {
let identity = self.ensure_identity()?;
if target_device_id != identity.device_id {
// Legacy commands still replicate with the library log, but cannot
// override the versioned personal-playback protocol.
let Some(authority) = authority else {
return Ok(());
};
let handoff = matches!(command, PlaybackCommand::ActiveChanged { .. });
if !handoff && target_device_id != identity.device_id {
return Ok(());
}
if let PlaybackCommand::ActiveChanged {
active_device_id, ..
} = command
&& active_device_id != &authority.claim.owner
{
return Ok(());
}
let accepted = self.with_playback_engine(|engine| {
engine.accept_command(origin, authority, handoff, playback_clock())
&& (handoff || engine.is_owner())
})?;
if !accepted || target_device_id != identity.device_id {
return Ok(());
}
let inserted = {
@@ -1669,7 +1777,11 @@ impl DeviceSync {
return Ok(());
}
if let Some(tx) = lock(&self.event_tx).as_ref() {
let _ = tx.send(AppEvent::PlaybackCommand(command.clone()));
let _ = tx.send(AppEvent::PlaybackCommand {
command: command.clone(),
authority: authority.clone(),
origin: origin.to_string(),
});
}
Ok(())
}
@@ -2595,19 +2707,29 @@ impl DeviceSync {
lock(&self.playback).local.clone()
}
fn apply_playback_snapshot(&self, snapshot: PlaybackSnapshot) -> Result<()> {
fn apply_playback_snapshot(&self, sender: &str, snapshot: PlaybackSnapshot) -> Result<()> {
let identity = self.ensure_identity()?;
if snapshot.device_id == identity.device_id {
if snapshot.device_id != sender || snapshot.device_id == identity.device_id {
return Ok(());
}
let Some(coordination) = &snapshot.coordination else {
return Ok(());
};
if !self.with_playback_engine(|engine| {
engine.observe(&snapshot.device_id, coordination, playback_clock())
})? {
return Ok(());
}
let should_send = {
let mut playback = lock(&self.playback);
let changed = playback
.remote
.get(&snapshot.device_id)
.is_none_or(|current| snapshot.updated_at_ms > current.updated_at_ms);
let mut shared = lock(&self.playback);
let changed = shared.remote.get(&snapshot.device_id).is_none_or(|old| {
old.coordination.as_ref().is_none_or(|c| {
coordination.claim > c.claim
|| (coordination.claim == c.claim && coordination.heartbeat > c.heartbeat)
})
});
if changed {
playback
shared
.remote
.insert(snapshot.device_id.clone(), snapshot.clone());
}
@@ -2936,7 +3058,7 @@ async fn handle_pair_request(
}
sync.apply_device_profile(&profile, true)?;
if let Some(playback) = playback {
sync.apply_playback_snapshot(playback)?;
sync.apply_playback_snapshot(&profile.device_id, playback)?;
}
sync.apply_snapshot(snapshot)?;
sync.apply_ops(ops)?;
@@ -3026,7 +3148,7 @@ async fn handle_hello(
sync.apply_device_profile(&profile, false)?;
sync.apply_device_profiles(&devices)?;
if let Some(playback) = playback {
sync.apply_playback_snapshot(playback)?;
sync.apply_playback_snapshot(&profile.device_id, playback)?;
}
sync.apply_snapshot(snapshot)?;
sync.apply_ops(ops)?;
@@ -3635,3 +3757,11 @@ fn base64url_decode(value: &str) -> Result<Vec<u8>> {
#[cfg(test)]
#[path = "devices/tests.rs"]
mod tests;
fn playback_clock() -> u64 {
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
START
.get_or_init(std::time::Instant::now)
.elapsed()
.as_millis() as u64
}