Something went wrong. Try again.
experiments of a tiny cytube-like player with yt-dlp
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897//! Pure playback state machine: deterministic, testable, IO-free.//!//! Owns the in-memory queue, active track, and history. All transitions//! are pure functions returning [`Effect`]s that the caller executes.
use std::collections::HashMap;
use crate::store::PlaylistEntry;use crate::types::{ActiveTrackInfo, PlaylistEntryId, TrackId};
/// Resolved metadata for a media URL, cached in the state machine so a re-queued/// track with the same URL doesn't need to re-resolve.////// `Pending` is implicit — absence from [`PlaybackState::media_cache`] means the URL/// has not been resolved yet. In-flight downloads are tracked separately on the/// actor (they're tokio handles, can't be in pure state).#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) enum MediaStatus { Resolved { title: String, duration: String, thumbnail: Option<String>, source: String, }, Failed(String),}
/// A track waiting in the queue (has a DB id).#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct QueuedTrack { pub id: TrackId, pub title: String, pub url: String, pub duration: String, pub thumbnail: Option<String>, /// True while metadata is being extracted asynchronously. pub pending: bool,}
/// A finished track in history.#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct FinishedTrack { pub id: TrackId, pub title: String, pub url: String, pub duration: String, pub thumbnail: Option<String>, pub played_at: String,}
/// Events that drive the playback state machine.#[derive(Debug, Clone)]pub(crate) enum Event { /// A new track was added to the queue (with its DB-assigned id). TrackQueued(QueuedTrack), /// User requested to skip the current track. Skip, /// Frontend reported the track finished playing. TrackEnded { item_id: TrackId }, /// A download failed. Remove the track from wherever it sits and advance. TrackFailed { item_id: TrackId }, /// Asynchronous metadata extraction completed for a specific track. MetadataUpdated { item_id: TrackId, url: String, title: String, duration: String, thumbnail: Option<String>, source: String, }, /// Download finished: cache the URL as Resolved and update all queue/active /// tracks matching this URL in place. DownloadResolved { url: String, title: String, duration: String, thumbnail: Option<String>, source: String, }, /// Download failed: cache the URL as Failed. Does not modify tracks directly — /// the actor fans out per-track `MetadataUpdated` + `TrackFailed` events. DownloadFailed { url: String, error: String }, /// A track was queued by the perpetual playlist (vs. user request). /// `source_index` is the playlist position the entry came from, persisted /// so analytics can later attribute plays to specific playlist slots. TrackQueuedFromPlaylist(QueuedTrack, usize), /// Add (or replace) a perpetual playlist entry. PlaylistEntryAdded(PlaylistEntry), /// Remove a perpetual playlist entry by id. PlaylistEntryRemoved(PlaylistEntryId), /// Reorder a track within the up-next queue. /// `from` and `to` are 0-based queue indices. `from == to` is a no-op. /// Indices out of range or queue empty are no-ops. MoveQueueItem { from: usize, to: usize }, /// Shuffle the up-next queue deterministically. Same seed → same result. /// Empty/single-item queues are no-ops. Active track is not affected. ShuffleQueue { seed: u64 }, /// Remove a track from the up-next queue. No-op if the track isn't /// queued (active and history are unaffected). RemoveQueueItem { track_id: TrackId }, /// Toggle the room's auto-play setting. When `true`, idle rooms /// auto-fill from the perpetual playlist (also surfaced as the /// playlist panel in the UI). SetAutoplay { enabled: bool }, /// Set the skip-vote threshold as a percentage of the room size /// (0..=100). `votes * 100 >= threshold * count` triggers a skip. /// `percent > 100` is clamped to 100; values outside 0..=100 are no-ops. SetSkipThreshold { percent: u8 },}
/// Side effects to execute after a transition.////// The caller (RoomActor) must execute these in order.#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) enum Effect { /// Signal the current download to abort. AbortDownload, /// Mark a track as started in the DB (set started_playing_at). PersistStarted(TrackId), /// Mark a track as finished in the DB (set played=1, played_at). PersistFinished(TrackId), /// Broadcast the current state snapshot to all clients. PublishSnapshot, /// Persist a newly queued track to the store. PersistQueuedTrack(QueuedTrack), /// Persist metadata update after async extraction completes. /// `url` targets media_cache (canonical metadata store). PersistMetadata { item_id: TrackId, url: String, title: String, duration: String, thumbnail: Option<String>, source: String, }, /// Persist a chat message. PersistChat { user_name: String, content: String, msg_type: String, created_at: String, }, /// Persist a new perpetual playlist entry. AddPlaylistEntry { id: PlaylistEntryId, url: String, title: String, duration: String, thumbnail: Option<String>, source: String, }, /// Remove a playlist entry. RemovePlaylistEntry(PlaylistEntryId), /// Renumber queue positions to match `new_order`. Emitted by both /// move and shuffle operations; the store updates the `position` /// column for every queued track so a fresh `SELECT ... ORDER BY /// position` returns the new order. ReorderQueue { new_order: Vec<TrackId> }, /// Delete a queued track from the store. Emitted alongside a /// `PublishSnapshot` after the state machine removes the track from /// its in-memory `queue` Vec. RemoveQueuedTrack { track_id: TrackId }, /// Persist a change to the room's autoplay setting or skip-vote /// threshold. Carried together so the actor writes both columns in /// one UPDATE. PersistRoomSettings { autoplay_enabled: bool, skip_threshold: u8, },}
/// Pure playback state: no IO handles, no DB connections.#[derive(Debug, Clone)]pub(crate) struct PlaybackState { /// Currently playing track, if any. pub active: Option<ActiveTrackInfo>, /// Upcoming tracks (not yet started). pub queue: Vec<QueuedTrack>, /// Recently finished tracks (newest first). pub history: Vec<FinishedTrack>, /// Resolved metadata for URLs this room has encountered, keyed by URL. /// When a track is queued, the state machine looks up its URL here to fill /// in title/duration/thumbnail without re-resolving. pub media_cache: HashMap<String, MediaStatus>, /// Perpetual playlist: idle rooms auto-fill from this queue. pub playlist: Vec<PlaylistEntry>, /// Index of the next playlist entry to draw from (`next_playlist_entry`). pub playlist_cursor: usize, /// When true, the room auto-fills from the playlist when idle (and /// on client connect, as an actor-level trigger). Also gates the /// playlist panel in the UI. pub autoplay_enabled: bool, /// Skip-vote threshold as a percentage of the room size /// (0..=100). `votes * 100 >= threshold * count` triggers a skip /// (the actor counts votes; the state machine just stores this). pub skip_threshold: u8,}
/// Defaults for new rooms. `autoplay_enabled` and `skip_threshold` are/// the schema's column defaults too — keep them aligned.const DEFAULT_AUTOPLAY_ENABLED: bool = true;const DEFAULT_SKIP_THRESHOLD: u8 = 34;
impl Default for PlaybackState { fn default() -> Self { Self { active: None, queue: Vec::new(), history: Vec::new(), media_cache: HashMap::new(), playlist: Vec::new(), playlist_cursor: 0, autoplay_enabled: DEFAULT_AUTOPLAY_ENABLED, skip_threshold: DEFAULT_SKIP_THRESHOLD, } }}
impl PlaybackState { /// Pure transition function. /// /// Returns effects for the caller to execute. Mutates `self` in place. /// Does no I/O. Deterministic given the same inputs. pub fn transition(&mut self, event: &Event, now: i64) -> Vec<Effect> { match event { Event::TrackQueued(track) => self.handle_queued(track), Event::Skip => self.handle_skip(now), Event::TrackEnded { item_id } => self.handle_track_ended(*item_id, now), Event::TrackFailed { item_id } => self.handle_track_failed(*item_id, now), Event::MetadataUpdated { item_id, url, title, duration, thumbnail, source, } => self.handle_metadata_updated(*item_id, url, title, duration, thumbnail, source), Event::DownloadResolved { url, title, duration, thumbnail, source, } => self.handle_download_resolved(url, title, duration, thumbnail, source), Event::DownloadFailed { url, error } => self.handle_download_failed(url, error), Event::TrackQueuedFromPlaylist(track, _source_index) => self.handle_queued(track), Event::PlaylistEntryAdded(entry) => self.handle_playlist_entry_added(entry), Event::PlaylistEntryRemoved(id) => self.handle_playlist_entry_removed(*id), Event::SetAutoplay { enabled } => self.handle_set_autoplay(*enabled), Event::SetSkipThreshold { percent } => self.handle_set_skip_threshold(*percent), Event::MoveQueueItem { from, to } => self.handle_move_queue_item(*from, *to), Event::ShuffleQueue { seed } => self.handle_shuffle_queue(*seed), Event::RemoveQueueItem { track_id } => self.handle_remove_queue_item(*track_id), } }
/// A track was added to the queue. fn handle_queued(&mut self, track: &QueuedTrack) -> Vec<Effect> { // Resolve placeholder values from media_cache if the URL has been // resolved before. The actor can always send a placeholder track — // the state machine fills in real metadata when available. let track = self.fill_from_cache(track);
self.queue.push(track.clone());
if self.active.is_some() { return vec![ Effect::PersistQueuedTrack(track.clone()), Effect::PublishSnapshot, ]; }
// Idle: persist the track first, then advance it to playing. let mut effects = vec![Effect::PersistQueuedTrack(track.clone())]; effects.extend(self.advance(track.clone())); effects }
/// If `media_cache` has resolved metadata for this track's URL, return a /// new track with `pending = false` and the cached title/duration/thumbnail. /// Otherwise return the track unchanged. fn fill_from_cache(&self, track: &QueuedTrack) -> QueuedTrack { match self.media_cache.get(&track.url) { Some(MediaStatus::Resolved { title, duration, thumbnail, .. }) => QueuedTrack { id: track.id, title: title.clone(), url: track.url.clone(), duration: duration.clone(), thumbnail: thumbnail.clone(), pending: false, }, // Failed or absent: keep placeholder, pending stays true. _ => track.clone(), } }
/// Mirror of [`fill_from_cache`] for playlist entries. If the URL is /// already in media_cache as Resolved, return a new entry with the /// cached title/duration/thumbnail and `pending = false`. Otherwise /// return the entry unchanged. fn fill_playlist_from_cache(&self, entry: &PlaylistEntry) -> PlaylistEntry { match self.media_cache.get(&entry.url) { Some(MediaStatus::Resolved { title, duration, thumbnail, .. }) => PlaylistEntry { id: entry.id, url: entry.url.clone(), title: title.clone(), duration: duration.clone(), thumbnail: thumbnail.clone(), source: entry.source.clone(), added_by: entry.added_by.clone(), added_at: entry.added_at.clone(), pending: false, failed: None, }, // Failed or absent: keep placeholder, pending stays true. _ => entry.clone(), } }
/// User requested skip. fn handle_skip(&mut self, _now: i64) -> Vec<Effect> { self.finish_active_and_advance(true) }
/// Frontend reported the current track finished playing. fn handle_track_ended(&mut self, item_id: TrackId, _now: i64) -> Vec<Effect> { // Only react if this matches the currently active track. let is_current = self.active.as_ref().is_some_and(|t| t.id == item_id); if !is_current { return Vec::new(); // stale event, ignore } self.finish_active_and_advance(false) }
/// Shared logic: move the active track to history, then advance the next /// queued track (if any). `abort` controls whether `AbortDownload` is emitted. fn finish_active_and_advance(&mut self, abort: bool) -> Vec<Effect> { let mut effects = Vec::new();
let Some(active) = self.active.take() else { return effects; };
if abort { effects.push(Effect::AbortDownload); } effects.push(Effect::PersistFinished(active.id)); self.history.insert( 0, FinishedTrack { id: active.id, title: active.title, url: active.url, duration: active.duration, thumbnail: active.thumbnail, // RoomActor overrides via PersistFinished handler which sets // the real played_at timestamp in the DB. played_at: String::new(), }, );
if let Some(next) = self.queue.first().cloned() { effects.extend(self.advance(next)); } else { effects.push(Effect::PublishSnapshot); }
effects }
/// A download failed. Remove the track from wherever it sits (active or /// queue) and advance if it was the currently playing track. fn handle_track_failed(&mut self, item_id: TrackId, _now: i64) -> Vec<Effect> { if self.active.as_ref().is_some_and(|t| t.id == item_id) { return self.finish_active_and_advance(true); }
if self.queue.iter().any(|t| t.id == item_id) { self.queue.retain(|t| t.id != item_id); return vec![Effect::PublishSnapshot]; }
Vec::new() }
/// Asynchronous metadata resolution completed. Updates the matching item /// in the queue and/or the active track in-place. Does not change playback /// state, only updates display fields. fn handle_metadata_updated( &mut self, item_id: TrackId, url: &str, title: &str, duration: &str, thumbnail: &Option<String>, source: &str, ) -> Vec<Effect> { if let Some(item) = self.queue.iter_mut().find(|t| t.id == item_id) { item.title = title.to_string(); item.duration = duration.to_string(); item.thumbnail.clone_from(thumbnail); item.pending = false; } if let Some(ref mut active) = self.active { if active.id == item_id { active.title = title.to_string(); active.duration = duration.to_string(); active.thumbnail.clone_from(thumbnail); } } vec![ Effect::PersistMetadata { item_id, url: url.to_string(), title: title.to_string(), duration: duration.to_string(), thumbnail: thumbnail.clone(), source: source.to_string(), }, Effect::PublishSnapshot, ] }
/// Download completed: update ALL queued and active tracks matching `url`. /// Produces a single PersistMetadata effect (media_cache is URL-keyed) /// and a PublishSnapshot to broadcast the updated metadata. /// Cache the URL as Resolved and update all matching queue/active tracks. /// Writes the result to `self.media_cache` so future `TrackQueued` events for /// the same URL can pre-fill from cache. fn handle_download_resolved( &mut self, url: &str, title: &str, duration: &str, thumbnail: &Option<String>, source: &str, ) -> Vec<Effect> { // 1. Update media_cache. self.media_cache.insert( url.to_string(), MediaStatus::Resolved { title: title.to_string(), duration: duration.to_string(), thumbnail: thumbnail.clone(), source: source.to_string(), }, );
// 2. Update all matching queue tracks. for item in &mut self.queue { if item.url == url { item.title = title.to_string(); item.duration = duration.to_string(); item.thumbnail.clone_from(thumbnail); item.pending = false; } }
// 3. Update active track if URL matches. if let Some(ref mut active) = self.active { if active.url == url { active.title = title.to_string(); active.duration = duration.to_string(); active.thumbnail.clone_from(thumbnail); } }
// 4. Update all matching playlist entries. Without this, a fresh // download for a never-before-seen URL would leave the playlist // entry stuck on "Loading..." — the cache is keyed by URL, so // resolving it should fan out to every playlist entry that // shares the URL. for entry in &mut self.playlist { if entry.url == url { entry.title = title.to_string(); entry.duration = duration.to_string(); entry.thumbnail.clone_from(thumbnail); entry.pending = false; } }
vec![ Effect::PersistMetadata { // item_id unused — media_cache is keyed by url item_id: TrackId::nil(), url: url.to_string(), title: title.to_string(), duration: duration.to_string(), thumbnail: thumbnail.clone(), source: source.to_string(), }, Effect::PublishSnapshot, ] }
/// Cache the URL as Failed. Does not modify queue/active tracks directly /// — the actor fans out per-track `MetadataUpdated` + `TrackFailed` /// events for removal. Playlist entries are updated here so a bad URL /// in the playlist stops showing "Loading..." with no indication that /// it failed. We also fire `PersistMetadata` so the failure title /// lands in `media_cache` and survives rehydration (the playlist load /// JOINs media_cache to compute title + pending). fn handle_download_failed(&mut self, url: &str, error: &str) -> Vec<Effect> { self.media_cache .insert(url.to_string(), MediaStatus::Failed(error.to_string())); let failed_title = format!("Failed to load: {error}"); let mut changed = false; for entry in &mut self.playlist { if entry.url == url && entry.pending { entry.title = failed_title.clone(); entry.duration = "--:--".into(); entry.thumbnail = None; entry.pending = false; entry.failed = Some(error.to_string()); changed = true; } } if changed { vec![ Effect::PersistMetadata { item_id: TrackId::nil(), url: url.to_string(), title: failed_title, duration: "--:--".into(), thumbnail: None, source: "ytdlp".into(), }, Effect::PublishSnapshot, ] } else { Vec::new() } }
/// Add a playlist entry, replacing any existing entry with the same URL. /// Cursor unchanged: existing slot stays current. fn handle_playlist_entry_added(&mut self, entry: &PlaylistEntry) -> Vec<Effect> { // If the URL is already in media_cache as Resolved, fill the // placeholder with cached metadata and mark it ready. This avoids // a "Loading..." entry that never updates — the common case is // adding a track to the playlist from history, where the URL was // already resolved when it was originally queued. let entry = self.fill_playlist_from_cache(entry); if let Some(slot) = self.playlist.iter().position(|e| e.url == entry.url) { self.playlist[slot] = entry.clone(); } else { self.playlist.push(entry.clone()); } // Enable by default when the first entry is added. if !self.autoplay_enabled { self.autoplay_enabled = true; } vec![ Effect::AddPlaylistEntry { id: entry.id, url: entry.url.clone(), title: entry.title.clone(), duration: entry.duration.clone(), thumbnail: entry.thumbnail.clone(), source: entry.source.clone(), }, Effect::PublishSnapshot, ] }
/// Remove a playlist entry. Clamp the cursor so it doesn't point past the end. fn handle_playlist_entry_removed(&mut self, id: PlaylistEntryId) -> Vec<Effect> { let prev_len = self.playlist.len(); self.playlist.retain(|e| e.id != id); if self.playlist.len() != prev_len { if self.playlist_cursor >= self.playlist.len() && !self.playlist.is_empty() { self.playlist_cursor = self.playlist.len() - 1; } if self.playlist.is_empty() { self.autoplay_enabled = false; } return vec![Effect::RemovePlaylistEntry(id), Effect::PublishSnapshot]; } Vec::new() }
/// Toggle the perpetual playlist on/off. Disabling is always a no-op for state. /// Re-enabling only takes effect if there's something to play. fn handle_set_autoplay(&mut self, enabled: bool) -> Vec<Effect> { if enabled && self.playlist.is_empty() { // Re-enabling an empty playlist: leave it disabled, no-op. return Vec::new(); } if self.autoplay_enabled == enabled { return Vec::new(); } self.autoplay_enabled = enabled; // Persist the new settings + publish so clients see the toggle. // (The empty-Playlist re-enable guard above means we never // reach here when the playlist is empty, so this fires only // for a real state change.) vec![ Effect::PersistRoomSettings { autoplay_enabled: self.autoplay_enabled, skip_threshold: self.skip_threshold, }, Effect::PublishSnapshot, ] }
/// Set the skip-vote threshold as a percentage of room size. Out of /// range values are clamped to 0..=100 (0 means any single vote /// skips; 100 means unanimous). Persists + publishes on change. fn handle_set_skip_threshold(&mut self, percent: u8) -> Vec<Effect> { let percent = percent.min(100); if self.skip_threshold == percent { return Vec::new(); } self.skip_threshold = percent; vec![ Effect::PersistRoomSettings { autoplay_enabled: self.autoplay_enabled, skip_threshold: self.skip_threshold, }, Effect::PublishSnapshot, ] }
/// Pop the next playlist entry: returns the entry at the cursor and advances /// the cursor. Returns `None` when disabled, empty, or past the end. /// /// The actor converts the returned entry into a `TrackQueuedFromPlaylist` event. /// Keeping this as a separate step (helper + event) means the state machine /// doesn't need a "draw from playlist" event that mutates two fields in one go. pub(crate) fn next_playlist_entry(&mut self) -> Option<PlaylistEntry> { if !self.autoplay_enabled { return None; } let entry = self.playlist.get(self.playlist_cursor)?.clone(); self.playlist_cursor = self.playlist_cursor.saturating_add(1); if self.playlist_cursor >= self.playlist.len() { self.playlist_cursor = 0; } Some(entry) }
/// Reorder a track within the up-next queue. `from` and `to` are /// 0-based queue indices. The active track is not affected. No-op if /// either index is out of range, the queue is empty, or `from == to`. fn handle_move_queue_item(&mut self, from: usize, to: usize) -> Vec<Effect> { if from == to || from >= self.queue.len() || to >= self.queue.len() { return Vec::new(); } let track = self.queue.remove(from); self.queue.insert(to, track); let new_order: Vec<TrackId> = self.queue.iter().map(|t| t.id).collect(); vec![Effect::ReorderQueue { new_order }, Effect::PublishSnapshot] }
/// Shuffle the up-next queue deterministically. Same seed → same order. /// Fisher-Yates with a splitmix64 PRNG seeded by the caller. /// No-op for queues with fewer than 2 items. fn handle_shuffle_queue(&mut self, seed: u64) -> Vec<Effect> { if self.queue.len() < 2 { return Vec::new(); } let mut rng = SplitMix64::new(seed); let n = self.queue.len(); // Fisher-Yates: walk from the end, swap each element with a random earlier one. for i in (1..n).rev() { let j = (rng.next() as usize) % (i + 1); self.queue.swap(i, j); } let new_order: Vec<TrackId> = self.queue.iter().map(|t| t.id).collect(); vec![Effect::ReorderQueue { new_order }, Effect::PublishSnapshot] }
/// Remove a track from the up-next queue. The active track and history /// are unaffected. No-op (returns empty) if the track isn't in the /// queue. After removal, the remaining queue positions are renumbered /// via `Effect::ReorderQueue` so the store stays in sync. fn handle_remove_queue_item(&mut self, track_id: TrackId) -> Vec<Effect> { let pos = self.queue.iter().position(|t| t.id == track_id); let Some(_) = pos else { return Vec::new(); }; self.queue.retain(|t| t.id != track_id); let new_order: Vec<TrackId> = self.queue.iter().map(|t| t.id).collect(); vec![ Effect::RemoveQueuedTrack { track_id }, Effect::ReorderQueue { new_order }, Effect::PublishSnapshot, ] }
/// Pop the first track from the queue and start playing it. /// /// Returns effects for starting the pipeline and publishing state. fn advance(&mut self, next: QueuedTrack) -> Vec<Effect> { self.queue.retain(|t| t.id != next.id);
// Store active track with placeholder timestamp. RoomActor calls // `resolve_started_at` with the real wall-clock time. let info = ActiveTrackInfo { id: next.id, title: next.title.clone(), url: next.url.clone(), duration: next.duration.clone(), thumbnail: next.thumbnail.clone(), started_at_wall: 0, }; self.active = Some(info);
vec![Effect::PersistStarted(next.id), Effect::PublishSnapshot] }
/// Replace the placeholder `started_at_wall` on the active track with /// the real timestamp. Called by RoomActor when it executes StartPipeline. pub fn resolve_started_at(&mut self, real_started_at: i64) { if let Some(ref mut active) = self.active { active.started_at_wall = real_started_at; } }
/// Reconstruct state from a [`RoomSnapshot`] loaded from the store. /// /// If there's a stale active track (server died mid-playback), it's kept /// as the active track with a placeholder `started_at_wall = 0`. The actor /// resolves it to the current time when it publishes the initial snapshot. pub fn from_snapshot(snapshot: crate::store::RoomSnapshot) -> (Self, Vec<Effect>) { let mut state = PlaybackState::default(); let mut effects = Vec::new();
state.queue = snapshot.queue;
// Stale active track → keep playing (file is cached on disk) if let Some(stale) = snapshot.active { state.active = Some(ActiveTrackInfo { id: stale.id, title: stale.title, url: stale.url, duration: stale.duration, thumbnail: stale.thumbnail, started_at_wall: 0, }); effects.push(Effect::PersistStarted(stale.id)); }
// Append existing history state.history.extend(snapshot.history);
// Restore perpetual playlist. If there are entries, the playlist is // enabled by default (no way to persist "disabled" yet, so we treat // a non-empty playlist as the user's intent to autoplay). state.playlist = snapshot.playlist; // Room settings come from the snapshot (DB row), not from // defaults. The "force autoplay if playlist is non-empty" // override is gone — the user can now turn autoplay off even // with a non-empty playlist. state.autoplay_enabled = snapshot.autoplay_enabled; state.skip_threshold = snapshot.skip_threshold;
if !effects.is_empty() { effects.push(Effect::PublishSnapshot); }
(state, effects) }}
/// Tiny splitmix64 PRNG — deterministic, no dependencies, good enough for/// shuffling a user-visible queue. Not cryptographic.struct SplitMix64(u64);
impl SplitMix64 { fn new(seed: u64) -> Self { Self(seed) }
fn next(&mut self) -> u64 { self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15); let mut z = self.0; z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); z ^ (z >> 31) }}
/// # Pure unit tests (no async, no DB, no IO)#[cfg(test)]mod tests { use super::*;
fn track_id(n: u128) -> TrackId { TrackId(uuid::Uuid::from_u128(n)) }
fn queued(title: &str, n: u128) -> QueuedTrack { QueuedTrack { id: track_id(n), title: title.into(), url: format!("https://example.com/{title}"), duration: "3:45".into(), thumbnail: None, pending: false, } }
#[test] fn test_idle_to_playing_effect_order() { let mut s = PlaybackState::default(); let effects = s.transition(&Event::TrackQueued(queued("A", 1)), 0);
assert_eq!( effects, vec![ Effect::PersistQueuedTrack(queued("A", 1)), Effect::PersistStarted(track_id(1)), Effect::PublishSnapshot, ], "idle → first track: persist queued, started_at, notify clients" ); }
#[test] fn test_queue_second_while_playing_effect_order() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); let effects = s.transition(&Event::TrackQueued(queued("B", 2)), 0);
assert_eq!( effects, vec![ Effect::PersistQueuedTrack(queued("B", 2)), Effect::PublishSnapshot ], "playing -> queue another: persist track, notify, pipeline untouched" ); }
#[test] fn test_skip_with_next_effect_order() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition(&Event::TrackQueued(queued("B", 2)), 0); let effects = s.transition(&Event::Skip, 1_700_000_000_001);
assert_eq!( &effects[..3], &[ Effect::AbortDownload, Effect::PersistFinished(track_id(1)), Effect::PersistStarted(track_id(2)), ], "skip with next: abort download, finalize A, start B" ); assert!( effects.contains(&Effect::PublishSnapshot), "skip with next: must notify clients" ); }
#[test] fn test_skip_last_track_effect_order() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); let effects = s.transition(&Event::Skip, 0);
assert_eq!( effects, vec![ Effect::AbortDownload, Effect::PersistFinished(track_id(1)), Effect::PublishSnapshot, ], "skip last: abort, finalize, notify" ); }
#[test] fn test_track_ended_last_goes_idle_effect_order() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); let effects = s.transition( &Event::TrackEnded { item_id: track_id(1), }, 0, );
assert_eq!( effects, vec![ Effect::PersistFinished(track_id(1)), Effect::PublishSnapshot, ], "track ended last: finalize, notify" ); }
#[test] fn test_idle_room_starts_playing_on_first_track() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0);
assert!(s.active.is_some()); assert_eq!(s.active.as_ref().unwrap().title, "A"); assert!(s.queue.is_empty()); }
#[test] fn test_second_track_stays_in_queue() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition(&Event::TrackQueued(queued("B", 2)), 0);
assert_eq!(s.queue.len(), 1); assert_eq!(s.queue[0].title, "B"); assert_eq!(s.active.as_ref().unwrap().title, "A"); }
#[test] fn test_skip_advances_to_next_track() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition(&Event::TrackQueued(queued("B", 2)), 0); s.transition(&Event::Skip, 1_700_000_000_001);
assert_eq!(s.active.as_ref().unwrap().title, "B"); assert!(s.queue.is_empty()); assert_eq!(s.history.len(), 1); assert_eq!(s.history[0].title, "A"); }
#[test] fn test_skip_on_idle_does_nothing() { let mut s = PlaybackState::default(); let effects = s.transition(&Event::Skip, 0);
assert!(s.active.is_none()); assert!(effects.is_empty()); }
#[test] fn test_skip_last_track_goes_idle() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition(&Event::Skip, 0);
assert!(s.active.is_none()); assert_eq!(s.history.len(), 1); assert_eq!(s.history[0].title, "A"); }
#[test] fn test_track_ended_with_next_effect_order() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition(&Event::TrackQueued(queued("B", 2)), 0); let effects = s.transition( &Event::TrackEnded { item_id: track_id(1), }, 0, );
assert_eq!( &effects[..2], &[ Effect::PersistFinished(track_id(1)), Effect::PersistStarted(track_id(2)), ], "track ended (next): finalize A, start B, no AbortDownload, no re-persist queued" ); assert!( effects.contains(&Effect::PublishSnapshot), "track ended (next): must notify" ); }
#[test] fn test_track_ended_ignores_stale_id() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0);
let effects = s.transition( &Event::TrackEnded { item_id: track_id(999), }, 0, );
assert!(s.active.is_some()); assert_eq!(s.active.as_ref().unwrap().title, "A"); assert!(effects.is_empty()); }
#[test] fn test_track_ended_last_track_goes_idle() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition( &Event::TrackEnded { item_id: track_id(1), }, 0, );
assert!(s.active.is_none()); assert_eq!(s.history.len(), 1); }
#[test] fn test_resolve_started_at_updates_active_track() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0);
s.resolve_started_at(42); assert_eq!(s.active.as_ref().unwrap().started_at_wall, 42); }
#[test] fn test_skip_then_track_ended_stale_is_ignored() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition(&Event::TrackQueued(queued("B", 2)), 0); s.transition(&Event::Skip, 0); assert_eq!(s.active.as_ref().unwrap().title, "B");
let effects = s.transition( &Event::TrackEnded { item_id: track_id(1), }, 0, ); assert!(effects.is_empty()); assert_eq!(s.active.as_ref().unwrap().title, "B"); }
#[test] fn test_resolve_started_at_noop_when_idle() { let mut s = PlaybackState::default(); s.resolve_started_at(42); assert!(s.active.is_none()); }
#[test] fn test_metadata_update_updates_queue_item() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); let effects = s.transition( &Event::MetadataUpdated { item_id: track_id(1), url: "https://example.com/A".into(), title: "Track A (Real)".into(), duration: "4:20".into(), thumbnail: Some("https://img.example/a.jpg".into()), source: "ytdlp".into(), }, 0, ); assert_eq!( effects, vec![ Effect::PersistMetadata { item_id: track_id(1), url: "https://example.com/A".into(), title: "Track A (Real)".into(), duration: "4:20".into(), thumbnail: Some("https://img.example/a.jpg".into()), source: "ytdlp".into(), }, Effect::PublishSnapshot, ] );
// Queue should be empty (track was advanced to active), but active // should have updated metadata. assert_eq!(s.active.as_ref().unwrap().title, "Track A (Real)"); assert_eq!(s.active.as_ref().unwrap().duration, "4:20"); assert_eq!( s.active.as_ref().unwrap().thumbnail, Some("https://img.example/a.jpg".into()) ); }
#[test] fn test_metadata_update_on_queued_item() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition(&Event::TrackQueued(queued("B", 2)), 0);
// Update the queued item (B, id=2). let effects = s.transition( &Event::MetadataUpdated { item_id: track_id(2), url: "https://example.com/B".into(), title: "Track B (Real)".into(), duration: "5:55".into(), thumbnail: None, source: "ytdlp".into(), }, 0, ); assert_eq!( effects, vec![ Effect::PersistMetadata { item_id: track_id(2), url: "https://example.com/B".into(), title: "Track B (Real)".into(), duration: "5:55".into(), thumbnail: None, source: "ytdlp".into(), }, Effect::PublishSnapshot, ] ); assert_eq!(s.queue[0].title, "Track B (Real)"); assert!(!s.queue[0].pending); }
#[test] fn test_metadata_update_on_unknown_id_is_noop() { let mut s = PlaybackState::default(); let effects = s.transition( &Event::MetadataUpdated { item_id: track_id(999), url: "https://example.com/Ghost".into(), title: "Ghost".into(), duration: "0:00".into(), thumbnail: None, source: "ytdlp".into(), }, 0, ); // Still publishes snapshot even if nothing changed: clients may want // to know the update was processed. assert_eq!( effects, vec![ Effect::PersistMetadata { item_id: track_id(999), url: "https://example.com/Ghost".into(), title: "Ghost".into(), duration: "0:00".into(), thumbnail: None, source: "ytdlp".into(), }, Effect::PublishSnapshot, ] ); assert!(s.active.is_none()); assert!(s.queue.is_empty()); }
// --- TrackFailed tests ---
#[test] fn test_track_failed_removes_from_queue() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition(&Event::TrackQueued(queued("B", 2)), 0); // B is in queue, behind active A. let effects = s.transition( &Event::TrackFailed { item_id: track_id(2), }, 0, );
assert!(!s.queue.iter().any(|t| t.id == track_id(2))); assert_eq!(effects, vec![Effect::PublishSnapshot]); // A still active; B removed. assert_eq!(s.active.as_ref().unwrap().title, "A"); }
#[test] fn test_track_failed_while_active_acts_like_skip() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); // A is now active. let effects = s.transition( &Event::TrackFailed { item_id: track_id(1), }, 0, );
assert_eq!( effects, vec![ Effect::AbortDownload, Effect::PersistFinished(track_id(1)), Effect::PublishSnapshot, ] ); assert!(s.active.is_none()); assert_eq!(s.history.len(), 1); assert_eq!(s.history[0].title, "A"); }
#[test] fn test_track_failed_active_with_next_advances() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition(&Event::TrackQueued(queued("B", 2)), 0); // A active, B queued. let effects = s.transition( &Event::TrackFailed { item_id: track_id(1), }, 0, );
assert_eq!(s.active.as_ref().unwrap().title, "B"); assert!(s.queue.is_empty()); assert_eq!(s.history.len(), 1); assert_eq!(s.history[0].title, "A"); // PersistFinished for A, PersistStarted for B, PublishSnapshot. assert!(effects.contains(&Effect::AbortDownload)); assert!(effects.contains(&Effect::PersistFinished(track_id(1)))); assert!(effects.contains(&Effect::PersistStarted(track_id(2)))); assert!(effects.contains(&Effect::PublishSnapshot)); }
#[test] fn test_track_failed_stale_id_noop() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); let effects = s.transition( &Event::TrackFailed { item_id: track_id(999), }, 0, );
assert!(effects.is_empty()); assert_eq!(s.active.as_ref().unwrap().title, "A"); }
#[test] fn test_track_failed_idle_noop() { let mut s = PlaybackState::default(); let effects = s.transition( &Event::TrackFailed { item_id: track_id(1), }, 0, );
assert!(effects.is_empty()); assert!(s.active.is_none()); assert!(s.queue.is_empty()); }
#[test] fn test_download_resolved_updates_all_matching_by_url() { // Four tracks: first advances to active (different URL), three in queue // where two share the same URL. let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("Active", 1)), 0); let mut q1 = queued("B", 2); q1.url = "https://example.com/shared".into(); q1.pending = true; s.transition(&Event::TrackQueued(q1), 0); let mut qc = queued("C-other", 3); qc.pending = true; s.transition(&Event::TrackQueued(qc), 0); let mut q2 = queued("D", 4); q2.url = "https://example.com/shared".into(); q2.pending = true; s.transition(&Event::TrackQueued(q2), 0);
// Queue: [B (shared), C (other), D (shared)] — indices 0, 1, 2.
let effects = s.transition( &Event::DownloadResolved { url: "https://example.com/shared".into(), title: "Real Title".into(), duration: "3:30".into(), thumbnail: Some("https://img.example/thumb.jpg".into()), source: "ytdlp".into(), }, 0, );
// Matching queue tracks (B at 0, D at 2) updated. assert_eq!(s.queue[0].title, "Real Title"); assert_eq!(s.queue[0].duration, "3:30"); assert!(!s.queue[0].pending);
assert_eq!(s.queue[2].title, "Real Title"); assert_eq!(s.queue[2].duration, "3:30"); assert!(!s.queue[2].pending);
// Non-matching queue track (C at 1) unchanged. assert_eq!(s.queue[1].title, "C-other"); assert!(s.queue[1].pending);
// Active track has different URL, should be unchanged. assert_eq!(s.active.as_ref().unwrap().title, "Active");
// Cache populated, snapshot published. assert!(matches!( s.media_cache.get("https://example.com/shared"), Some(crate::state::MediaStatus::Resolved { .. }) )); assert!(effects.contains(&Effect::PublishSnapshot)); }
#[test] fn test_download_resolved_updates_active_track() { let mut s = PlaybackState::default(); // Queue one track — room is idle, so it advances to active. s.transition(&Event::TrackQueued(queued("Active", 1)), 0); s.active.as_mut().unwrap().url = "https://example.com/active".into();
s.transition( &Event::DownloadResolved { url: "https://example.com/active".into(), title: "Resolved Active".into(), duration: "2:00".into(), thumbnail: None, source: "ytdlp".into(), }, 0, );
assert_eq!(s.active.as_ref().unwrap().title, "Resolved Active"); assert_eq!(s.active.as_ref().unwrap().duration, "2:00"); }
// --- media_cache tests ---
#[test] fn test_download_resolved_populates_media_cache() { let mut s = PlaybackState::default(); s.transition( &Event::DownloadResolved { url: "https://example.com/x".into(), title: "Real Title".into(), duration: "3:00".into(), thumbnail: Some("https://img/x.jpg".into()), source: "ytdlp".into(), }, 0, ); assert_eq!( s.media_cache.get("https://example.com/x"), Some(&MediaStatus::Resolved { title: "Real Title".into(), duration: "3:00".into(), thumbnail: Some("https://img/x.jpg".into()), source: "ytdlp".into(), }) ); }
#[test] fn test_download_resolved_updates_matching_queue_tracks() { let mut s = PlaybackState::default(); // First track advances to active. s.transition(&Event::TrackQueued(queued("Active", 1)), 0); // Two queued tracks sharing a URL. let mut q1 = queued("B-pending", 2); q1.url = "https://example.com/shared".into(); q1.pending = true; s.transition(&Event::TrackQueued(q1), 0); s.transition(&Event::TrackQueued(queued("C-other", 3)), 0); let mut q2 = queued("D-pending", 4); q2.url = "https://example.com/shared".into(); q2.pending = true; s.transition(&Event::TrackQueued(q2), 0);
s.transition( &Event::DownloadResolved { url: "https://example.com/shared".into(), title: "Shared Title".into(), duration: "4:20".into(), thumbnail: Some("https://img/shared.jpg".into()), source: "ytdlp".into(), }, 0, );
// Both B and D in queue now have resolved metadata. assert_eq!(s.queue[0].title, "Shared Title"); assert_eq!(s.queue[0].duration, "4:20"); assert!(!s.queue[0].pending); assert_eq!(s.queue[2].title, "Shared Title"); assert!(!s.queue[2].pending); // C (different URL) is unchanged. assert_eq!(s.queue[1].title, "C-other"); }
#[test] fn test_download_resolved_updates_active_track_when_url_matches() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("Active", 1)), 0); s.active.as_mut().unwrap().url = "https://example.com/active".into();
s.transition( &Event::DownloadResolved { url: "https://example.com/active".into(), title: "Resolved".into(), duration: "2:00".into(), thumbnail: None, source: "ytdlp".into(), }, 0, );
assert_eq!(s.active.as_ref().unwrap().title, "Resolved"); assert_eq!(s.active.as_ref().unwrap().duration, "2:00"); }
#[test] fn test_download_failed_marks_cache_failed() { let mut s = PlaybackState::default(); s.transition( &Event::DownloadFailed { url: "https://example.com/bad".into(), error: "404".into(), }, 0, ); assert_eq!( s.media_cache.get("https://example.com/bad"), Some(&MediaStatus::Failed("404".into())) ); }
#[test] fn test_download_resolved_overwrites_failed() { let mut s = PlaybackState::default(); s.transition( &Event::DownloadFailed { url: "https://example.com/x".into(), error: "transient".into(), }, 0, ); s.transition( &Event::DownloadResolved { url: "https://example.com/x".into(), title: "Recovered".into(), duration: "1:00".into(), thumbnail: None, source: "ytdlp".into(), }, 0, ); assert!(matches!( s.media_cache.get("https://example.com/x"), Some(MediaStatus::Resolved { .. }) )); }
#[test] fn test_track_queued_uses_cached_metadata() { // The headline behavior: re-queuing a URL that's been resolved // returns a fully-formed track, not a placeholder. let mut s = PlaybackState::default(); let mut t = QueuedTrack { id: track_id(1), title: "Loading...".into(), url: "https://example.com/cached".into(), duration: "--:--".into(), thumbnail: None, pending: true, }; s.transition(&Event::TrackQueued(t.clone()), 0); s.transition( &Event::DownloadResolved { url: "https://example.com/cached".into(), title: "Real".into(), duration: "5:00".into(), thumbnail: Some("https://img/real.jpg".into()), source: "ytdlp".into(), }, 0, ); // Now queue another track with the same URL. t.id = track_id(2); t.title = "Loading...".into(); t.duration = "--:--".into(); t.thumbnail = None; t.pending = true; let effects = s.transition(&Event::TrackQueued(t.clone()), 1); // The track in the queue should be the resolved one. assert_eq!(s.queue.last().unwrap().title, "Real"); assert_eq!(s.queue.last().unwrap().duration, "5:00"); assert!(!s.queue.last().unwrap().pending); // And the active track (track 1) shouldn't be affected by this. assert_eq!(s.active.as_ref().unwrap().title, "Real"); // Sanity: the persist effect should carry the resolved track. assert!(effects .iter() .any(|e| matches!(e, Effect::PersistQueuedTrack(q) if q.title == "Real"))); }
#[test] fn test_track_queued_keeps_placeholder_after_failed_download() { let mut s = PlaybackState::default(); s.transition( &Event::DownloadFailed { url: "https://example.com/x".into(), error: "500".into(), }, 0, ); let t = QueuedTrack { id: track_id(1), title: "Loading...".into(), url: "https://example.com/x".into(), duration: "--:--".into(), thumbnail: None, pending: true, }; s.transition(&Event::TrackQueued(t), 0); // Active track is the just-queued one. Placeholder preserved. assert_eq!(s.active.as_ref().unwrap().title, "Loading..."); assert_eq!(s.active.as_ref().unwrap().duration, "--:--"); }
// --- playlist tests ---
fn playlist_entry(id: u64, url: &str) -> PlaylistEntry { PlaylistEntry { id: PlaylistEntryId(uuid::Uuid::from_u64_pair(0, id)), url: url.into(), title: format!("P-{id}"), duration: "1:00".into(), thumbnail: None, source: "user".into(), added_by: None, added_at: "2026-01-01T00:00:00Z".into(), pending: false, failed: None, } }
/// Build a placeholder entry as the actor would: "Loading..." with /// `pending: true`. This is the entry shape that previously got stuck /// when its URL was already in media_cache as Resolved. fn placeholder_playlist_entry(id: u64, url: &str) -> PlaylistEntry { PlaylistEntry { id: PlaylistEntryId(uuid::Uuid::from_u64_pair(0, id)), url: url.into(), title: "Loading...".into(), duration: "--:--".into(), thumbnail: None, source: "direct".into(), added_by: None, added_at: "2026-01-01T00:00:00Z".into(), pending: true, failed: None, } }
#[test] fn test_playlist_add_appends_and_enables() { let mut s = PlaybackState::default(); let e = playlist_entry(1, "https://example.com/p1"); let effects = s.transition(&Event::PlaylistEntryAdded(e.clone()), 0); assert_eq!(s.playlist.len(), 1); assert!(s.autoplay_enabled); assert!(effects .iter() .any(|e| matches!(e, Effect::AddPlaylistEntry { .. }))); assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); }
#[test] fn test_playlist_add_replaces_same_url() { let mut s = PlaybackState::default(); s.transition( &Event::PlaylistEntryAdded(playlist_entry(1, "https://example.com/p1")), 0, ); // Same URL, different id: should replace, not append. s.transition( &Event::PlaylistEntryAdded(playlist_entry(2, "https://example.com/p1")), 0, ); assert_eq!(s.playlist.len(), 1); assert_eq!(s.playlist[0].id.0, uuid::Uuid::from_u64_pair(0, 2)); }
#[test] fn test_playlist_remove_filters_and_clamps_cursor() { let mut s = PlaybackState::default(); s.transition( &Event::PlaylistEntryAdded(playlist_entry(1, "https://example.com/a")), 0, ); s.transition( &Event::PlaylistEntryAdded(playlist_entry(2, "https://example.com/b")), 0, ); s.transition( &Event::PlaylistEntryAdded(playlist_entry(3, "https://example.com/c")), 0, ); s.playlist_cursor = 3; // past the end after removal let id = PlaylistEntryId(uuid::Uuid::from_u64_pair(0, 3)); let effects = s.transition(&Event::PlaylistEntryRemoved(id), 0); assert_eq!(s.playlist.len(), 2); // Cursor should be clamped to last valid index (1). assert_eq!(s.playlist_cursor, 1); assert!(effects .iter() .any(|e| matches!(e, Effect::RemovePlaylistEntry(_)))); }
#[test] fn test_playlist_remove_last_disables() { let mut s = PlaybackState::default(); s.transition( &Event::PlaylistEntryAdded(playlist_entry(1, "https://example.com/a")), 0, ); let id = PlaylistEntryId(uuid::Uuid::from_u64_pair(0, 1)); s.transition(&Event::PlaylistEntryRemoved(id), 0); assert!(s.playlist.is_empty()); assert!(!s.autoplay_enabled); }
#[test] fn test_set_autoplay_enabled_noop_when_same() { let mut s = PlaybackState { autoplay_enabled: true, playlist: vec![playlist_entry(1, "https://example.com/a")], ..PlaybackState::default() }; let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0); assert!(effects.is_empty()); }
#[test] fn test_set_autoplay_enabled_disable_persists_and_publishes() { let mut s = PlaybackState { autoplay_enabled: true, playlist: vec![playlist_entry(1, "https://example.com/a")], ..PlaybackState::default() }; let effects = s.transition(&Event::SetAutoplay { enabled: false }, 0); assert!(!s.autoplay_enabled); // Disable is a real state change: persist + publish. assert!(effects.iter().any(|e| matches!( e, Effect::PersistRoomSettings { autoplay_enabled: false, .. } ))); assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); }
#[test] fn test_set_autoplay_enabled_enable_persists_and_publishes() { let mut s = PlaybackState { autoplay_enabled: false, playlist: vec![playlist_entry(1, "https://example.com/a")], ..PlaybackState::default() }; let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0); assert!(s.autoplay_enabled); assert!(effects.iter().any(|e| matches!( e, Effect::PersistRoomSettings { autoplay_enabled: true, .. } ))); assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); }
#[test] fn test_set_skip_threshold_persists_and_publishes() { let mut s = PlaybackState::default(); let effects = s.transition(&Event::SetSkipThreshold { percent: 50 }, 0); assert_eq!(s.skip_threshold, 50); assert!(effects.iter().any(|e| matches!( e, Effect::PersistRoomSettings { skip_threshold: 50, .. } ))); assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); }
#[test] fn test_set_skip_threshold_noop_when_same() { let mut s = PlaybackState { skip_threshold: 50, ..PlaybackState::default() }; let effects = s.transition(&Event::SetSkipThreshold { percent: 50 }, 0); assert!(effects.is_empty()); }
#[test] fn test_default_autoplay_enabled() { let s = PlaybackState::default(); assert!(s.autoplay_enabled, "default is on for new rooms"); assert_eq!(s.skip_threshold, 34, "default threshold is 34%"); }
// --- playlist cache-fill on add / DownloadResolved ---
#[test] fn test_playlist_add_fills_from_resolved_cache() { // Bug fix: history-click case. URL is already resolved in // media_cache; placeholder must be replaced with cached metadata. let mut s = PlaybackState::default(); s.media_cache.insert( "https://example.com/known".into(), MediaStatus::Resolved { title: "Known Title".into(), duration: "3:21".into(), thumbnail: Some("https://img/known.jpg".into()), source: "ytdlp".into(), }, ); let effects = s.transition( &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/known")), 0, ); assert_eq!(s.playlist.len(), 1); let entry = &s.playlist[0]; assert_eq!(entry.title, "Known Title"); assert_eq!(entry.duration, "3:21"); assert_eq!(entry.thumbnail.as_deref(), Some("https://img/known.jpg")); assert!(!entry.pending, "filled entry should be !pending"); // The effect must carry the corrected values so the store persists // them, not the placeholder. assert!(effects.iter().any(|e| matches!( e, Effect::AddPlaylistEntry { title, duration, thumbnail: Some(thumb), .. } if title == "Known Title" && duration == "3:21" && thumb == "https://img/known.jpg" ))); }
#[test] fn test_playlist_add_keeps_placeholder_when_url_unresolved() { // No cache entry, no download yet — placeholder stays until // DownloadResolved fires. let mut s = PlaybackState::default(); let effects = s.transition( &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/new")), 0, ); let entry = &s.playlist[0]; assert_eq!(entry.title, "Loading..."); assert!(entry.pending); // Effect still carries the placeholder values; the store will // persist them, and DownloadResolved will fix it up later. assert!(effects.iter().any(|e| matches!( e, Effect::AddPlaylistEntry { title, .. } if title == "Loading..." ))); }
#[test] fn test_playlist_add_keeps_placeholder_when_cache_failed() { // A Failed cache entry means we know the URL is bad, but we don't // have any metadata to fill with. The placeholder stays; the // actor's existing fan-out path will mark the entry as failed // via MetadataUpdated. let mut s = PlaybackState::default(); s.media_cache.insert( "https://example.com/bad".into(), MediaStatus::Failed("yt-dlp error".into()), ); s.transition( &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/bad")), 0, ); let entry = &s.playlist[0]; assert_eq!(entry.title, "Loading..."); assert!(entry.pending); }
#[test] fn test_download_resolved_updates_playlist_entry() { // Bug fix: DownloadResolved must fan out to playlist entries, not // just queue/active. Without this, a fresh download for a // never-before-seen URL would leave the playlist entry stuck on // "Loading...". let mut s = PlaybackState::default(); s.transition( &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/x")), 0, ); assert!(s.playlist[0].pending);
s.transition( &Event::DownloadResolved { url: "https://example.com/x".into(), title: "Resolved Title".into(), duration: "4:20".into(), thumbnail: Some("https://img/x.jpg".into()), source: "ytdlp".into(), }, 0, ); let entry = &s.playlist[0]; assert_eq!(entry.title, "Resolved Title"); assert_eq!(entry.duration, "4:20"); assert_eq!(entry.thumbnail.as_deref(), Some("https://img/x.jpg")); assert!(!entry.pending); }
#[test] fn test_download_resolved_updates_only_matching_playlist_entries() { // Multiple entries with different URLs: only the matching one // should be updated. let mut s = PlaybackState::default(); s.transition( &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/a")), 0, ); s.transition( &Event::PlaylistEntryAdded(placeholder_playlist_entry(2, "https://example.com/b")), 0, ); s.transition( &Event::DownloadResolved { url: "https://example.com/a".into(), title: "A Resolved".into(), duration: "1:00".into(), thumbnail: None, source: "ytdlp".into(), }, 0, ); assert_eq!(s.playlist[0].title, "A Resolved"); assert!(!s.playlist[0].pending); assert_eq!(s.playlist[1].title, "Loading..."); assert!(s.playlist[1].pending); }
#[test] fn test_set_autoplay_enabled_re_enable_empty_noop() { let mut s = PlaybackState { autoplay_enabled: false, ..PlaybackState::default() }; let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0); assert!(!s.autoplay_enabled); assert!(effects.is_empty()); }
#[test] fn test_next_playlist_entry_disabled_returns_none() { let mut s = PlaybackState { autoplay_enabled: false, playlist: vec![playlist_entry(1, "https://example.com/a")], ..PlaybackState::default() }; assert!(s.next_playlist_entry().is_none()); }
#[test] fn test_next_playlist_entry_advances_cursor() { let mut s = PlaybackState { autoplay_enabled: true, playlist: vec![ playlist_entry(1, "https://example.com/a"), playlist_entry(2, "https://example.com/b"), ], ..PlaybackState::default() }; let first = s.next_playlist_entry().unwrap(); assert_eq!(first.id.0, uuid::Uuid::from_u64_pair(0, 1)); assert_eq!(s.playlist_cursor, 1); let second = s.next_playlist_entry().unwrap(); assert_eq!(second.id.0, uuid::Uuid::from_u64_pair(0, 2)); // Cursor wraps to 0. assert_eq!(s.playlist_cursor, 0); }
#[test] fn test_track_queued_from_playlist_uses_handle_queued_path() { let mut s = PlaybackState::default(); let q = QueuedTrack { id: track_id(1), title: "PL".into(), url: "https://example.com/pl".into(), duration: "1:00".into(), thumbnail: None, pending: true, }; s.transition(&Event::TrackQueuedFromPlaylist(q, 0), 0); // Active track should be the queued one. assert_eq!(s.active.as_ref().unwrap().title, "PL"); }
// --- queue reorder / shuffle ---
fn qid(n: u128) -> QueuedTrack { QueuedTrack { id: track_id(n), title: format!("Q{n}"), url: format!("https://example.com/q{n}"), duration: "1:00".into(), thumbnail: None, pending: false, } }
fn queue_with(n: usize) -> (PlaybackState, Vec<TrackId>) { // Queue `n` tracks. The first becomes active (idle room); the rest // sit in the queue. Total TrackQueued events = n + 1. let mut s = PlaybackState::default(); let active = qid(0); let mut ids = vec![active.id]; s.transition(&Event::TrackQueued(active), 0); for i in 0..n { let t = qid((i + 1) as u128); ids.push(t.id); s.transition(&Event::TrackQueued(t), 0); } (s, ids) }
#[test] fn test_move_queue_item_basic() { // Queue (after queue_with): [B, C, D]. Active is A. Move from 0 to 2 → [C, D, B]. let (mut s, ids) = queue_with(3); let effects = s.transition(&Event::MoveQueueItem { from: 0, to: 2 }, 0); assert_eq!(s.queue[0].id, ids[2]); assert_eq!(s.queue[1].id, ids[3]); assert_eq!(s.queue[2].id, ids[1]); // Effect carries the new queue order so the store can renumber. assert!(effects.iter().any(|e| matches!( e, Effect::ReorderQueue { new_order } if new_order == &vec![ids[2], ids[3], ids[1]] ))); assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); }
#[test] fn test_move_queue_item_same_index_is_noop() { let (mut s, ids) = queue_with(3); let effects = s.transition(&Event::MoveQueueItem { from: 1, to: 1 }, 0); assert_eq!(s.queue[0].id, ids[1]); assert!(effects.is_empty()); }
#[test] fn test_move_queue_item_out_of_range_is_noop() { let (mut s, _ids) = queue_with(3); let effects = s.transition(&Event::MoveQueueItem { from: 5, to: 0 }, 0); assert_eq!(s.queue.len(), 3); assert!(effects.is_empty()); let effects = s.transition(&Event::MoveQueueItem { from: 0, to: 5 }, 0); assert!(effects.is_empty()); }
#[test] fn test_move_queue_item_empty_queue_is_noop() { let mut s = PlaybackState::default(); let effects = s.transition(&Event::MoveQueueItem { from: 0, to: 0 }, 0); assert!(effects.is_empty()); }
#[test] fn test_shuffle_queue_deterministic() { // Same seed → same order. Run twice and compare. let (mut s1, _) = queue_with(8); let (mut s2, _) = queue_with(8); s1.transition(&Event::ShuffleQueue { seed: 42 }, 0); s2.transition(&Event::ShuffleQueue { seed: 42 }, 0); let order1: Vec<TrackId> = s1.queue.iter().map(|t| t.id).collect(); let order2: Vec<TrackId> = s2.queue.iter().map(|t| t.id).collect(); assert_eq!(order1, order2); }
#[test] fn test_shuffle_queue_different_seeds_diverge() { // Two different seeds → almost certainly different orders. // (Birthday-paradox: collision chance with 8 items is ~1/2000.) let (mut s1, _) = queue_with(8); let (mut s2, _) = queue_with(8); s1.transition(&Event::ShuffleQueue { seed: 1 }, 0); s2.transition(&Event::ShuffleQueue { seed: 2 }, 0); let order1: Vec<TrackId> = s1.queue.iter().map(|t| t.id).collect(); let order2: Vec<TrackId> = s2.queue.iter().map(|t| t.id).collect(); assert_ne!(order1, order2); }
#[test] fn test_shuffle_queue_preserves_count() { let (mut s, ids) = queue_with(10); s.transition(&Event::ShuffleQueue { seed: 99 }, 0); assert_eq!(s.queue.len(), 10); // ids[0] is the active track; ids[1..] are the queue. let mut sorted: Vec<_> = s.queue.iter().map(|t| t.id).collect(); sorted.sort_by_key(|id| id.0.as_u128()); let mut expected: Vec<_> = ids[1..].to_vec(); expected.sort_by_key(|id| id.0.as_u128()); assert_eq!(sorted, expected); }
#[test] fn test_shuffle_queue_singleton_is_noop() { let (mut s, ids) = queue_with(1); let effects = s.transition(&Event::ShuffleQueue { seed: 0 }, 0); assert_eq!(s.queue[0].id, ids[1]); assert!(effects.is_empty()); }
#[test] fn test_shuffle_queue_empty_is_noop() { let mut s = PlaybackState::default(); let effects = s.transition(&Event::ShuffleQueue { seed: 0 }, 0); assert!(effects.is_empty()); }
#[test] fn test_shuffle_queue_does_not_affect_active() { // Active track stays put; the shuffle permutes only the queue Vec. let (mut s, _ids) = queue_with(5); let original_active = s.active.as_ref().unwrap().id; s.transition(&Event::ShuffleQueue { seed: 7 }, 0); assert_eq!(s.active.as_ref().unwrap().id, original_active); assert_eq!(s.queue.len(), 5); }
// --- queue item removal ---
#[test] fn test_remove_queue_item_basic() { // Queue: [B, C, D], active A. Remove C → [B, D]. let (mut s, ids) = queue_with(3); let effects = s.transition(&Event::RemoveQueueItem { track_id: ids[2] }, 0); assert_eq!(s.queue.len(), 2); assert_eq!(s.queue[0].id, ids[1]); assert_eq!(s.queue[1].id, ids[3]); // Active track is untouched. assert_eq!(s.active.as_ref().unwrap().id, ids[0]); // Effects: delete, reorder, snapshot. assert!(effects.iter().any(|e| matches!( e, Effect::RemoveQueuedTrack { track_id } if *track_id == ids[2] ))); assert!(effects.iter().any(|e| matches!( e, Effect::ReorderQueue { new_order } if new_order == &vec![ids[1], ids[3]] ))); assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); }
#[test] fn test_remove_queue_item_unknown_id_is_noop() { let (mut s, ids) = queue_with(3); let ghost = track_id(0xdeadbeef); let effects = s.transition(&Event::RemoveQueueItem { track_id: ghost }, 0); assert_eq!(s.queue.len(), 3); assert!(effects.is_empty()); let _ = ids; }
#[test] fn test_remove_queue_item_active_is_noop() { // Active track is NOT in the queue Vec, so removing it should be a // no-op. The state machine never lets you delete a playing track. let (mut s, ids) = queue_with(2); let active_id = s.active.as_ref().unwrap().id; let effects = s.transition( &Event::RemoveQueueItem { track_id: active_id, }, 0, ); assert_eq!(s.queue.len(), 2); assert!(effects.is_empty()); let _ = ids; }
#[test] fn test_remove_queue_item_empty_queue_is_noop() { let mut s = PlaybackState::default(); let effects = s.transition( &Event::RemoveQueueItem { track_id: track_id(1), }, 0, ); assert!(effects.is_empty()); }
#[test] fn test_remove_queue_item_last_track() { // Queue has 1 track, remove it → empty. let (mut s, ids) = queue_with(1); let effects = s.transition(&Event::RemoveQueueItem { track_id: ids[1] }, 0); assert!(s.queue.is_empty()); assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); }
#[test] fn test_download_failed_updates_playlist_entry() { // Bug fix: a failed download for a URL in the playlist must // mark the entry as failed (title = "Failed to load: <error>", // pending = false, failed = Some(error)). Without this, a bad URL // in the playlist stays "Loading..." forever and the UI has no // structured signal to render a failure state. let mut s = PlaybackState::default(); s.transition( &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/bad")), 0, ); assert!(s.playlist[0].pending);
let effects = s.transition( &Event::DownloadFailed { url: "https://example.com/bad".into(), error: "yt-dlp returned 404".into(), }, 0, );
let entry = &s.playlist[0]; assert_eq!(entry.title, "Failed to load: yt-dlp returned 404"); assert_eq!(entry.duration, "--:--"); assert!(entry.thumbnail.is_none()); assert!(!entry.pending); assert_eq!(entry.failed.as_deref(), Some("yt-dlp returned 404")); // PersistMetadata so the failure title lands in media_cache and // survives rehydration. assert!(effects.iter().any(|e| matches!( e, Effect::PersistMetadata { url, title, .. } if url == "https://example.com/bad" && title == "Failed to load: yt-dlp returned 404" ))); assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); }
#[test] fn test_playlist_entry_failed_field_defaults_to_none() { // A freshly added playlist entry must have `failed = None`. The // `failed` field is only set when DownloadFailed flips a pending // entry, so the default has to be explicit and the type has to be // Option<String> (not just String) so the field can be deserialized // from older rows that don't carry it. let mut s = PlaybackState::default(); s.transition( &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/a")), 0, ); assert!(s.playlist[0].failed.is_none()); }
#[test] fn test_download_failed_only_updates_pending_playlist_entries() { // A non-pending playlist entry (e.g. already resolved) for the same // URL should NOT be clobbered by a later failure for that URL. let mut s = PlaybackState::default(); s.transition( &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/ok")), 0, ); s.transition( &Event::DownloadResolved { url: "https://example.com/ok".into(), title: "OK Title".into(), duration: "5:00".into(), thumbnail: Some("https://img/ok.jpg".into()), source: "ytdlp".into(), }, 0, ); assert!(!s.playlist[0].pending); let original_title = s.playlist[0].title.clone();
let effects = s.transition( &Event::DownloadFailed { url: "https://example.com/ok".into(), error: "boom".into(), }, 0, );
// Title preserved — the entry was already resolved. assert_eq!(s.playlist[0].title, original_title); // No metadata effect needed (no pending entries to flip). assert!(!effects .iter() .any(|e| matches!(e, Effect::PersistMetadata { .. }))); assert!(!effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); }
#[test] fn test_download_failed_with_no_playlist_match_emits_nothing() { // URL fails but isn't in the playlist and isn't a queue/active // track. State machine only updates media_cache. No effects. let mut s = PlaybackState::default(); let effects = s.transition( &Event::DownloadFailed { url: "https://example.com/orphan".into(), error: "no such host".into(), }, 0, ); assert!(matches!( s.media_cache.get("https://example.com/orphan"), Some(MediaStatus::Failed(_)) )); assert!(effects.is_empty()); }}
/// Deterministic simulation testing: random event sequences with invariant/// checks after every step and at rest.#[cfg(test)]mod dst { use proptest::prelude::*;
use super::*;
fn tid(n: u64) -> TrackId { TrackId(uuid::Uuid::from_u64_pair(0, n)) }
fn queued(n: u64) -> QueuedTrack { QueuedTrack { id: tid(n), title: format!("Track-{n}"), url: format!("https://example.com/track-{n}"), duration: "3:00".into(), thumbnail: None, pending: n.is_multiple_of(3), } }
#[derive(Debug, Clone)] enum Op { Queue(u64), Skip, TrackEnded(u64), TrackFailed(u64), MetadataUpdated(u64), DownloadResolved(u64), DownloadFailed(u64), MoveQueueItem { from: u8, to: u8, }, ShuffleQueue { seed: u64, }, /// Remove a queued track. `n` indexes into the same 0..12 track pool /// as the other ops; if the track isn't in the queue the state /// machine no-ops, mirroring the real-world "clicked X on a track /// already removed" case. RemoveQueueItem(u64), /// Toggle autoplay. The no-op guard for empty playlist + re-enable /// is checked in apply_ops. SetAutoplay(bool), /// Set the skip-vote threshold. The no-op guard for unchanged value /// is checked in apply_ops. SetSkipThreshold(u8), }
fn op_strategy() -> impl Strategy<Value = Vec<Op>> { // Move, shuffle, and remove are weighted higher than the other ops // so the new queue-management handlers get meaningful coverage in // the 1024-case run. let op = prop_oneof![ 2 => (0u64..12).prop_map(Op::Queue), 1 => Just(Op::Skip), 1 => (0u64..12).prop_map(Op::TrackEnded), 1 => (0u64..12).prop_map(Op::TrackFailed), 1 => (0u64..12).prop_map(Op::MetadataUpdated), 1 => (0u64..12).prop_map(Op::DownloadResolved), 1 => (0u64..12).prop_map(Op::DownloadFailed), 3 => (0u8..16, 0u8..16).prop_map(|(from, to)| Op::MoveQueueItem { from, to }), 2 => (0u64..1000).prop_map(|seed| Op::ShuffleQueue { seed }), 3 => (0u64..12).prop_map(Op::RemoveQueueItem), 2 => any::<bool>().prop_map(Op::SetAutoplay), 2 => any::<u8>().prop_map(Op::SetSkipThreshold), ]; proptest::collection::vec(op, 0..50) }
fn apply_ops(ops: &[Op]) { let mut state = PlaybackState::default(); let mut queued_ids = std::collections::HashSet::new(); let mut history_snapshot = Vec::new();
for op in ops { let prev_active_id = state.active.as_ref().map(|a| a.id);
let effects = match op { Op::Queue(n) => { if !queued_ids.insert(*n) { continue; }
let t = queued(*n); state.transition(&Event::TrackQueued(t), 0) } Op::Skip => state.transition(&Event::Skip, 0), Op::TrackEnded(n) => state.transition(&Event::TrackEnded { item_id: tid(*n) }, 0), Op::TrackFailed(n) => { let id = tid(*n); let effects = state.transition(&Event::TrackFailed { item_id: id }, 0);
// TrackFailed must only affect the named track. if prev_active_id.is_some_and(|aid| aid != id) { assert!( state.active.is_some(), "TrackFailed({id}) removed active track {aid}", aid = prev_active_id.unwrap() ); assert_eq!( state.active.as_ref().map(|a| a.id), prev_active_id, "TrackFailed({id}) changed active track from {aid}", aid = prev_active_id.unwrap() ); } effects } Op::MetadataUpdated(n) => { let q = queued(*n); state.transition( &Event::MetadataUpdated { item_id: q.id, url: q.url, title: format!("Updated-{n}"), duration: "5:00".into(), thumbnail: None, source: "ytdlp".into(), }, 0, ) } Op::DownloadResolved(n) => { let q = queued(*n); let url = q.url.clone(); let effects = state.transition( &Event::DownloadResolved { url: q.url.clone(), title: format!("Cached-{n}"), duration: "4:20".into(), thumbnail: Some(format!("https://img/{n}.jpg")), source: "ytdlp".into(), }, 0, ); // After DownloadResolved, the cache must hold a Resolved entry. assert!( matches!( state.media_cache.get(&url), Some(crate::state::MediaStatus::Resolved { .. }) ), "DownloadResolved({url}) did not populate media_cache as Resolved", ); // And any queue track with that URL must be !pending. for t in &state.queue { if t.url == url { assert!( !t.pending, "queue track {id} still pending after DownloadResolved({url})", id = t.id, ); } } effects } Op::DownloadFailed(n) => { let q = queued(*n); let url = q.url.clone(); let effects = state.transition( &Event::DownloadFailed { url: q.url, error: format!("err-{n}"), }, 0, ); assert_eq!( state.media_cache.get(&url), Some(&crate::state::MediaStatus::Failed(format!("err-{n}"))), "DownloadFailed({url}) did not record Failed in media_cache", ); // Cross-state invariant: a Failed cache must not leave // any matching playlist entries in pending state. They // should be flipped to the failure title and the // `failed` field should carry the error. for entry in &state.playlist { if entry.url == url { assert!( !entry.pending, "DownloadFailed({url}) left a playlist entry pending: {entry:?}", ); assert!( entry.title.starts_with("Failed to load: "), "DownloadFailed({url}) did not set failure title; got: {}", entry.title, ); assert_eq!( entry.failed.as_deref(), Some(format!("err-{n}").as_str()), "DownloadFailed({url}) did not set failed reason; got: {:?}", entry.failed, ); } } effects } Op::MoveQueueItem { from, to } => { // The handler is a no-op for out-of-range or equal indices. let queue_len = state.queue.len(); let from_in = (*from as usize) < queue_len; let to_in = (*to as usize) < queue_len; let changed = from_in && to_in && from != to; let moved_track_id = changed.then(|| state.queue[*from as usize].id); let expected_ids: Vec<_> = if changed { let mut q = state.queue.clone(); let t = q.remove(*from as usize); q.insert(*to as usize, t); q.into_iter().map(|t| t.id).collect() } else { state.queue.iter().map(|t| t.id).collect() }; let effects = state.transition( &Event::MoveQueueItem { from: *from as usize, to: *to as usize, }, 0, ); let actual_ids: Vec<_> = state.queue.iter().map(|t| t.id).collect(); assert_eq!( actual_ids, expected_ids, "MoveQueueItem {from},{to} produced wrong order", ); // The moved track must end at the destination index. if let Some(id) = moved_track_id { assert_eq!( state.queue[*to as usize].id, id, "moved track not at destination index" ); } if changed { assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); } else { assert!(effects.is_empty(), "no-op move should return no effects"); } effects } Op::ShuffleQueue { seed } => { // Same seed → same order. Record the order, run the op, // and verify the queue contains the same track IDs // (it's a permutation, not a transformation). let pre: std::collections::HashSet<_> = state.queue.iter().map(|t| t.id).collect(); let effects = state.transition(&Event::ShuffleQueue { seed: *seed }, 0); let post: std::collections::HashSet<_> = state.queue.iter().map(|t| t.id).collect(); assert_eq!(pre, post, "shuffle must be a permutation"); if state.queue.len() >= 2 { assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); } else { assert!(effects.is_empty(), "tiny queue should be a no-op"); } effects } Op::RemoveQueueItem(n) => { // Pick the track id from the same 0..12 pool used by // other ops. The state machine no-ops when the track // isn't in the queue (or it's the active track), so // this arm covers both the actual-remove and no-op // cases. let target = tid(*n); let was_in_queue = state.queue.iter().any(|t| t.id == target); let prev_len = state.queue.len(); let effects = state.transition(&Event::RemoveQueueItem { track_id: target }, 0); if was_in_queue { assert_eq!( state.queue.len(), prev_len - 1, "remove should shrink queue by exactly one", ); assert!( !state.queue.iter().any(|t| t.id == target), "removed track must not remain in queue", ); assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); assert!(effects .iter() .any(|e| matches!(e, Effect::RemoveQueuedTrack { .. }))); } else { assert!(effects.is_empty(), "no-op remove must return no effects"); } effects } Op::SetAutoplay(enabled) => { let prev = state.autoplay_enabled; let effects = state.transition(&Event::SetAutoplay { enabled: *enabled }, 0); // No-op guard: enabling on empty playlist, or matching // the current value, must return no effects and not // mutate state. let is_noop = (*enabled && state.playlist.is_empty()) || *enabled == prev; if is_noop { assert!( effects.is_empty(), "SetAutoplay no-op must return no effects" ); assert_eq!( state.autoplay_enabled, prev, "SetAutoplay no-op must not mutate state" ); } else { // Real change: state must reflect it, and the // PersistRoomSettings effect (if any) must carry // the new value. assert_eq!(state.autoplay_enabled, *enabled); assert!( effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)), "SetAutoplay change must publish: {effects:?}" ); } effects } Op::SetSkipThreshold(percent) => { let prev = state.skip_threshold; // State machine clamps the input to 0..=100. let clamped = (*percent).min(100u8); let effects = state.transition(&Event::SetSkipThreshold { percent: *percent }, 0); if clamped == prev { assert!( effects.is_empty(), "SetSkipThreshold no-op (after clamp) must return no effects" ); assert_eq!(state.skip_threshold, prev); } else { assert_eq!(state.skip_threshold, clamped); assert!( effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)), "SetSkipThreshold change must publish: {effects:?}" ); } effects } };
check_invariants(&state, &effects, &queued_ids, &mut history_snapshot); }
// Final-state crash test: applying any event to the final state // should never panic. let _ = state.transition(&Event::Skip, 0); let _ = state.transition( &Event::DownloadResolved { url: "https://example.com/ghost".into(), title: "Ghost".into(), duration: "0:00".into(), thumbnail: None, source: "ytdlp".into(), }, 0, ); // Determinism: same event on identical state should produce same // effects. Tested across the main event families, not just Skip. for event in &[ Event::Skip, Event::MoveQueueItem { from: 0, to: 0 }, Event::ShuffleQueue { seed: 0 }, Event::RemoveQueueItem { track_id: tid(0) }, Event::SetAutoplay { enabled: true }, Event::SetAutoplay { enabled: false }, Event::SetSkipThreshold { percent: 50 }, Event::SetSkipThreshold { percent: 0 }, Event::SetSkipThreshold { percent: 100 }, ] { let mut clone = state.clone(); let e1 = state.transition(event, 0); let e2 = clone.transition(event, 0); assert_eq!(e1, e2, "non-deterministic {event:?} on final state"); } }
fn check_invariants( state: &PlaybackState, effects: &[Effect], queued_ids: &std::collections::HashSet<u64>, history_snapshot: &mut Vec<FinishedTrack>, ) { // === State structural invariants ===
// 1. All track IDs across active + queue + history are unique. let mut ids = std::collections::HashSet::new(); if let Some(ref a) = state.active { assert!(ids.insert(a.id), "duplicate id in active: {a}", a = a.id); } for t in &state.queue { assert!(ids.insert(t.id), "duplicate id in queue: {t}", t = t.id); } for h in &state.history { assert!(ids.insert(h.id), "duplicate id in history: {h}", h = h.id); }
// 2. Active track is never also in queue or history. if let Some(ref a) = state.active { assert!( !state.queue.iter().any(|t| t.id == a.id), "active track {a} also in queue", a = a.id ); assert!( !state.history.iter().any(|h| h.id == a.id), "active track {a} also in history", a = a.id ); }
// 3. Queue and history are disjoint. for h in &state.history { assert!( !state.queue.iter().any(|t| t.id == h.id), "track {h} in both queue and history", h = h.id ); }
// 4. Conservation: every track in active/queue/history was once queued. for id in &ids { let n = id.0.as_u64_pair().1; assert!( queued_ids.contains(&n), "orphan track id {id} was never queued" ); }
// 5. History is monotonic: never shrinks, and held tracks are stable. assert!( state.history.len() >= history_snapshot.len(), "history shrank from {} to {}", history_snapshot.len(), state.history.len() ); for prev in history_snapshot.iter() { assert!( state.history.iter().any(|h| h.id == prev.id), "history lost track {id}", id = prev.id ); } *history_snapshot = state.history.clone();
// === Effect composition invariants ===
// 6. Within a single transition, all Persist* effects precede // PublishSnapshot. There may be zero or one PublishSnapshot. let publish_pos = effects .iter() .position(|e| matches!(e, Effect::PublishSnapshot)); if let Some(pos) = publish_pos { for e in &effects[pos + 1..] { assert!( !is_persist_effect(e), "persist effect {e:?} appears after PublishSnapshot" ); } }
// 7. State-changing effects must be followed by PublishSnapshot. let needs_publish = effects.iter().any(|e| { matches!( e, Effect::PersistStarted(_) | Effect::PersistFinished(_) | Effect::PersistQueuedTrack(_) | Effect::PersistMetadata { .. } | Effect::PersistRoomSettings { .. } | Effect::AbortDownload ) }); if needs_publish { assert!( effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)), "state-changing effects without PublishSnapshot: {effects:?}" ); }
// 8. PersistStarted and PersistFinished for the same track can't // appear in the same transition. let started: std::collections::HashSet<TrackId> = effects .iter() .filter_map(|e| { if let Effect::PersistStarted(id) = e { Some(*id) } else { None } }) .collect(); for e in effects { if let Effect::PersistFinished(id) = e { assert!( !started.contains(id), "PersistStarted and PersistFinished for same track: {id}" ); } }
// 9. AbortDownload always accompanies PersistFinished (only fired // when the active track is aborted during skip/fail). if effects.iter().any(|e| matches!(e, Effect::AbortDownload)) { assert!( effects .iter() .any(|e| matches!(e, Effect::PersistFinished(_))), "AbortDownload without PersistFinished" ); }
// === Queue-management invariants ===
// 10. ReorderQueue and RemoveQueuedTrack are state-changing: they // must be followed by PublishSnapshot in the same transition. let has_reorder = effects .iter() .any(|e| matches!(e, Effect::ReorderQueue { .. })); let has_remove = effects .iter() .any(|e| matches!(e, Effect::RemoveQueuedTrack { .. })); if has_reorder || has_remove { assert!( effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)), "queue-management effects without PublishSnapshot: {effects:?}" ); }
// 11. Effect/state consistency: a ReorderQueue effect carries the // post-transition queue order, so the state must match it. This // catches handlers that mutate self.queue and emit a new_order // that don't agree. if let Some(new_order) = effects.iter().find_map(|e| { if let Effect::ReorderQueue { new_order } = e { Some(new_order.clone()) } else { None } }) { let state_ids: Vec<TrackId> = state.queue.iter().map(|t| t.id).collect(); assert_eq!( state_ids, new_order, "ReorderQueue's new_order doesn't match post-transition state.queue" ); }
// 12. When RemoveQueuedTrack is emitted, the removed track must not // be in the post-transition queue. for e in effects { if let Effect::RemoveQueuedTrack { track_id } = e { assert!( !state.queue.iter().any(|t| t.id == *track_id), "RemoveQueuedTrack({track_id}) but track is still in state.queue" ); } }
// 13. When both RemoveQueuedTrack and ReorderQueue are emitted in // the same transition, the removed track must not appear in // new_order (the two effects must agree). if let (Some(removed), Some(new_order)) = ( effects.iter().find_map(|e| { if let Effect::RemoveQueuedTrack { track_id } = e { Some(*track_id) } else { None } }), effects.iter().find_map(|e| { if let Effect::ReorderQueue { new_order } = e { Some(new_order) } else { None } }), ) { assert!( !new_order.contains(&removed), "ReorderQueue.new_order still contains just-removed track {removed}" ); }
// 14. Every non-pending playlist entry's URL must be present in // media_cache (Resolved or Failed). If a non-pending entry // has no matching cache row, the entry would render with a // stale title on rehydration. for entry in &state.playlist { if !entry.pending { let url = entry.url.clone(); let cached = state.media_cache.get(&url); assert!( matches!( cached, Some(crate::state::MediaStatus::Resolved { .. }) | Some(crate::state::MediaStatus::Failed(_)) ), "non-pending playlist entry for {url} has no media_cache row (would rehydrate with stale title): {entry:?}", ); if let Some(crate::state::MediaStatus::Failed(err)) = cached { assert_eq!( entry.title, format!("Failed to load: {err}"), "non-pending Failed entry title mismatch for {url}", ); assert_eq!( entry.failed.as_deref(), Some(err.as_str()), "non-pending Failed entry `failed` field mismatch for {url}", ); } else { assert!( entry.failed.is_none(), "non-pending Resolved playlist entry has a `failed` reason: {entry:?}", ); } } }
// 15. PersistRoomSettings effect payload must match the // post-transition state. The actor uses these to overwrite // DB columns; a stale payload would diverge state from // what's persisted. for e in effects { if let Effect::PersistRoomSettings { autoplay_enabled, skip_threshold, } = e { assert_eq!( *autoplay_enabled, state.autoplay_enabled, "PersistRoomSettings.autoplay_enabled stale" ); assert_eq!( *skip_threshold, state.skip_threshold, "PersistRoomSettings.skip_threshold stale" ); } }
// 16. skip_threshold stays in 0..=100 across the run. State // is `u8` so the type guarantees this, but a regression // that wraps/clips would slip past the type system — pin // it here. assert!(state.skip_threshold <= 100); }
fn is_persist_effect(e: &Effect) -> bool { matches!( e, Effect::PersistStarted(_) | Effect::PersistFinished(_) | Effect::PersistQueuedTrack(_) | Effect::PersistMetadata { .. } | Effect::AddPlaylistEntry { .. } | Effect::RemovePlaylistEntry(_) | Effect::ReorderQueue { .. } | Effect::RemoveQueuedTrack { .. } | Effect::PersistRoomSettings { .. } ) }
proptest! { #![proptest_config(ProptestConfig { cases: 1024, ..ProptestConfig::default() })] #[test] fn dst_random_sequences_invariants_hold(ops in op_strategy()) { apply_ops(&ops); } }}