···8181 }
8282 });
83838484- let app = web::router(rooms, pool, args.cache_dir, sources);
8484+ let app = web::router(rooms, pool);
8585 let listener = tokio::net::TcpListener::bind(args.http_addr).await?;
86868787 tracing::info!("HTTP server listening on {}", args.http_addr);
+23
src/playlist.rs
···165165 .await?;
166166 Ok(())
167167 }
168168+169169+ /// Update metadata fields after async extraction completes.
170170+ ///
171171+ /// Updates title, duration, thumbnail, and source for the given item.
172172+ /// Used when yt-dlp extraction finishes after the item was already
173173+ /// queued with placeholder values.
174174+ pub(crate) async fn update_metadata(
175175+ &self,
176176+ id: i64,
177177+ meta: &TrackMeta,
178178+ ) -> Result<(), sqlx::Error> {
179179+ sqlx::query(
180180+ "UPDATE queue_items SET title = ?, duration = ?, thumbnail = ?, source = ? WHERE id = ?",
181181+ )
182182+ .bind(&meta.title)
183183+ .bind(&meta.duration)
184184+ .bind(&meta.thumbnail)
185185+ .bind(meta.source.to_string())
186186+ .bind(id)
187187+ .execute(&self.pool)
188188+ .await?;
189189+ Ok(())
190190+ }
168191}
169192170193#[cfg(test)]
+147-1
src/room.rs
···122122 room_name: String,
123123 publishers: TrackPublishers,
124124 pool: Pool<Sqlite>,
125125+ source_registry: Arc<SourceRegistry>,
126126+ cache_dir: PathBuf,
127127+ /// Clone of the command sender — used by spawned extraction tasks to
128128+ /// deliver MetadataReady / MetadataFailed back to the actor.
129129+ cmd_tx: mpsc::Sender<RoomCommand>,
125130126131 // Mutable state (owned by this actor, no locks needed)
127132 state: PlaybackState,
···184189 room_name: room_name.clone(),
185190 publishers: publishers.clone(),
186191 pool: self.pool.clone(),
192192+ source_registry: self.sources.clone(),
193193+ cache_dir: self.cache_dir.clone(),
194194+ cmd_tx: tx.clone(),
187195 state: PlaybackState::default(),
188196 player,
189197 client_count: 0,
···240248 }
241249242250 /// Push a track onto a room's queue (persisted to SQLite via Playlist).
251251+ #[allow(dead_code)] // only used by tests
243252 pub(crate) async fn push(&self, id: &RoomId, meta: TrackMeta) {
244253 if let Some(handle) = self.handle(id).await {
245254 let _ = handle.cmd_tx.send(RoomCommand::QueueTrack(meta)).await;
246255 }
256256+ }
257257+258258+ /// Push a raw URL onto a room's queue. Metadata extraction (yt-dlp) runs
259259+ /// asynchronously in a spawned task — the caller returns immediately.
260260+ pub(crate) async fn push_url(&self, id: &RoomId, url: String) {
261261+ let t0 = std::time::Instant::now();
262262+ let handle = match self.handle(id).await {
263263+ Some(h) => h,
264264+ None => return,
265265+ };
266266+ let t1 = t0.elapsed();
267267+ let _ = handle.cmd_tx.send(RoomCommand::QueueUrl(url)).await;
268268+ let t2 = t0.elapsed();
269269+ tracing::info!(
270270+ room = %id,
271271+ "push_url timing: handle={:.0?} send={:.0?} total={:.0?}",
272272+ t1, t2 - t1, t2
273273+ );
247274 }
248275249276 /// Remove a room (from SQLite and in-memory state).
···436463 url: item.url,
437464 duration: item.duration,
438465 thumbnail: item.thumbnail,
466466+ pending: false,
439467 }),
440468 now,
441469 );
···444472 self.execute_effects(effects).await;
445473 }
446474475475+ RoomCommand::QueueUrl(url) => {
476476+ let t0 = std::time::Instant::now();
477477+ tracing::debug!(room = %self.room_id, %url, "track url queued");
478478+479479+ // ── Gather ──────────────────────────────────────────
480480+ let playlist = Playlist::new(self.pool.clone(), &self.room_id.0);
481481+ let placeholder = TrackMeta {
482482+ title: "Loading…".into(),
483483+ duration: "--:--".into(),
484484+ thumbnail: None,
485485+ url: url.clone(),
486486+ source: crate::types::SourceKind::Direct,
487487+ };
488488+ let item = match playlist.push(&placeholder).await {
489489+ Ok(item) => item,
490490+ Err(e) => {
491491+ tracing::warn!("failed to persist queue item: {e}");
492492+ return false;
493493+ }
494494+ };
495495+ let t1 = t0.elapsed();
496496+ let now = Utc::now().timestamp_millis();
497497+498498+ // ── Transition (pure) ───────────────────────────────
499499+ let effects = self.state.transition(
500500+ &Event::TrackQueued(state::QueuedTrack {
501501+ id: item.id,
502502+ title: placeholder.title,
503503+ url: placeholder.url,
504504+ duration: placeholder.duration,
505505+ thumbnail: placeholder.thumbnail,
506506+ pending: true,
507507+ }),
508508+ now,
509509+ );
510510+ let t2 = t0.elapsed();
511511+512512+ // ── Commit ──────────────────────────────────────────
513513+ self.execute_effects(effects).await;
514514+ let t3 = t0.elapsed();
515515+516516+ // ── Spawn async extraction (non-blocking) ───────────
517517+ let item_id = item.id;
518518+ let cmd_tx = self.cmd_tx.clone();
519519+ let sources = self.source_registry.clone();
520520+ let cache_dir = self.cache_dir.clone();
521521+ tokio::spawn(async move {
522522+ match sources.extract(&url, &cache_dir).await {
523523+ Ok(meta) => {
524524+ let _ = cmd_tx
525525+ .send(RoomCommand::MetadataReady { item_id, meta })
526526+ .await;
527527+ }
528528+ Err(e) => {
529529+ let _ = cmd_tx
530530+ .send(RoomCommand::MetadataFailed {
531531+ item_id,
532532+ error: e.to_string(),
533533+ })
534534+ .await;
535535+ }
536536+ }
537537+ });
538538+539539+ tracing::info!(
540540+ room = %self.room_id,
541541+ "QueueUrl actor timing: db_push={:.0?} transition={:.0?} effects={:.0?} total={:.0?}",
542542+ t1, t2 - t1, t3 - t2, t3
543543+ );
544544+ }
545545+546546+ RoomCommand::MetadataReady { item_id, meta } => {
547547+ tracing::debug!(room = %self.room_id, item_id, title = %meta.title, "metadata ready");
548548+549549+ // ── Gather ──────────────────────────────────────────
550550+ let now = Utc::now().timestamp_millis();
551551+552552+ // ── DB update (synchronous commit, actor-safe) ──────
553553+ let playlist = Playlist::new(self.pool.clone(), &self.room_id.0);
554554+ if let Err(e) = playlist.update_metadata(item_id, &meta).await {
555555+ tracing::warn!(item_id, error = %e, "failed to persist metadata update");
556556+ }
557557+558558+ // ── Transition (pure) ───────────────────────────────
559559+ let effects = self.state.transition(
560560+ &Event::MetadataUpdated {
561561+ item_id,
562562+ title: meta.title,
563563+ duration: meta.duration,
564564+ thumbnail: meta.thumbnail,
565565+ },
566566+ now,
567567+ );
568568+569569+ // ── Commit ──────────────────────────────────────────
570570+ self.execute_effects(effects).await;
571571+ }
572572+573573+ RoomCommand::MetadataFailed { item_id, error } => {
574574+ tracing::debug!(room = %self.room_id, item_id, %error, "metadata extraction failed");
575575+576576+ // ── Transition (pure) — set error state in title ────
577577+ let now = Utc::now().timestamp_millis();
578578+ let effects = self.state.transition(
579579+ &Event::MetadataUpdated {
580580+ item_id,
581581+ title: format!("Failed to load — {error}"),
582582+ duration: "--:--".into(),
583583+ thumbnail: None,
584584+ },
585585+ now,
586586+ );
587587+ self.execute_effects(effects).await;
588588+ }
589589+447590 RoomCommand::Skip => {
448591 tracing::info!(room = %self.room_id, "skip requested");
449592···534677 }
535678536679 Effect::PersistStarted(id) => {
680680+ let t0 = std::time::Instant::now();
537681 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
538682 let playlist = Playlist::new(self.pool.clone(), &self.room_id.0);
539683 let _ = playlist.mark_started(id, &now).await;
684684+ tracing::debug!(room = %self.room_id, id, "PersistStarted: {:.0?}", t0.elapsed());
540685 }
541686542687 Effect::StartPipeline(track) => {
···587732 url: q.url.clone(),
588733 duration: q.duration.clone(),
589734 thumbnail: q.thumbnail.clone(),
735735+ pending: q.pending,
590736 })
591737 .collect();
592738···608754609755 tracing::trace!(
610756 room = %self.room_id, queue_len = %queue.len(), history_len = %history.len(),
611611- clients = %client_count, "state snapshot published"
757757+ clients = %client_count, "publish_state_snapshot",
612758 );
613759614760 let snapshot = serde_json::json!({
+50-41
src/source/ytdlp.rs
···77use std::path::Path;
8899use serde::Deserialize;
1010-use tokio::process::Command;
11101211use crate::media;
1312use crate::source::{MediaSource, SourceError};
···4948 return Err(SourceError::Unsupported(format!("not an HTTP(S) URL: {url}")));
5049 }
51505252- let output = Command::new("yt-dlp")
5353- .args([
5454- "--dump-json",
5555- "--no-playlist",
5656- "--no-warnings",
5757- "--cache-dir",
5858- ])
5959- .arg(cache_dir)
6060- .arg(url)
6161- .kill_on_drop(true)
6262- .output()
6363- .await
6464- .map_err(|e| {
6565- if e.kind() == std::io::ErrorKind::NotFound {
6666- SourceError::Unsupported("yt-dlp not found on PATH".into())
6767- } else {
6868- SourceError::Io(e)
6969- }
7070- })?;
5151+ let cache_dir = cache_dir.to_path_buf();
5252+ let url = url.to_string();
5353+ let result = tokio::task::spawn_blocking(move || {
5454+ std::process::Command::new("yt-dlp")
5555+ .args([
5656+ "--dump-json",
5757+ "--no-playlist",
5858+ "--no-warnings",
5959+ "--cache-dir",
6060+ ])
6161+ .arg(&cache_dir)
6262+ .arg(&url)
6363+ .output()
6464+ })
6565+ .await
6666+ .map_err(|e| SourceError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
6767+6868+ let output = result.map_err(|e| {
6969+ if e.kind() == std::io::ErrorKind::NotFound {
7070+ SourceError::Unsupported("yt-dlp not found on PATH".into())
7171+ } else {
7272+ SourceError::Io(e)
7373+ }
7474+ })?;
71757276 if !output.status.success() {
7377 let stderr = String::from_utf8_lossy(&output.stderr);
···99103 return Err(SourceError::Unsupported(format!("not an HTTP(S) URL: {url}")));
100104 }
101105102102- let output = Command::new("yt-dlp")
103103- .args([
104104- "-f",
105105- "b",
106106- "-g",
107107- "--no-playlist",
108108- "--no-warnings",
109109- "--cache-dir",
110110- ])
111111- .arg(cache_dir)
112112- .arg(url)
113113- .kill_on_drop(true)
114114- .output()
115115- .await
116116- .map_err(|e| {
117117- if e.kind() == std::io::ErrorKind::NotFound {
118118- SourceError::Unsupported("yt-dlp not found on PATH".into())
119119- } else {
120120- SourceError::Io(e)
121121- }
122122- })?;
106106+ let cache_dir = cache_dir.to_path_buf();
107107+ let url = url.to_string();
108108+ let result = tokio::task::spawn_blocking(move || {
109109+ std::process::Command::new("yt-dlp")
110110+ .args([
111111+ "-f",
112112+ "b",
113113+ "-g",
114114+ "--no-playlist",
115115+ "--no-warnings",
116116+ "--cache-dir",
117117+ ])
118118+ .arg(&cache_dir)
119119+ .arg(&url)
120120+ .output()
121121+ })
122122+ .await
123123+ .map_err(|e| SourceError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
124124+125125+ let output = result.map_err(|e| {
126126+ if e.kind() == std::io::ErrorKind::NotFound {
127127+ SourceError::Unsupported("yt-dlp not found on PATH".into())
128128+ } else {
129129+ SourceError::Io(e)
130130+ }
131131+ })?;
123132124133 if !output.status.success() {
125134 let stderr = String::from_utf8_lossy(&output.stderr);
+232-41
src/state.rs
···2727 pub url: String,
2828 pub duration: String,
2929 pub thumbnail: Option<String>,
3030+ /// True while metadata is being extracted asynchronously.
3131+ pub pending: bool,
3032}
31333234/// A finished track in history.
···4951 Skip,
5052 /// The transcoding pipeline finished (naturally or via abort).
5153 TrackEnded { item_id: i64 },
5454+ /// Asynchronous metadata extraction completed for a queued/playing track.
5555+ MetadataUpdated {
5656+ item_id: i64,
5757+ title: String,
5858+ duration: String,
5959+ thumbnail: Option<String>,
6060+ },
5261}
53625463/// Side effects to execute after a transition.
···102111 Event::TrackQueued(track) => self.handle_queued(track),
103112 Event::Skip => self.handle_skip(now),
104113 Event::TrackEnded { item_id } => self.handle_track_ended(*item_id, now),
114114+ Event::MetadataUpdated {
115115+ item_id,
116116+ title,
117117+ duration,
118118+ thumbnail,
119119+ } => self.handle_metadata_updated(*item_id, title, duration, thumbnail),
105120 }
106121 }
107122···193208 effects
194209 }
195210211211+ /// Asynchronous metadata resolution completed. Updates the matching item
212212+ /// in the queue and/or the active track in-place. Does not change playback
213213+ /// state — only updates display fields.
214214+ fn handle_metadata_updated(
215215+ &mut self,
216216+ item_id: i64,
217217+ title: &str,
218218+ duration: &str,
219219+ thumbnail: &Option<String>,
220220+ ) -> Vec<Effect> {
221221+ if let Some(item) = self.queue.iter_mut().find(|t| t.id == item_id) {
222222+ item.title = title.to_string();
223223+ item.duration = duration.to_string();
224224+ item.thumbnail.clone_from(thumbnail);
225225+ item.pending = false;
226226+ }
227227+ if let Some(ref mut active) = self.active {
228228+ if active.id == item_id {
229229+ active.title = title.to_string();
230230+ active.duration = duration.to_string();
231231+ active.thumbnail.clone_from(thumbnail);
232232+ }
233233+ }
234234+ vec![Effect::PublishSnapshot]
235235+ }
236236+196237 /// Pop the first track from the queue and start playing it.
197238 ///
198239 /// Returns effects for starting the pipeline and publishing state.
···246287 url: format!("https://example.com/{title}"),
247288 duration: "3:45".into(),
248289 thumbnail: None,
290290+ pending: false,
249291 }
250292 }
251293294294+ // ── Effect ordering tests ─────────────────────────────────────────────
295295+ // These verify the exact effect sequence returned by transition(). Order
296296+ // matters: PersistStarted must precede StartPipeline (DB commit before
297297+ // pipeline spawn), AbortPipeline must precede PersistFinished, etc.
298298+299299+ #[test]
300300+ fn idle_to_playing_effect_order() {
301301+ let mut s = PlaybackState::default();
302302+ let effects = s.transition(&Event::TrackQueued(queued("A", 1)), 0);
303303+304304+ assert_eq!(
305305+ effects,
306306+ vec![
307307+ Effect::PersistStarted(1),
308308+ Effect::StartPipeline(queued("A", 1)),
309309+ Effect::PublishSnapshot,
310310+ ],
311311+ "idle → first track: persist started_at, spawn pipeline, notify clients"
312312+ );
313313+ }
314314+315315+ #[test]
316316+ fn queue_second_while_playing_effect_order() {
317317+ let mut s = PlaybackState::default();
318318+ s.transition(&Event::TrackQueued(queued("A", 1)), 0);
319319+ let effects = s.transition(&Event::TrackQueued(queued("B", 2)), 0);
320320+321321+ assert_eq!(
322322+ effects,
323323+ vec![Effect::PublishSnapshot],
324324+ "playing → queue another: just notify — pipeline untouched"
325325+ );
326326+ }
327327+328328+ #[test]
329329+ fn skip_with_next_effect_order() {
330330+ let mut s = PlaybackState::default();
331331+ s.transition(&Event::TrackQueued(queued("A", 1)), 0);
332332+ s.transition(&Event::TrackQueued(queued("B", 2)), 0);
333333+ let effects = s.transition(&Event::Skip, 1_700_000_000_001);
334334+335335+ assert_eq!(
336336+ &effects[..3],
337337+ &[
338338+ Effect::AbortPipeline,
339339+ Effect::PersistFinished(1),
340340+ Effect::PersistStarted(2),
341341+ ],
342342+ "skip with next: abort current, finalize A, persist B started_at"
343343+ );
344344+ assert!(
345345+ effects
346346+ .iter()
347347+ .any(|e| matches!(e, Effect::StartPipeline(t) if t.title == "B")),
348348+ "skip with next: must include StartPipeline for B"
349349+ );
350350+ assert!(
351351+ effects.contains(&Effect::PublishSnapshot),
352352+ "skip with next: must notify clients"
353353+ );
354354+ }
355355+356356+ #[test]
357357+ fn skip_last_track_effect_order() {
358358+ let mut s = PlaybackState::default();
359359+ s.transition(&Event::TrackQueued(queued("A", 1)), 0);
360360+ let effects = s.transition(&Event::Skip, 0);
361361+362362+ assert_eq!(
363363+ effects,
364364+ vec![
365365+ Effect::AbortPipeline,
366366+ Effect::PersistFinished(1),
367367+ Effect::PublishSnapshot,
368368+ ],
369369+ "skip last: abort, finalize, notify — no StartPipeline"
370370+ );
371371+ }
372372+373373+ #[test]
374374+ fn track_ended_with_next_effect_order() {
375375+ let mut s = PlaybackState::default();
376376+ s.transition(&Event::TrackQueued(queued("A", 1)), 0);
377377+ s.transition(&Event::TrackQueued(queued("B", 2)), 0);
378378+ let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0);
379379+380380+ assert_eq!(
381381+ &effects[..2],
382382+ &[Effect::PersistFinished(1), Effect::PersistStarted(2),],
383383+ "track ended (next): finalize A, persist B started_at — no AbortPipeline"
384384+ );
385385+ assert!(
386386+ effects
387387+ .iter()
388388+ .any(|e| matches!(e, Effect::StartPipeline(t) if t.title == "B")),
389389+ "track ended (next): must start B"
390390+ );
391391+ assert!(
392392+ effects.contains(&Effect::PublishSnapshot),
393393+ "track ended (next): must notify"
394394+ );
395395+ }
396396+397397+ #[test]
398398+ fn track_ended_last_goes_idle_effect_order() {
399399+ let mut s = PlaybackState::default();
400400+ s.transition(&Event::TrackQueued(queued("A", 1)), 0);
401401+ let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0);
402402+403403+ assert_eq!(
404404+ effects,
405405+ vec![Effect::PersistFinished(1), Effect::PublishSnapshot,],
406406+ "track ended last: finalize, notify — no StartPipeline, no AbortPipeline"
407407+ );
408408+ }
409409+410410+ // ── State invariant tests ─────────────────────────────────────────────
411411+252412 #[test]
253413 fn idle_room_starts_playing_on_first_track() {
254414 let mut s = PlaybackState::default();
255255- let effects = s.transition(&Event::TrackQueued(queued("A", 1)), 0);
415415+ s.transition(&Event::TrackQueued(queued("A", 1)), 0);
256416257417 assert!(s.active.is_some());
258418 assert_eq!(s.active.as_ref().unwrap().title, "A");
259419 assert!(s.queue.is_empty());
260260- assert!(effects.contains(&Effect::PublishSnapshot));
261261- assert!(effects
262262- .iter()
263263- .any(|e| matches!(e, Effect::StartPipeline(_))));
264420 }
265421266422 #[test]
267423 fn second_track_stays_in_queue() {
268424 let mut s = PlaybackState::default();
269425 s.transition(&Event::TrackQueued(queued("A", 1)), 0);
270270- let effects = s.transition(&Event::TrackQueued(queued("B", 2)), 0);
426426+ s.transition(&Event::TrackQueued(queued("B", 2)), 0);
271427272428 assert_eq!(s.queue.len(), 1);
273429 assert_eq!(s.queue[0].title, "B");
274430 assert_eq!(s.active.as_ref().unwrap().title, "A");
275275- assert_eq!(effects, vec![Effect::PublishSnapshot]);
276431 }
277432278433 #[test]
···280435 let mut s = PlaybackState::default();
281436 s.transition(&Event::TrackQueued(queued("A", 1)), 0);
282437 s.transition(&Event::TrackQueued(queued("B", 2)), 0);
283283- let effects = s.transition(&Event::Skip, 1_700_000_000_001);
438438+ s.transition(&Event::Skip, 1_700_000_000_001);
284439285285- // Should have advanced to B.
286440 assert_eq!(s.active.as_ref().unwrap().title, "B");
287441 assert!(s.queue.is_empty());
288288-289289- // A should be in history.
290442 assert_eq!(s.history.len(), 1);
291443 assert_eq!(s.history[0].title, "A");
292292-293293- // Effects: abort prev, persist finished, start B, publish snapshot.
294294- assert!(effects.contains(&Effect::AbortPipeline));
295295- assert!(effects.contains(&Effect::PersistFinished(1)));
296296- assert!(effects
297297- .iter()
298298- .any(|e| matches!(e, Effect::StartPipeline(t) if t.title == "B")));
299299- assert!(effects.contains(&Effect::PublishSnapshot));
300444 }
301445302446 #[test]
···312456 fn skip_last_track_goes_idle() {
313457 let mut s = PlaybackState::default();
314458 s.transition(&Event::TrackQueued(queued("A", 1)), 0);
315315- let effects = s.transition(&Event::Skip, 0);
459459+ s.transition(&Event::Skip, 0);
316460317461 assert!(s.active.is_none());
318462 assert_eq!(s.history.len(), 1);
319463 assert_eq!(s.history[0].title, "A");
320320- assert!(effects.contains(&Effect::AbortPipeline));
321321- assert!(effects.contains(&Effect::PersistFinished(1)));
322322- assert!(effects.contains(&Effect::PublishSnapshot));
323323- // No StartPipeline since queue is empty.
324324- assert!(!effects
325325- .iter()
326326- .any(|e| matches!(e, Effect::StartPipeline(_))));
327464 }
328465329466 #[test]
···331468 let mut s = PlaybackState::default();
332469 s.transition(&Event::TrackQueued(queued("A", 1)), 0);
333470 s.transition(&Event::TrackQueued(queued("B", 2)), 0);
334334-335335- let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0);
471471+ s.transition(&Event::TrackEnded { item_id: 1 }, 0);
336472337473 assert_eq!(s.active.as_ref().unwrap().title, "B");
338474 assert_eq!(s.history.len(), 1);
339475 assert_eq!(s.history[0].title, "A");
340340- assert!(effects.contains(&Effect::PersistFinished(1)));
341341- assert!(effects.contains(&Effect::PublishSnapshot));
342476 }
343477344478 #[test]
···346480 let mut s = PlaybackState::default();
347481 s.transition(&Event::TrackQueued(queued("A", 1)), 0);
348482349349- // Stale TrackEnded for a different id.
350483 let effects = s.transition(&Event::TrackEnded { item_id: 999 }, 0);
351484352485 assert!(s.active.is_some());
···358491 fn track_ended_last_track_goes_idle() {
359492 let mut s = PlaybackState::default();
360493 s.transition(&Event::TrackQueued(queued("A", 1)), 0);
361361-362362- let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0);
494494+ s.transition(&Event::TrackEnded { item_id: 1 }, 0);
363495364496 assert!(s.active.is_none());
365497 assert_eq!(s.history.len(), 1);
366366- assert!(effects.contains(&Effect::PublishSnapshot));
367367- assert!(!effects
368368- .iter()
369369- .any(|e| matches!(e, Effect::StartPipeline(_))));
370498 }
371499372500 #[test]
···383511 let mut s = PlaybackState::default();
384512 s.transition(&Event::TrackQueued(queued("A", 1)), 0);
385513 s.transition(&Event::TrackQueued(queued("B", 2)), 0);
386386-387387- // Skip A → B starts playing.
388514 s.transition(&Event::Skip, 0);
389515 assert_eq!(s.active.as_ref().unwrap().title, "B");
390516391391- // Stale TrackEnded for A arrives — should be ignored.
392517 let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0);
393518 assert!(effects.is_empty());
394519 assert_eq!(s.active.as_ref().unwrap().title, "B");
···399524 let mut s = PlaybackState::default();
400525 s.resolve_started_at(42);
401526 assert!(s.active.is_none());
527527+ }
528528+529529+ // ── Metadata update tests ─────────────────────────────────────────
530530+531531+ #[test]
532532+ fn metadata_update_updates_queue_item() {
533533+ let mut s = PlaybackState::default();
534534+ s.transition(&Event::TrackQueued(queued("A", 1)), 0);
535535+ s.transition(
536536+ &Event::MetadataUpdated {
537537+ item_id: 1,
538538+ title: "Track A (Real)".into(),
539539+ duration: "4:20".into(),
540540+ thumbnail: Some("https://img.example/a.jpg".into()),
541541+ },
542542+ 0,
543543+ );
544544+545545+ // Queue should have empty (track was advanced to active), but active
546546+ // should have updated metadata.
547547+ assert_eq!(s.active.as_ref().unwrap().title, "Track A (Real)");
548548+ assert_eq!(s.active.as_ref().unwrap().duration, "4:20");
549549+ assert_eq!(
550550+ s.active.as_ref().unwrap().thumbnail,
551551+ Some("https://img.example/a.jpg".into())
552552+ );
553553+ }
554554+555555+ #[test]
556556+ fn metadata_update_on_queued_item() {
557557+ let mut s = PlaybackState::default();
558558+ s.transition(&Event::TrackQueued(queued("A", 1)), 0);
559559+ s.transition(&Event::TrackQueued(queued("B", 2)), 0);
560560+561561+ // Update the queued item (B, id=2).
562562+ let effects = s.transition(
563563+ &Event::MetadataUpdated {
564564+ item_id: 2,
565565+ title: "Track B (Real)".into(),
566566+ duration: "5:55".into(),
567567+ thumbnail: None,
568568+ },
569569+ 0,
570570+ );
571571+ assert_eq!(effects, vec![Effect::PublishSnapshot]);
572572+ assert_eq!(s.queue[0].title, "Track B (Real)");
573573+ assert!(!s.queue[0].pending);
574574+ }
575575+576576+ #[test]
577577+ fn metadata_update_on_unknown_id_is_noop() {
578578+ let mut s = PlaybackState::default();
579579+ let effects = s.transition(
580580+ &Event::MetadataUpdated {
581581+ item_id: 999,
582582+ title: "Ghost".into(),
583583+ duration: "0:00".into(),
584584+ thumbnail: None,
585585+ },
586586+ 0,
587587+ );
588588+ // Still publishes snapshot even if nothing changed — clients may want
589589+ // to know the update was processed.
590590+ assert_eq!(effects, vec![Effect::PublishSnapshot]);
591591+ assert!(s.active.is_none());
592592+ assert!(s.queue.is_empty());
402593 }
403594}
+86-127
src/transport.rs
···261261/// Cloning is cheap (all inner channels are `Arc`-based).
262262#[derive(Clone)]
263263pub(crate) struct TrackPublishers {
264264- pub(crate) audio: broadcast::Sender<MoqObject>,
265264 pub(crate) video: broadcast::Sender<MoqObject>,
266265 pub(crate) chat: broadcast::Sender<MoqObject>,
267266 pub(crate) state: watch::Sender<StateValue>,
···273272274273impl TrackPublishers {
275274 pub(crate) fn new() -> Self {
276276- let (audio, _) = broadcast::channel(256);
277275 let (video, _) = broadcast::channel(256);
278276 let (chat, _) = broadcast::channel(256);
279277 let (state, _) = watch::channel(StateValue {
···282280 });
283281 let (video_init_watch, _) = watch::channel(false);
284282 TrackPublishers {
285285- audio,
286283 video,
287284 chat,
288285 state,
···455452 }
456453}
457454455455+/// WebSocket session for video-only delivery.
456456+///
457457+/// No MoQ protocol negotiation — the server auto-subscribes the client to
458458+/// the video broadcast. Messages are raw fMP4 bytes (no MoQ framing).
459459+/// A separate WS-control connection handles chat, state, and MoQ handshake.
460460+pub(crate) async fn handle_video_session(
461461+ ws: WebSocket,
462462+ publishers: TrackPublishers,
463463+) {
464464+ let (mut ws_tx, mut ws_rx) = ws.split();
465465+466466+ // Collect the cached init (or wait for the pipeline to produce one)
467467+ // but don't send it yet — we need to subscribe to the broadcast first
468468+ // so we can discard any init that was also published via broadcast,
469469+ // which would otherwise arrive as a duplicate through the forward loop.
470470+ let init = match publishers.get_init_segment() {
471471+ Some(cached) => {
472472+ tracing::debug!("video ws: using cached init segment");
473473+ Some(cached)
474474+ }
475475+ None => {
476476+ tracing::debug!("video ws: no cached init, waiting for watch signal");
477477+ let mut init_rx = publishers.video_init_waiter();
478478+ let _ = init_rx.changed().await;
479479+ let result = publishers.get_init_segment();
480480+ if result.is_some() {
481481+ tracing::debug!("video ws: received init segment via watch");
482482+ } else {
483483+ tracing::warn!("video ws: watch fired but no init available");
484484+ }
485485+ result
486486+ }
487487+ };
488488+489489+ // Subscribe to the broadcast before sending cached init so that
490490+ // any init published via broadcast is caught by the catch-up loop
491491+ // and discarded, preventing a duplicate on the forward path.
492492+ let mut video_rx = publishers.video.subscribe();
493493+ loop {
494494+ match video_rx.try_recv() {
495495+ Ok(_) => continue,
496496+ Err(broadcast::error::TryRecvError::Empty) => break,
497497+ Err(broadcast::error::TryRecvError::Closed) => return,
498498+ Err(broadcast::error::TryRecvError::Lagged(_)) => break,
499499+ }
500500+ }
501501+502502+ // Now send the cached init — the broadcast buffer has been drained
503503+ // so the forward loop below won't re-deliver it.
504504+ if let Some((_group_id, payload)) = init {
505505+ if ws_tx.send(Message::Binary(payload)).await.is_err() {
506506+ return;
507507+ }
508508+ }
509509+510510+ // Forward live video objects as raw fMP4 payloads.
511511+ loop {
512512+ tokio::select! {
513513+ msg = ws_rx.next() => {
514514+ match msg {
515515+ Some(Ok(Message::Close(_))) | None => break,
516516+ _ => continue,
517517+ }
518518+ }
519519+ result = video_rx.recv() => {
520520+ match result {
521521+ Ok(obj) => {
522522+ if ws_tx.send(Message::Binary(obj.payload)).await.is_err() {
523523+ break;
524524+ }
525525+ }
526526+ Err(broadcast::error::RecvError::Lagged(n)) => {
527527+ tracing::debug!("video ws: broadcast lagged by {n}");
528528+ }
529529+ Err(broadcast::error::RecvError::Closed) => break,
530530+ }
531531+ }
532532+ }
533533+ }
534534+}
535535+458536async fn handle_moq_message(
459537 msg: MoqMessage,
460538 room_id: &RoomId,
···573651 }
574652 }
575653 }));
576576- }
577577- TrackId::Video => {
578578- // Send cached init (or wait for it) and forward live broadcast
579579- // from the same spawned task to guarantee ordering for late joiners.
580580- let init_rx = publishers.video_init_waiter();
581581- let cached = publishers.get_init_segment(); // snapshot before spawn
582582- let tx = object_tx.clone();
583583- let publishers_for_task = publishers.clone();
584584- let rx = publishers.video.subscribe();
585585- task_handles.push(tokio::spawn(async move {
586586- // 1. Ensure init segment is available (wait if pipeline
587587- // hasn't produced it yet).
588588- let init = if let Some(cached) = cached {
589589- tracing::debug!("forward task: using cached init segment");
590590- Some(cached)
591591- } else {
592592- tracing::debug!(
593593- "forward task: no cached init, waiting for watch signal"
594594- );
595595- let mut init_rx = init_rx;
596596- let _ = init_rx.changed().await;
597597- let result = publishers_for_task.get_init_segment();
598598- if result.is_some() {
599599- tracing::debug!("forward task: received init segment via watch");
600600- } else {
601601- tracing::warn!("forward task: watch fired but no init available");
602602- }
603603- result
604604- };
605605-606606- if let Some((group_id, init)) = init {
607607- tracing::debug!(
608608- group_id,
609609- size = init.len(),
610610- "forward task: sending init segment"
611611- );
612612- let obj = encode_message(&MoqMessage::Object {
613613- track_id: TrackId::Video as u64,
614614- group_id,
615615- object_id: 0,
616616- payload: init,
617617- });
618618- if tx.send(obj).is_err() {
619619- return;
620620- }
621621- } else {
622622- // No init and never got one — nothing to play.
623623- tracing::warn!("forward task: no init segment available, aborting");
624624- return;
625625- }
626626-627627- // 2. Catch up to live edge: discard any messages that were
628628- // already in the broadcast buffer before subscription.
629629- // Without this, a late subscriber would replay all prior
630630- // segments from the oldest in the buffer.
631631- let mut rx = rx;
632632- loop {
633633- match rx.try_recv() {
634634- Ok(_) => continue,
635635- Err(broadcast::error::TryRecvError::Empty) => break,
636636- Err(broadcast::error::TryRecvError::Closed) => return,
637637- Err(broadcast::error::TryRecvError::Lagged(_)) => break,
638638- }
639639- }
640640-641641- // 3. Forward live broadcast messages.
642642- tracing::debug!("forward task: entering broadcast loop");
643643- loop {
644644- match rx.recv().await {
645645- Ok(obj) => {
646646- let msg = encode_message(&MoqMessage::Object {
647647- track_id: obj.track_id as u64,
648648- group_id: obj.group_id,
649649- object_id: obj.object_id,
650650- payload: obj.payload,
651651- });
652652- if tx.send(msg).is_err() {
653653- break;
654654- }
655655- }
656656- Err(broadcast::error::RecvError::Lagged(n)) => {
657657- tracing::debug!("forward task: video broadcast lagged by {n}");
658658- }
659659- Err(broadcast::error::RecvError::Closed) => break,
660660- }
661661- }
662662- }));
663663- }
664664- TrackId::Audio => {
665665- let rx = publishers.audio.subscribe();
666666- let tx = object_tx.clone();
667667- task_handles.push(tokio::spawn(async move {
668668- let mut rx = rx;
669669- // Catch up to live edge — discard prior messages.
670670- loop {
671671- match rx.try_recv() {
672672- Ok(_) => continue,
673673- Err(broadcast::error::TryRecvError::Empty) => break,
674674- Err(broadcast::error::TryRecvError::Closed) => return,
675675- Err(broadcast::error::TryRecvError::Lagged(_)) => break,
676676- }
677677- }
678678- loop {
679679- match rx.recv().await {
680680- Ok(obj) => {
681681- let msg = encode_message(&MoqMessage::Object {
682682- track_id: obj.track_id as u64,
683683- group_id: obj.group_id,
684684- object_id: obj.object_id,
685685- payload: obj.payload,
686686- });
687687- if tx.send(msg).is_err() {
688688- break;
689689- }
690690- }
691691- Err(broadcast::error::RecvError::Lagged(_)) => {
692692- // Live media — skip missed objects.
693693- continue;
694694- }
695695- Err(broadcast::error::RecvError::Closed) => break,
696696- }
697697- }
698698- }));
699699- }
654654+ }
655655+ _ => {
656656+ // Video and audio use a dedicated WebSocket endpoint.
657657+ // Ignore subscribe requests on the control WS.
700658 }
701659 }
660660+ }
702661703662 MoqMessage::Object {
704663 track_id,
+21
src/types.rs
···7979 pub(crate) url: String,
8080 pub(crate) duration: String,
8181 pub(crate) thumbnail: Option<String>,
8282+ /// True while metadata is being extracted asynchronously.
8383+ #[serde(skip_serializing_if = "is_false")]
8484+ pub(crate) pending: bool,
8585+}
8686+8787+fn is_false(b: &bool) -> bool {
8888+ !*b
8289}
83908491#[derive(Debug, Clone, Serialize)]
···140147141148/// All room operations — processed sequentially by the per-room actor.
142149pub(crate) enum RoomCommand {
150150+ /// Track with already-resolved metadata (legacy path, used by tests).
151151+ #[allow(dead_code)]
143152 QueueTrack(TrackMeta),
153153+ /// Raw URL — metadata extraction happens asynchronously in a spawned task.
154154+ QueueUrl(String),
155155+ /// Async metadata extraction result.
156156+ MetadataReady {
157157+ item_id: i64,
158158+ meta: TrackMeta,
159159+ },
160160+ /// Async metadata extraction failed.
161161+ MetadataFailed {
162162+ item_id: i64,
163163+ error: String,
164164+ },
144165 Skip,
145166 /// Sent by the pipeline task when it finishes (or errors, or is aborted).
146167 TrackEnded {
+44-29
src/web.rs
···2233use std::collections::HashMap;
44use std::net::SocketAddr;
55-use std::path::PathBuf;
65use std::sync::Arc;
7687use askama::Template;
···18171918use crate::db;
2019use crate::room;
2121-use crate::source::SourceRegistry;
2220use crate::types::{RoomId, TrackMeta};
23212422/// Per-IP rate limiter for POST /api/ingest (max 1 req/5s per IP).
···5856pub(crate) struct AppState {
5957 pub rooms: room::Registry,
6058 pub pool: Pool<Sqlite>,
6161- pub cache_dir: PathBuf,
6262- pub sources: Arc<SourceRegistry>,
6359 pub rate_limiter: RateLimiter,
6460}
65616662pub(crate) fn router(
6763 rooms: room::Registry,
6864 pool: Pool<Sqlite>,
6969- cache_dir: PathBuf,
7070- sources: Arc<SourceRegistry>,
7165) -> Router {
7266 let rate_limiter = RateLimiter::new();
7367···8478 let state = AppState {
8579 rooms,
8680 pool,
8787- cache_dir,
8888- sources,
8981 rate_limiter,
9082 };
9183···9688 .route("/api/ingest", post(ingest_url))
9789 .route("/api/skip", post(skip_handler))
9890 .route("/ws/{room_id}", get(ws_handler))
9191+ .route("/ws/{room_id}/video", get(video_ws_handler))
9992 .with_state(state)
10093}
10194···188181 room_id: String,
189182}
190183191191-#[derive(serde::Serialize)]
192192-struct IngestResponse {
193193- title: String,
194194- duration: String,
195195- thumbnail: Option<String>,
196196-}
197197-198184fn err_response(status: StatusCode, msg: &str) -> axum::response::Response {
199185 (status, Json(serde_json::json!({ "error": msg }))).into_response()
200186}
···203189 State(state): State<AppState>,
204190 ConnectInfo(addr): ConnectInfo<SocketAddr>,
205191 axum::Json(req): axum::Json<IngestRequest>,
206206-) -> Result<Json<IngestResponse>, axum::response::Response> {
192192+) -> Result<Json<serde_json::Value>, axum::response::Response> {
207193 // Input validation — URL max 2048 characters.
208194 if req.url.len() > 2048 {
209195 return Err(err_response(StatusCode::BAD_REQUEST, "URL too long"));
···226212227213 let room_id = RoomId(req.room_id);
228214215215+ let t0 = std::time::Instant::now();
216216+229217 // Validate the room exists.
230218 if !state.rooms.exists(&room_id).await {
231219 return Err(err_response(StatusCode::NOT_FOUND, "room not found"));
232220 }
221221+ let t1 = t0.elapsed();
233222234234- // Extract metadata via the source registry.
235235- let meta = state
236236- .sources
237237- .extract(&req.url, &state.cache_dir)
238238- .await
239239- .map_err(|e| err_response(StatusCode::BAD_REQUEST, &e.to_string()))?;
223223+ // Push URL to room — metadata extraction runs async, no client wait.
224224+ state.rooms.push_url(&room_id, req.url).await;
225225+ let t2 = t0.elapsed();
240226241241- // Push to room queue.
242242- state.rooms.push(&room_id, meta.clone()).await;
227227+ tracing::info!(
228228+ "ingest_url timing: exists={:.0?} push_url={:.0?} total={:.0?}",
229229+ t1, t2 - t1, t2
230230+ );
243231244244- Ok(Json(IngestResponse {
245245- title: meta.title,
246246- duration: meta.duration,
247247- thumbnail: meta.thumbnail,
248248- }))
232232+ Ok(Json(serde_json::json!({ "ok": true })))
249233}
250234251235#[derive(serde::Deserialize)]
···321305 .await;
322306 }))
323307}
308308+309309+/// WebSocket upgrade endpoint for video-only delivery: `/ws/{room_id}/video`.
310310+///
311311+/// No MoQ protocol — server auto-subscribes to the video broadcast and
312312+/// forwards raw fMP4 payloads. The client identifies init segments by
313313+/// checking for the ftyp box in the data.
314314+async fn video_ws_handler(
315315+ ws: WebSocketUpgrade,
316316+ Path(room_id): Path<String>,
317317+ State(state): State<AppState>,
318318+) -> Result<impl IntoResponse, (StatusCode, &'static str)> {
319319+ if !room_id.chars().all(|c| c.is_ascii_digit()) {
320320+ return Err((StatusCode::BAD_REQUEST, "invalid room ID"));
321321+ }
322322+323323+ let rid = RoomId(room_id);
324324+325325+ if !state.rooms.exists(&rid).await {
326326+ return Err((StatusCode::NOT_FOUND, "room not found"));
327327+ }
328328+329329+ let handle = state
330330+ .rooms
331331+ .handle(&rid)
332332+ .await
333333+ .ok_or((StatusCode::NOT_FOUND, "room not found"))?;
334334+335335+ Ok(ws.on_upgrade(move |socket| async move {
336336+ crate::transport::handle_video_session(socket, handle.publishers).await;
337337+ }))
338338+}