feat(music): W22 shared listening + release 0.6.0

Personal playlist on a dedicated music player (browse/play/prev/next/
seek/volume/reorder/remove, .pls/.m3u import), plus per-person shared
listening: broadcast your track over presence, peers tune in and stream
it point-to-point over the files plane. Playback is timeline-synced
(play/pause/skip/seek mirror with no drift) with gapless prefetch of the
next track and independent per-source volume per listener.

In the 3-column layout the playlist gets its own card stacked under the
chat, with a resizable divider and its own scrollbar; other layouts keep
it in the Controls panel.

Breaking wire change: gossip protocol v5 (presence gains music fields),
so 0.6.0 peers cannot share a swarm with 0.5.x. Version bumped 0.5.1 ->
0.6.0; CHANGELOG updated.

Untrusted-input handling: broadcast track name sanitized and size
cap-checked at gossip ingest, fetched bytes confirmed audio before
decode, only the descriptor rides gossip (bytes go point-to-point, one
fetch in flight).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 06:04:31 -04:00
co-authored by Claude Opus 4.8
parent c2e27c3367
commit bca2ccd6a4
15 changed files with 1440 additions and 28 deletions
+24
View File
@@ -645,6 +645,29 @@ impl RoomState for IrohGossipState {
let cleaned = crate::sanitize::sanitize_game_label(&g);
(!cleaned.is_empty()).then_some(cleaned)
});
// Music presence is untrusted peer data:
// the track name is display text (sanitize
// + cap like the game label) and the size
// bounds a future fetch (reject anything
// outside the attachment cap).
state.music = state.music.and_then(|mut m| {
let name = crate::sanitize::sanitize_game_label(&m.name);
if name.is_empty() || !crate::files::size_within_cap(m.size) {
return None;
}
m.name = name;
if m.next_id.is_some() {
let ok = m
.next_size
.map(crate::files::size_within_cap)
.unwrap_or(false);
if !ok {
m.next_id = None;
m.next_size = None;
}
}
Some(m)
});
// Bound an insider's advertised address set
// before we retain it / hand it to the dialer
// (Tier C F-01).
@@ -959,6 +982,7 @@ mod tests {
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
music: None,
}
}
+23 -10
View File
@@ -594,17 +594,21 @@ impl IrohTransport {
self.shared.served_files.lock().unwrap().insert(id, bytes);
}
/// Fetch a chat attachment's bytes from its sender over the file plane. Dials
/// the sender on `FILES_ALPN` (preferring a known full address), writes the
/// 32-byte id, and reads the response bounded by the descriptor's declared
/// size (which the caller has already validated against the global cap). The
/// read limit means a malicious sender can't stream us more than advertised.
pub async fn fetch_attachment(
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
pub fn forget_attachment(&self, id: AttachmentId) {
self.shared.served_files.lock().unwrap().remove(&id);
}
/// Fetch `size` bytes stored under `id` from peer `from` over the files plane.
/// Shared core of `fetch_attachment` and music-track fetching: dials
/// `FILES_ALPN`, writes the 32-byte id, and reads bounded by `size`.
pub async fn fetch_blob(
&self,
from: EndpointId,
att: &ChatAttachment,
id: AttachmentId,
size: u64,
) -> Result<Vec<u8>, NetError> {
if !crate::files::size_within_cap(att.size) {
if !crate::files::size_within_cap(size) {
return Err(NetError::Other("attachment size out of range".to_string()));
}
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
@@ -623,13 +627,13 @@ impl IrohTransport {
.open_bi()
.await
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
send.write_all(&att.id)
send.write_all(&id)
.await
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
send.finish()
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
let read = recv.read_to_end(att.size as usize);
let read = recv.read_to_end(size as usize);
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
.await
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
@@ -639,6 +643,15 @@ impl IrohTransport {
}
Ok(bytes)
}
/// Fetch a chat attachment's bytes from its sender over the file plane.
pub async fn fetch_attachment(
&self,
from: EndpointId,
att: &ChatAttachment,
) -> Result<Vec<u8>, NetError> {
self.fetch_blob(from, att.id, att.size).await
}
}
#[async_trait]
+58
View File
@@ -22,6 +22,38 @@ pub enum NetError {
Other(String),
}
/// A peer's currently-broadcast music track + playback timeline (W22). Rides
/// gossip presence so listeners can tune in, follow track changes, and keep in
/// sync. Untrusted like `name`/`game`: the `name` is sanitized and `size` is
/// cap-checked at gossip ingest. Bytes never ride gossip — they are fetched
/// point-to-point over the files plane by `id`, exactly like a chat attachment.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MusicPresence {
/// Files-plane handle to fetch this track's bytes (minted per track by the DJ).
pub id: crate::files::AttachmentId,
/// Sanitized display name (track filename). Untrusted; cleaned at ingest.
pub name: String,
/// Byte length, bounds the listener's fetch. Must be `<= MAX_ATTACHMENT_BYTES`.
pub size: u64,
/// True while the DJ has the track paused.
pub paused: bool,
/// Wall-clock ms (UNIX epoch) of the timeline anchor. While playing, the true
/// playhead is `position_ms + (now_ms - anchor_ms)`; while paused it is
/// frozen at `position_ms`. Re-stamped on every play/pause/seek.
pub anchor_ms: u64,
/// Playhead position (ms) at `anchor_ms`.
pub position_ms: u64,
/// Files-plane handle for the DJ's NEXT track, so listeners can prefetch it
/// for a gapless skip. `None` when there is no distinct next track (single
/// item playlist) or the DJ isn't ready. Equals a future `id` once that
/// track plays.
#[serde(default)]
pub next_id: Option<crate::files::AttachmentId>,
/// Byte length of the next track; bounds the prefetch. Cap-checked at ingest.
#[serde(default)]
pub next_size: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PeerState {
pub name: String,
@@ -48,6 +80,10 @@ pub struct PeerState {
/// Defaulted so peers/configs predating the field still deserialize.
#[serde(default)]
pub game: Option<String>,
/// This peer's currently-broadcast music track and playback timeline, or
/// `None` when not broadcasting. Defaulted so pre-W22 peers deserialize.
#[serde(default)]
pub music: Option<MusicPresence>,
}
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
@@ -70,6 +106,8 @@ pub struct SelfPresence {
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
/// the outgoing announce carries a safe value.
pub game: Option<String>,
/// Our current broadcast timeline, or `None` when not broadcasting / not playing.
pub music: Option<MusicPresence>,
}
impl SelfPresence {
@@ -89,6 +127,7 @@ impl SelfPresence {
sharing,
avatar: self.avatar.clone(),
game: self.game.clone(),
music: self.music.clone(),
}
}
}
@@ -307,6 +346,7 @@ mod tests {
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
music: None,
}
}
@@ -422,6 +462,7 @@ mod tests {
name: "Alice".to_string(),
avatar: crate::avatar::Avatar::default(),
game: Some("Half-Life 2".to_string()),
music: None,
};
// Volatile fields come from the call; sticky fields from the struct.
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
@@ -445,4 +486,21 @@ mod tests {
let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn music_presence_serde_round_trip() {
let original = MusicPresence {
id: [3u8; 32],
name: "track.ogg".to_string(),
size: 1234,
paused: false,
anchor_ms: 1_700_000_000_000,
position_ms: 42_000,
next_id: None,
next_size: None,
};
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: MusicPresence = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
}