···462462 ]
463463 }
464464465465- /// Cache the URL as Failed. Does not modify tracks directly — the actor
466466- /// fans out per-track `MetadataUpdated` + `TrackFailed` events for removal.
465465+ /// Cache the URL as Failed. Does not modify queue/active tracks directly
466466+ /// — the actor fans out per-track `MetadataUpdated` + `TrackFailed`
467467+ /// events for removal. Playlist entries are updated here so a bad URL
468468+ /// in the playlist stops showing "Loading..." with no indication that
469469+ /// it failed. We also fire `PersistMetadata` so the failure title
470470+ /// lands in `media_cache` and survives rehydration (the playlist load
471471+ /// JOINs media_cache to compute title + pending).
467472 fn handle_download_failed(&mut self, url: &str, error: &str) -> Vec<Effect> {
468473 self.media_cache
469474 .insert(url.to_string(), MediaStatus::Failed(error.to_string()));
470470- // No track mutation here. No publish either — the actor's per-track
471471- // TrackFailed events will trigger their own PublishSnapshot.
472472- Vec::new()
475475+ let failed_title = format!("Failed to load: {error}");
476476+ let mut changed = false;
477477+ for entry in &mut self.playlist {
478478+ if entry.url == url && entry.pending {
479479+ entry.title = failed_title.clone();
480480+ entry.duration = "--:--".into();
481481+ entry.thumbnail = None;
482482+ entry.pending = false;
483483+ changed = true;
484484+ }
485485+ }
486486+ if changed {
487487+ vec![
488488+ Effect::PersistMetadata {
489489+ item_id: TrackId::nil(),
490490+ url: url.to_string(),
491491+ title: failed_title,
492492+ duration: "--:--".into(),
493493+ thumbnail: None,
494494+ source: "ytdlp".into(),
495495+ },
496496+ Effect::PublishSnapshot,
497497+ ]
498498+ } else {
499499+ Vec::new()
500500+ }
473501 }
474502475503 /// Add a playlist entry, replacing any existing entry with the same URL.
···19671995 assert!(s.queue.is_empty());
19681996 assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)));
19691997 }
19981998+19991999+ #[test]
20002000+ fn test_download_failed_updates_playlist_entry() {
20012001+ // Bug fix: a failed download for a URL in the playlist must
20022002+ // mark the entry as failed (title = "Failed to load: <error>",
20032003+ // pending = false). Without this, a bad URL in the playlist stays
20042004+ // "Loading..." forever.
20052005+ let mut s = PlaybackState::default();
20062006+ s.transition(
20072007+ &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/bad")),
20082008+ 0,
20092009+ );
20102010+ assert!(s.playlist[0].pending);
20112011+20122012+ let effects = s.transition(
20132013+ &Event::DownloadFailed {
20142014+ url: "https://example.com/bad".into(),
20152015+ error: "yt-dlp returned 404".into(),
20162016+ },
20172017+ 0,
20182018+ );
20192019+20202020+ let entry = &s.playlist[0];
20212021+ assert_eq!(entry.title, "Failed to load: yt-dlp returned 404");
20222022+ assert_eq!(entry.duration, "--:--");
20232023+ assert!(entry.thumbnail.is_none());
20242024+ assert!(!entry.pending);
20252025+ // PersistMetadata so the failure title lands in media_cache and
20262026+ // survives rehydration.
20272027+ assert!(effects.iter().any(|e| matches!(
20282028+ e,
20292029+ Effect::PersistMetadata { url, title, .. }
20302030+ if url == "https://example.com/bad"
20312031+ && title == "Failed to load: yt-dlp returned 404"
20322032+ )));
20332033+ assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)));
20342034+ }
20352035+20362036+ #[test]
20372037+ fn test_download_failed_only_updates_pending_playlist_entries() {
20382038+ // A non-pending playlist entry (e.g. already resolved) for the same
20392039+ // URL should NOT be clobbered by a later failure for that URL.
20402040+ let mut s = PlaybackState::default();
20412041+ s.transition(
20422042+ &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/ok")),
20432043+ 0,
20442044+ );
20452045+ s.transition(
20462046+ &Event::DownloadResolved {
20472047+ url: "https://example.com/ok".into(),
20482048+ title: "OK Title".into(),
20492049+ duration: "5:00".into(),
20502050+ thumbnail: Some("https://img/ok.jpg".into()),
20512051+ source: "ytdlp".into(),
20522052+ },
20532053+ 0,
20542054+ );
20552055+ assert!(!s.playlist[0].pending);
20562056+ let original_title = s.playlist[0].title.clone();
20572057+20582058+ let effects = s.transition(
20592059+ &Event::DownloadFailed {
20602060+ url: "https://example.com/ok".into(),
20612061+ error: "boom".into(),
20622062+ },
20632063+ 0,
20642064+ );
20652065+20662066+ // Title preserved — the entry was already resolved.
20672067+ assert_eq!(s.playlist[0].title, original_title);
20682068+ // No metadata effect needed (no pending entries to flip).
20692069+ assert!(!effects
20702070+ .iter()
20712071+ .any(|e| matches!(e, Effect::PersistMetadata { .. })));
20722072+ assert!(!effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)));
20732073+ }
20742074+20752075+ #[test]
20762076+ fn test_download_failed_with_no_playlist_match_emits_nothing() {
20772077+ // URL fails but isn't in the playlist and isn't a queue/active
20782078+ // track. State machine only updates media_cache. No effects.
20792079+ let mut s = PlaybackState::default();
20802080+ let effects = s.transition(
20812081+ &Event::DownloadFailed {
20822082+ url: "https://example.com/orphan".into(),
20832083+ error: "no such host".into(),
20842084+ },
20852085+ 0,
20862086+ );
20872087+ assert!(matches!(
20882088+ s.media_cache.get("https://example.com/orphan"),
20892089+ Some(MediaStatus::Failed(_))
20902090+ ));
20912091+ assert!(effects.is_empty());
20922092+ }
19702093}
1971209419722095/// Deterministic simulation testing: random event sequences with invariant
···21352258 Some(&crate::state::MediaStatus::Failed(format!("err-{n}"))),
21362259 "DownloadFailed({url}) did not record Failed in media_cache",
21372260 );
22612261+ // Cross-state invariant: a Failed cache must not leave
22622262+ // any matching playlist entries in pending state. They
22632263+ // should be flipped to the failure title.
22642264+ for entry in &state.playlist {
22652265+ if entry.url == url {
22662266+ assert!(
22672267+ !entry.pending,
22682268+ "DownloadFailed({url}) left a playlist entry pending: {entry:?}",
22692269+ );
22702270+ assert!(
22712271+ entry.title.starts_with("Failed to load: "),
22722272+ "DownloadFailed({url}) did not set failure title; got: {}",
22732273+ entry.title,
22742274+ );
22752275+ }
22762276+ }
21382277 effects
21392278 }
21402279 Op::MoveQueueItem { from, to } => {
···24602599 !new_order.contains(&removed),
24612600 "ReorderQueue.new_order still contains just-removed track {removed}"
24622601 );
26022602+ }
26032603+26042604+ // 14. Every non-pending playlist entry's URL must be present in
26052605+ // media_cache (Resolved or Failed). If a non-pending entry
26062606+ // has no matching cache row, the entry would render with a
26072607+ // stale title on rehydration.
26082608+ for entry in &state.playlist {
26092609+ if !entry.pending {
26102610+ let url = entry.url.clone();
26112611+ let cached = state.media_cache.get(&url);
26122612+ assert!(
26132613+ matches!(
26142614+ cached,
26152615+ Some(crate::state::MediaStatus::Resolved { .. })
26162616+ | Some(crate::state::MediaStatus::Failed(_))
26172617+ ),
26182618+ "non-pending playlist entry for {url} has no media_cache row (would rehydrate with stale title): {entry:?}",
26192619+ );
26202620+ if let Some(crate::state::MediaStatus::Failed(err)) = cached {
26212621+ assert_eq!(
26222622+ entry.title,
26232623+ format!("Failed to load: {err}"),
26242624+ "non-pending Failed entry title mismatch for {url}",
26252625+ );
26262626+ }
26272627+ }
24632628 }
24642629 }
24652630