experiments of a tiny cytube-like player with yt-dlp
0

Configure Feed

Select the types of activity you want to include in your feed.

state: mark playlist entries failed on download error

karitham (Jun 1, 2026, 1:53 PM +0200) 2f22c554 ececd5dd

+170 -5
+170 -5
src/state.rs
··· 462 462 ] 463 463 } 464 464 465 - /// Cache the URL as Failed. Does not modify tracks directly — the actor 466 - /// fans out per-track `MetadataUpdated` + `TrackFailed` events for removal. 465 + /// Cache the URL as Failed. Does not modify queue/active tracks directly 466 + /// — the actor fans out per-track `MetadataUpdated` + `TrackFailed` 467 + /// events for removal. Playlist entries are updated here so a bad URL 468 + /// in the playlist stops showing "Loading..." with no indication that 469 + /// it failed. We also fire `PersistMetadata` so the failure title 470 + /// lands in `media_cache` and survives rehydration (the playlist load 471 + /// JOINs media_cache to compute title + pending). 467 472 fn handle_download_failed(&mut self, url: &str, error: &str) -> Vec<Effect> { 468 473 self.media_cache 469 474 .insert(url.to_string(), MediaStatus::Failed(error.to_string())); 470 - // No track mutation here. No publish either — the actor's per-track 471 - // TrackFailed events will trigger their own PublishSnapshot. 472 - Vec::new() 475 + let failed_title = format!("Failed to load: {error}"); 476 + let mut changed = false; 477 + for entry in &mut self.playlist { 478 + if entry.url == url && entry.pending { 479 + entry.title = failed_title.clone(); 480 + entry.duration = "--:--".into(); 481 + entry.thumbnail = None; 482 + entry.pending = false; 483 + changed = true; 484 + } 485 + } 486 + if changed { 487 + vec![ 488 + Effect::PersistMetadata { 489 + item_id: TrackId::nil(), 490 + url: url.to_string(), 491 + title: failed_title, 492 + duration: "--:--".into(), 493 + thumbnail: None, 494 + source: "ytdlp".into(), 495 + }, 496 + Effect::PublishSnapshot, 497 + ] 498 + } else { 499 + Vec::new() 500 + } 473 501 } 474 502 475 503 /// Add a playlist entry, replacing any existing entry with the same URL. ··· 1967 1995 assert!(s.queue.is_empty()); 1968 1996 assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); 1969 1997 } 1998 + 1999 + #[test] 2000 + fn test_download_failed_updates_playlist_entry() { 2001 + // Bug fix: a failed download for a URL in the playlist must 2002 + // mark the entry as failed (title = "Failed to load: <error>", 2003 + // pending = false). Without this, a bad URL in the playlist stays 2004 + // "Loading..." forever. 2005 + let mut s = PlaybackState::default(); 2006 + s.transition( 2007 + &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/bad")), 2008 + 0, 2009 + ); 2010 + assert!(s.playlist[0].pending); 2011 + 2012 + let effects = s.transition( 2013 + &Event::DownloadFailed { 2014 + url: "https://example.com/bad".into(), 2015 + error: "yt-dlp returned 404".into(), 2016 + }, 2017 + 0, 2018 + ); 2019 + 2020 + let entry = &s.playlist[0]; 2021 + assert_eq!(entry.title, "Failed to load: yt-dlp returned 404"); 2022 + assert_eq!(entry.duration, "--:--"); 2023 + assert!(entry.thumbnail.is_none()); 2024 + assert!(!entry.pending); 2025 + // PersistMetadata so the failure title lands in media_cache and 2026 + // survives rehydration. 2027 + assert!(effects.iter().any(|e| matches!( 2028 + e, 2029 + Effect::PersistMetadata { url, title, .. } 2030 + if url == "https://example.com/bad" 2031 + && title == "Failed to load: yt-dlp returned 404" 2032 + ))); 2033 + assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); 2034 + } 2035 + 2036 + #[test] 2037 + fn test_download_failed_only_updates_pending_playlist_entries() { 2038 + // A non-pending playlist entry (e.g. already resolved) for the same 2039 + // URL should NOT be clobbered by a later failure for that URL. 2040 + let mut s = PlaybackState::default(); 2041 + s.transition( 2042 + &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/ok")), 2043 + 0, 2044 + ); 2045 + s.transition( 2046 + &Event::DownloadResolved { 2047 + url: "https://example.com/ok".into(), 2048 + title: "OK Title".into(), 2049 + duration: "5:00".into(), 2050 + thumbnail: Some("https://img/ok.jpg".into()), 2051 + source: "ytdlp".into(), 2052 + }, 2053 + 0, 2054 + ); 2055 + assert!(!s.playlist[0].pending); 2056 + let original_title = s.playlist[0].title.clone(); 2057 + 2058 + let effects = s.transition( 2059 + &Event::DownloadFailed { 2060 + url: "https://example.com/ok".into(), 2061 + error: "boom".into(), 2062 + }, 2063 + 0, 2064 + ); 2065 + 2066 + // Title preserved — the entry was already resolved. 2067 + assert_eq!(s.playlist[0].title, original_title); 2068 + // No metadata effect needed (no pending entries to flip). 2069 + assert!(!effects 2070 + .iter() 2071 + .any(|e| matches!(e, Effect::PersistMetadata { .. }))); 2072 + assert!(!effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); 2073 + } 2074 + 2075 + #[test] 2076 + fn test_download_failed_with_no_playlist_match_emits_nothing() { 2077 + // URL fails but isn't in the playlist and isn't a queue/active 2078 + // track. State machine only updates media_cache. No effects. 2079 + let mut s = PlaybackState::default(); 2080 + let effects = s.transition( 2081 + &Event::DownloadFailed { 2082 + url: "https://example.com/orphan".into(), 2083 + error: "no such host".into(), 2084 + }, 2085 + 0, 2086 + ); 2087 + assert!(matches!( 2088 + s.media_cache.get("https://example.com/orphan"), 2089 + Some(MediaStatus::Failed(_)) 2090 + )); 2091 + assert!(effects.is_empty()); 2092 + } 1970 2093 } 1971 2094 1972 2095 /// Deterministic simulation testing: random event sequences with invariant ··· 2135 2258 Some(&crate::state::MediaStatus::Failed(format!("err-{n}"))), 2136 2259 "DownloadFailed({url}) did not record Failed in media_cache", 2137 2260 ); 2261 + // Cross-state invariant: a Failed cache must not leave 2262 + // any matching playlist entries in pending state. They 2263 + // should be flipped to the failure title. 2264 + for entry in &state.playlist { 2265 + if entry.url == url { 2266 + assert!( 2267 + !entry.pending, 2268 + "DownloadFailed({url}) left a playlist entry pending: {entry:?}", 2269 + ); 2270 + assert!( 2271 + entry.title.starts_with("Failed to load: "), 2272 + "DownloadFailed({url}) did not set failure title; got: {}", 2273 + entry.title, 2274 + ); 2275 + } 2276 + } 2138 2277 effects 2139 2278 } 2140 2279 Op::MoveQueueItem { from, to } => { ··· 2460 2599 !new_order.contains(&removed), 2461 2600 "ReorderQueue.new_order still contains just-removed track {removed}" 2462 2601 ); 2602 + } 2603 + 2604 + // 14. Every non-pending playlist entry's URL must be present in 2605 + // media_cache (Resolved or Failed). If a non-pending entry 2606 + // has no matching cache row, the entry would render with a 2607 + // stale title on rehydration. 2608 + for entry in &state.playlist { 2609 + if !entry.pending { 2610 + let url = entry.url.clone(); 2611 + let cached = state.media_cache.get(&url); 2612 + assert!( 2613 + matches!( 2614 + cached, 2615 + Some(crate::state::MediaStatus::Resolved { .. }) 2616 + | Some(crate::state::MediaStatus::Failed(_)) 2617 + ), 2618 + "non-pending playlist entry for {url} has no media_cache row (would rehydrate with stale title): {entry:?}", 2619 + ); 2620 + if let Some(crate::state::MediaStatus::Failed(err)) = cached { 2621 + assert_eq!( 2622 + entry.title, 2623 + format!("Failed to load: {err}"), 2624 + "non-pending Failed entry title mismatch for {url}", 2625 + ); 2626 + } 2627 + } 2463 2628 } 2464 2629 } 2465 2630