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: resolve playlist entries from cache + on download

karitham (Jun 1, 2026, 1:44 PM +0200) ececd5dd 6e1d72aa

+202
+202
src/state.rs
··· 259 259 } 260 260 } 261 261 262 + /// Mirror of [`fill_from_cache`] for playlist entries. If the URL is 263 + /// already in media_cache as Resolved, return a new entry with the 264 + /// cached title/duration/thumbnail and `pending = false`. Otherwise 265 + /// return the entry unchanged. 266 + fn fill_playlist_from_cache(&self, entry: &PlaylistEntry) -> PlaylistEntry { 267 + match self.media_cache.get(&entry.url) { 268 + Some(MediaStatus::Resolved { 269 + title, 270 + duration, 271 + thumbnail, 272 + .. 273 + }) => PlaylistEntry { 274 + id: entry.id, 275 + url: entry.url.clone(), 276 + title: title.clone(), 277 + duration: duration.clone(), 278 + thumbnail: thumbnail.clone(), 279 + source: entry.source.clone(), 280 + added_by: entry.added_by.clone(), 281 + added_at: entry.added_at.clone(), 282 + pending: false, 283 + }, 284 + // Failed or absent: keep placeholder, pending stays true. 285 + _ => entry.clone(), 286 + } 287 + } 288 + 262 289 /// User requested skip. 263 290 fn handle_skip(&mut self, _now: i64) -> Vec<Effect> { 264 291 self.finish_active_and_advance(true) ··· 407 434 } 408 435 } 409 436 437 + // 4. Update all matching playlist entries. Without this, a fresh 438 + // download for a never-before-seen URL would leave the playlist 439 + // entry stuck on "Loading..." — the cache is keyed by URL, so 440 + // resolving it should fan out to every playlist entry that 441 + // shares the URL. 442 + for entry in &mut self.playlist { 443 + if entry.url == url { 444 + entry.title = title.to_string(); 445 + entry.duration = duration.to_string(); 446 + entry.thumbnail.clone_from(thumbnail); 447 + entry.pending = false; 448 + } 449 + } 450 + 410 451 vec![ 411 452 Effect::PersistMetadata { 412 453 // item_id unused — media_cache is keyed by url ··· 434 475 /// Add a playlist entry, replacing any existing entry with the same URL. 435 476 /// Cursor unchanged: existing slot stays current. 436 477 fn handle_playlist_entry_added(&mut self, entry: &PlaylistEntry) -> Vec<Effect> { 478 + // If the URL is already in media_cache as Resolved, fill the 479 + // placeholder with cached metadata and mark it ready. This avoids 480 + // a "Loading..." entry that never updates — the common case is 481 + // adding a track to the playlist from history, where the URL was 482 + // already resolved when it was originally queued. 483 + let entry = self.fill_playlist_from_cache(entry); 437 484 if let Some(slot) = self.playlist.iter().position(|e| e.url == entry.url) { 438 485 self.playlist[slot] = entry.clone(); 439 486 } else { ··· 1403 1450 } 1404 1451 } 1405 1452 1453 + /// Build a placeholder entry as the actor would: "Loading..." with 1454 + /// `pending: true`. This is the entry shape that previously got stuck 1455 + /// when its URL was already in media_cache as Resolved. 1456 + fn placeholder_playlist_entry(id: u64, url: &str) -> PlaylistEntry { 1457 + PlaylistEntry { 1458 + id: PlaylistEntryId(uuid::Uuid::from_u64_pair(0, id)), 1459 + url: url.into(), 1460 + title: "Loading...".into(), 1461 + duration: "--:--".into(), 1462 + thumbnail: None, 1463 + source: "direct".into(), 1464 + added_by: None, 1465 + added_at: "2026-01-01T00:00:00Z".into(), 1466 + pending: true, 1467 + } 1468 + } 1469 + 1406 1470 #[test] 1407 1471 fn test_playlist_add_appends_and_enables() { 1408 1472 let mut s = PlaybackState::default(); ··· 1504 1568 let effects = s.transition(&Event::SetPlaylistEnabled(true), 0); 1505 1569 assert!(s.playlist_enabled); 1506 1570 assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); 1571 + } 1572 + 1573 + // --- playlist cache-fill on add / DownloadResolved --- 1574 + 1575 + #[test] 1576 + fn test_playlist_add_fills_from_resolved_cache() { 1577 + // Bug fix: history-click case. URL is already resolved in 1578 + // media_cache; placeholder must be replaced with cached metadata. 1579 + let mut s = PlaybackState::default(); 1580 + s.media_cache.insert( 1581 + "https://example.com/known".into(), 1582 + MediaStatus::Resolved { 1583 + title: "Known Title".into(), 1584 + duration: "3:21".into(), 1585 + thumbnail: Some("https://img/known.jpg".into()), 1586 + source: "ytdlp".into(), 1587 + }, 1588 + ); 1589 + let effects = s.transition( 1590 + &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/known")), 1591 + 0, 1592 + ); 1593 + assert_eq!(s.playlist.len(), 1); 1594 + let entry = &s.playlist[0]; 1595 + assert_eq!(entry.title, "Known Title"); 1596 + assert_eq!(entry.duration, "3:21"); 1597 + assert_eq!(entry.thumbnail.as_deref(), Some("https://img/known.jpg")); 1598 + assert!(!entry.pending, "filled entry should be !pending"); 1599 + // The effect must carry the corrected values so the store persists 1600 + // them, not the placeholder. 1601 + assert!(effects.iter().any(|e| matches!( 1602 + e, 1603 + Effect::AddPlaylistEntry { 1604 + title, 1605 + duration, 1606 + thumbnail: Some(thumb), 1607 + .. 1608 + } if title == "Known Title" && duration == "3:21" && thumb == "https://img/known.jpg" 1609 + ))); 1610 + } 1611 + 1612 + #[test] 1613 + fn test_playlist_add_keeps_placeholder_when_url_unresolved() { 1614 + // No cache entry, no download yet — placeholder stays until 1615 + // DownloadResolved fires. 1616 + let mut s = PlaybackState::default(); 1617 + let effects = s.transition( 1618 + &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/new")), 1619 + 0, 1620 + ); 1621 + let entry = &s.playlist[0]; 1622 + assert_eq!(entry.title, "Loading..."); 1623 + assert!(entry.pending); 1624 + // Effect still carries the placeholder values; the store will 1625 + // persist them, and DownloadResolved will fix it up later. 1626 + assert!(effects.iter().any(|e| matches!( 1627 + e, 1628 + Effect::AddPlaylistEntry { title, .. } if title == "Loading..." 1629 + ))); 1630 + } 1631 + 1632 + #[test] 1633 + fn test_playlist_add_keeps_placeholder_when_cache_failed() { 1634 + // A Failed cache entry means we know the URL is bad, but we don't 1635 + // have any metadata to fill with. The placeholder stays; the 1636 + // actor's existing fan-out path will mark the entry as failed 1637 + // via MetadataUpdated. 1638 + let mut s = PlaybackState::default(); 1639 + s.media_cache.insert( 1640 + "https://example.com/bad".into(), 1641 + MediaStatus::Failed("yt-dlp error".into()), 1642 + ); 1643 + s.transition( 1644 + &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/bad")), 1645 + 0, 1646 + ); 1647 + let entry = &s.playlist[0]; 1648 + assert_eq!(entry.title, "Loading..."); 1649 + assert!(entry.pending); 1650 + } 1651 + 1652 + #[test] 1653 + fn test_download_resolved_updates_playlist_entry() { 1654 + // Bug fix: DownloadResolved must fan out to playlist entries, not 1655 + // just queue/active. Without this, a fresh download for a 1656 + // never-before-seen URL would leave the playlist entry stuck on 1657 + // "Loading...". 1658 + let mut s = PlaybackState::default(); 1659 + s.transition( 1660 + &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/x")), 1661 + 0, 1662 + ); 1663 + assert!(s.playlist[0].pending); 1664 + 1665 + s.transition( 1666 + &Event::DownloadResolved { 1667 + url: "https://example.com/x".into(), 1668 + title: "Resolved Title".into(), 1669 + duration: "4:20".into(), 1670 + thumbnail: Some("https://img/x.jpg".into()), 1671 + source: "ytdlp".into(), 1672 + }, 1673 + 0, 1674 + ); 1675 + let entry = &s.playlist[0]; 1676 + assert_eq!(entry.title, "Resolved Title"); 1677 + assert_eq!(entry.duration, "4:20"); 1678 + assert_eq!(entry.thumbnail.as_deref(), Some("https://img/x.jpg")); 1679 + assert!(!entry.pending); 1680 + } 1681 + 1682 + #[test] 1683 + fn test_download_resolved_updates_only_matching_playlist_entries() { 1684 + // Multiple entries with different URLs: only the matching one 1685 + // should be updated. 1686 + let mut s = PlaybackState::default(); 1687 + s.transition( 1688 + &Event::PlaylistEntryAdded(placeholder_playlist_entry(1, "https://example.com/a")), 1689 + 0, 1690 + ); 1691 + s.transition( 1692 + &Event::PlaylistEntryAdded(placeholder_playlist_entry(2, "https://example.com/b")), 1693 + 0, 1694 + ); 1695 + s.transition( 1696 + &Event::DownloadResolved { 1697 + url: "https://example.com/a".into(), 1698 + title: "A Resolved".into(), 1699 + duration: "1:00".into(), 1700 + thumbnail: None, 1701 + source: "ytdlp".into(), 1702 + }, 1703 + 0, 1704 + ); 1705 + assert_eq!(s.playlist[0].title, "A Resolved"); 1706 + assert!(!s.playlist[0].pending); 1707 + assert_eq!(s.playlist[1].title, "Loading..."); 1708 + assert!(s.playlist[1].pending); 1507 1709 } 1508 1710 1509 1711 #[test]