···320320 added_by: entry.added_by.clone(),
321321 added_at: entry.added_at.clone(),
322322 pending: false,
323323+ failed: None,
323324 },
324325 // Failed or absent: keep placeholder, pending stays true.
325326 _ => entry.clone(),
···520521 entry.duration = "--:--".into();
521522 entry.thumbnail = None;
522523 entry.pending = false;
524524+ entry.failed = Some(error.to_string());
523525 changed = true;
524526 }
525527 }
···15401542 added_by: None,
15411543 added_at: "2026-01-01T00:00:00Z".into(),
15421544 pending: false,
15451545+ failed: None,
15431546 }
15441547 }
15451548···15571560 added_by: None,
15581561 added_at: "2026-01-01T00:00:00Z".into(),
15591562 pending: true,
15631563+ failed: None,
15601564 }
15611565 }
15621566···21122116 fn test_download_failed_updates_playlist_entry() {
21132117 // Bug fix: a failed download for a URL in the playlist must
21142118 // mark the entry as failed (title = "Failed to load: <error>",
21152115- // pending = false). Without this, a bad URL in the playlist stays
21162116- // "Loading..." forever.
21192119+ // pending = false, failed = Some(error)). Without this, a bad URL
21202120+ // in the playlist stays "Loading..." forever and the UI has no
21212121+ // structured signal to render a failure state.
21172122 let mut s = PlaybackState::default();
21182123 s.transition(
21192124 &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/bad")),
···21342139 assert_eq!(entry.duration, "--:--");
21352140 assert!(entry.thumbnail.is_none());
21362141 assert!(!entry.pending);
21422142+ assert_eq!(entry.failed.as_deref(), Some("yt-dlp returned 404"));
21372143 // PersistMetadata so the failure title lands in media_cache and
21382144 // survives rehydration.
21392145 assert!(effects.iter().any(|e| matches!(
···21432149 && title == "Failed to load: yt-dlp returned 404"
21442150 )));
21452151 assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)));
21522152+ }
21532153+21542154+ #[test]
21552155+ fn test_playlist_entry_failed_field_defaults_to_none() {
21562156+ // A freshly added playlist entry must have `failed = None`. The
21572157+ // `failed` field is only set when DownloadFailed flips a pending
21582158+ // entry, so the default has to be explicit and the type has to be
21592159+ // Option<String> (not just String) so the field can be deserialized
21602160+ // from older rows that don't carry it.
21612161+ let mut s = PlaybackState::default();
21622162+ s.transition(
21632163+ &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/a")),
21642164+ 0,
21652165+ );
21662166+ assert!(s.playlist[0].failed.is_none());
21462167 }
2147216821482169 #[test]
···23802401 );
23812402 // Cross-state invariant: a Failed cache must not leave
23822403 // any matching playlist entries in pending state. They
23832383- // should be flipped to the failure title.
24042404+ // should be flipped to the failure title and the
24052405+ // `failed` field should carry the error.
23842406 for entry in &state.playlist {
23852407 if entry.url == url {
23862408 assert!(
···23912413 entry.title.starts_with("Failed to load: "),
23922414 "DownloadFailed({url}) did not set failure title; got: {}",
23932415 entry.title,
24162416+ );
24172417+ assert_eq!(
24182418+ entry.failed.as_deref(),
24192419+ Some(format!("err-{n}").as_str()),
24202420+ "DownloadFailed({url}) did not set failed reason; got: {:?}",
24212421+ entry.failed,
23942422 );
23952423 }
23962424 }
···27972825 entry.title,
27982826 format!("Failed to load: {err}"),
27992827 "non-pending Failed entry title mismatch for {url}",
28282828+ );
28292829+ assert_eq!(
28302830+ entry.failed.as_deref(),
28312831+ Some(err.as_str()),
28322832+ "non-pending Failed entry `failed` field mismatch for {url}",
28332833+ );
28342834+ } else {
28352835+ assert!(
28362836+ entry.failed.is_none(),
28372837+ "non-pending Resolved playlist entry has a `failed` reason: {entry:?}",
28002838 );
28012839 }
28022840 }
+141-10
src/store.rs
···3636 pub added_at: String,
3737 /// Derived from `media_cache.state` at load time.
3838 pub pending: bool,
3939+ /// `Some(reason)` if the URL failed to resolve. Mutually exclusive with
4040+ /// `pending: true` — a failed entry has `pending: false` so the UI can
4141+ /// distinguish it from a still-loading placeholder. The reason is the
4242+ /// same string the state machine wrote into `media_cache.title` as
4343+ /// `"Failed to load: <reason>"`; the prefix is stripped during snapshot
4444+ /// load so consumers see the raw reason.
4545+ pub failed: Option<String>,
3946}
40474148/// Full snapshot of a room's state loaded from the store.
···135142 added_by: Option<String>,
136143 added_at: String,
137144 pending: bool,
145145+ /// Newly-pushed entries are never failed; the field is set later by
146146+ /// state-machine effects that mutate the playlist in place. Kept in the
147147+ /// in-memory struct so the public `PlaylistEntry` shape is preserved on
148148+ /// snapshot.
149149+ failed: Option<String>,
138150}
139151140152impl From<InMemoryPlaylistEntry> for PlaylistEntry {
···149161 added_by: e.added_by,
150162 added_at: e.added_at,
151163 pending: e.pending,
164164+ failed: e.failed,
152165 }
153166 }
154167}
···313326 added_by: None,
314327 added_at: crate::util::now_iso(),
315328 pending: true,
329329+ failed: None,
316330 });
317331 }
318332 Effect::RemovePlaylistEntry(id) => {
···922936 .await?;
923937924938 let pending_from_state = |state: &str| state == "pending";
939939+ // The failure state is encoded in the cached title (set by
940940+ // `Effect::PersistMetadata` from `handle_download_failed`). Strip the
941941+ // prefix here so the public `PlaylistEntry.failed` field matches the
942942+ // in-memory shape — consumers shouldn't have to parse the title.
943943+ let failed_from_title = |title: &str| -> Option<String> {
944944+ title
945945+ .strip_prefix("Failed to load: ")
946946+ .map(|rest| rest.to_string())
947947+ };
925948926949 let queue: Vec<QueuedTrack> = queue_rows
927950 .into_iter()
···969992970993 let playlist: Vec<PlaylistEntry> = playlist_rows
971994 .into_iter()
972972- .map(|r| PlaylistEntry {
973973- id: parse_playlist_id(&r.id),
974974- url: r.url,
975975- title: r.title,
976976- duration: r.duration,
977977- thumbnail: r.thumbnail,
978978- source: r.source,
979979- added_by: r.added_by,
980980- added_at: r.added_at,
981981- pending: pending_from_state(&r.state),
995995+ .map(|r| {
996996+ let failed = failed_from_title(&r.title);
997997+ PlaylistEntry {
998998+ id: parse_playlist_id(&r.id),
999999+ url: r.url,
10001000+ title: r.title,
10011001+ duration: r.duration,
10021002+ thumbnail: r.thumbnail,
10031003+ source: r.source,
10041004+ added_by: r.added_by,
10051005+ added_at: r.added_at,
10061006+ pending: pending_from_state(&r.state),
10071007+ failed,
10081008+ }
9821009 })
9831010 .collect();
9841011···17081735 assert_eq!(entry.duration, "4:20");
17091736 assert_eq!(entry.thumbnail, Some("https://img/x.jpg".into()));
17101737 assert!(!entry.pending);
17381738+ }
17391739+17401740+ #[tokio::test]
17411741+ async fn sqlite_failed_playlist_entry_rehydrates_with_failed_field() {
17421742+ // A playlist entry whose media_cache row was rewritten to a
17431743+ // "Failed to load: <err>" title (via Effect::PersistMetadata from
17441744+ // handle_download_failed) must rehydrate with `failed = Some(err)`.
17451745+ // The DB schema doesn't have a `failed` column — the title prefix is
17461746+ // the only signal — so the snapshot loader is the place this
17471747+ // contract is enforced.
17481748+ let store = sqlite_test_store().await;
17491749+ store.create_room("r", "R").await.unwrap();
17501750+17511751+ let entry_id = PlaylistEntryId(uuid::Uuid::from_u128(1));
17521752+ store
17531753+ .persist(
17541754+ "r",
17551755+ &[Effect::AddPlaylistEntry {
17561756+ id: entry_id,
17571757+ url: "https://example.com/dead".into(),
17581758+ title: "Loading...".into(),
17591759+ duration: "--:--".into(),
17601760+ thumbnail: None,
17611761+ source: "ytdlp".into(),
17621762+ }],
17631763+ )
17641764+ .await
17651765+ .unwrap();
17661766+17671767+ store
17681768+ .persist(
17691769+ "r",
17701770+ &[Effect::PersistMetadata {
17711771+ item_id: TrackId::nil(),
17721772+ url: "https://example.com/dead".into(),
17731773+ title: "Failed to load: yt-dlp returned 404".into(),
17741774+ duration: "--:--".into(),
17751775+ thumbnail: None,
17761776+ source: "ytdlp".into(),
17771777+ }],
17781778+ )
17791779+ .await
17801780+ .unwrap();
17811781+17821782+ let snap = store.load("r").await.unwrap().unwrap();
17831783+ assert_eq!(snap.playlist.len(), 1);
17841784+ let entry = &snap.playlist[0];
17851785+ assert_eq!(entry.title, "Failed to load: yt-dlp returned 404");
17861786+ assert!(!entry.pending);
17871787+ assert_eq!(
17881788+ entry.failed.as_deref(),
17891789+ Some("yt-dlp returned 404"),
17901790+ "snapshot loader must strip the title prefix into the `failed` field",
17911791+ );
17921792+ }
17931793+17941794+ #[tokio::test]
17951795+ async fn sqlite_resolved_playlist_entry_rehydrates_with_failed_none() {
17961796+ // A playlist entry with a non-failure title must rehydrate with
17971797+ // `failed = None`. The snapshot loader is prefix-match based, so
17981798+ // a title that doesn't start with "Failed to load: " must not
17991799+ // surface a stale `failed` reason.
18001800+ let store = sqlite_test_store().await;
18011801+ store.create_room("r", "R").await.unwrap();
18021802+18031803+ let entry_id = PlaylistEntryId(uuid::Uuid::from_u128(1));
18041804+ store
18051805+ .persist(
18061806+ "r",
18071807+ &[Effect::AddPlaylistEntry {
18081808+ id: entry_id,
18091809+ url: "https://example.com/ok".into(),
18101810+ title: "Loading...".into(),
18111811+ duration: "--:--".into(),
18121812+ thumbnail: None,
18131813+ source: "ytdlp".into(),
18141814+ }],
18151815+ )
18161816+ .await
18171817+ .unwrap();
18181818+18191819+ store
18201820+ .persist(
18211821+ "r",
18221822+ &[Effect::PersistMetadata {
18231823+ item_id: TrackId::nil(),
18241824+ url: "https://example.com/ok".into(),
18251825+ title: "Resolved Title".into(),
18261826+ duration: "1:23".into(),
18271827+ thumbnail: None,
18281828+ source: "ytdlp".into(),
18291829+ }],
18301830+ )
18311831+ .await
18321832+ .unwrap();
18331833+18341834+ let snap = store.load("r").await.unwrap().unwrap();
18351835+ let entry = &snap.playlist[0];
18361836+ assert_eq!(entry.title, "Resolved Title");
18371837+ assert!(!entry.pending);
18381838+ assert!(
18391839+ entry.failed.is_none(),
18401840+ "resolved entry must not have a `failed` reason: {entry:?}",
18411841+ );
17111842 }
1712184317131844 #[tokio::test]