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.

store: room settings columns + name resolver

karitham (Jun 1, 2026, 3:52 PM +0200) 84295c2f d88da9c6

+244 -27
+31 -18
src/state.rs
··· 755 755 // enabled by default (no way to persist "disabled" yet, so we treat 756 756 // a non-empty playlist as the user's intent to autoplay). 757 757 state.playlist = snapshot.playlist; 758 - if !state.playlist.is_empty() { 759 - state.autoplay_enabled = true; 760 - } 758 + // Room settings come from the snapshot (DB row), not from 759 + // defaults. The "force autoplay if playlist is non-empty" 760 + // override is gone — the user can now turn autoplay off even 761 + // with a non-empty playlist. 762 + state.autoplay_enabled = snapshot.autoplay_enabled; 763 + state.skip_threshold = snapshot.skip_threshold; 761 764 762 765 if !effects.is_empty() { 763 766 effects.push(Effect::PublishSnapshot); ··· 1494 1497 // And the active track (track 1) shouldn't be affected by this. 1495 1498 assert_eq!(s.active.as_ref().unwrap().title, "Real"); 1496 1499 // Sanity: the persist effect should carry the resolved track. 1497 - assert!(effects 1498 - .iter() 1499 - .any(|e| matches!(e, Effect::PersistQueuedTrack(q) if q.title == "Real"))); 1500 + assert!( 1501 + effects 1502 + .iter() 1503 + .any(|e| matches!(e, Effect::PersistQueuedTrack(q) if q.title == "Real")) 1504 + ); 1500 1505 } 1501 1506 1502 1507 #[test] ··· 1563 1568 let effects = s.transition(&Event::PlaylistEntryAdded(e.clone()), 0); 1564 1569 assert_eq!(s.playlist.len(), 1); 1565 1570 assert!(s.autoplay_enabled); 1566 - assert!(effects 1567 - .iter() 1568 - .any(|e| matches!(e, Effect::AddPlaylistEntry { .. }))); 1571 + assert!( 1572 + effects 1573 + .iter() 1574 + .any(|e| matches!(e, Effect::AddPlaylistEntry { .. })) 1575 + ); 1569 1576 assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); 1570 1577 } 1571 1578 ··· 1606 1613 assert_eq!(s.playlist.len(), 2); 1607 1614 // Cursor should be clamped to last valid index (1). 1608 1615 assert_eq!(s.playlist_cursor, 1); 1609 - assert!(effects 1610 - .iter() 1611 - .any(|e| matches!(e, Effect::RemovePlaylistEntry(_)))); 1616 + assert!( 1617 + effects 1618 + .iter() 1619 + .any(|e| matches!(e, Effect::RemovePlaylistEntry(_))) 1620 + ); 1612 1621 } 1613 1622 1614 1623 #[test] ··· 2174 2183 // Title preserved — the entry was already resolved. 2175 2184 assert_eq!(s.playlist[0].title, original_title); 2176 2185 // No metadata effect needed (no pending entries to flip). 2177 - assert!(!effects 2178 - .iter() 2179 - .any(|e| matches!(e, Effect::PersistMetadata { .. }))); 2186 + assert!( 2187 + !effects 2188 + .iter() 2189 + .any(|e| matches!(e, Effect::PersistMetadata { .. })) 2190 + ); 2180 2191 assert!(!effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); 2181 2192 } 2182 2193 ··· 2463 2474 "removed track must not remain in queue", 2464 2475 ); 2465 2476 assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); 2466 - assert!(effects 2467 - .iter() 2468 - .any(|e| matches!(e, Effect::RemoveQueuedTrack { .. }))); 2477 + assert!( 2478 + effects 2479 + .iter() 2480 + .any(|e| matches!(e, Effect::RemoveQueuedTrack { .. })) 2481 + ); 2469 2482 } else { 2470 2483 assert!(effects.is_empty(), "no-op remove must return no effects"); 2471 2484 }
+213 -9
src/store.rs
··· 56 56 /// Pre-resolved perpetual playlist entries. 57 57 pub playlist: Vec<PlaylistEntry>, 58 58 pub chat_messages: Vec<ChatMessage>, 59 + /// Room settings: when true, idle rooms auto-fill from the 60 + /// playlist (also drives the playlist panel visibility in the UI). 61 + pub autoplay_enabled: bool, 62 + /// Skip-vote threshold as a percentage of room size (0..=100). 63 + pub skip_threshold: u8, 59 64 } 60 65 61 66 /// Output returned by [`RoomStore::persist`]. ··· 89 94 /// Load the full snapshot for a room, or [`None`] if it does not exist. 90 95 async fn load(&self, room_id: &str) -> Result<Option<RoomSnapshot>, sqlx::Error>; 91 96 97 + /// Look up a room id by its human-readable name. Returns `None` 98 + /// when the name is unknown. Used by URL routing: rooms are 99 + /// addressed by name in URLs and resolved to ids for storage. 100 + #[allow(dead_code)] 101 + async fn name_to_id(&self, name: &str) -> Result<Option<String>, sqlx::Error>; 102 + 103 + /// Update the room's settings (autoplay toggle + skip threshold). 104 + /// Both fields are written in a single UPDATE so the row is never 105 + /// observed in a half-applied state. 106 + #[allow(dead_code)] 107 + async fn update_room_settings( 108 + &self, 109 + room_id: &str, 110 + autoplay_enabled: bool, 111 + skip_threshold: u8, 112 + ) -> Result<(), sqlx::Error>; 113 + 92 114 /// Retrieve the most recent chat messages for a room, newest first. 93 115 #[allow(dead_code)] 94 116 async fn recent_chat(&self, room_id: &str, limit: i64) 95 - -> Result<Vec<ChatMessage>, sqlx::Error>; 117 + -> Result<Vec<ChatMessage>, sqlx::Error>; 96 118 97 119 /// Delete a room and all its associated data (CASCADE). 98 120 #[allow(dead_code)] ··· 140 162 history: Vec<FinishedTrack>, 141 163 playlist: Vec<InMemoryPlaylistEntry>, 142 164 chat_messages: Vec<ChatMessage>, 165 + autoplay_enabled: bool, 166 + skip_threshold: u8, 143 167 } 144 168 145 169 /// Thread-safe in-memory store backed by a [`Mutex`]-protected [`HashMap`]. ··· 180 204 history: Vec::new(), 181 205 playlist: Vec::new(), 182 206 chat_messages: Vec::new(), 207 + autoplay_enabled: true, 208 + skip_threshold: 34, 183 209 }, 184 210 ); 185 211 Ok(()) ··· 323 349 history: room.history.clone(), 324 350 playlist: room.playlist.iter().map(|e| e.clone().into()).collect(), 325 351 chat_messages: room.chat_messages.clone(), 352 + autoplay_enabled: room.autoplay_enabled, 353 + skip_threshold: room.skip_threshold, 326 354 })) 355 + } 356 + 357 + async fn name_to_id(&self, name: &str) -> Result<Option<String>, sqlx::Error> { 358 + let inner = self.inner.lock().expect("InMemoryStore mutex poisoned"); 359 + Ok(inner 360 + .iter() 361 + .find(|(_, r)| r.name == name) 362 + .map(|(id, _)| id.clone())) 363 + } 364 + 365 + async fn update_room_settings( 366 + &self, 367 + room_id: &str, 368 + autoplay_enabled: bool, 369 + skip_threshold: u8, 370 + ) -> Result<(), sqlx::Error> { 371 + let mut inner = self.inner.lock().expect("InMemoryStore mutex poisoned"); 372 + if let Some(room) = inner.get_mut(room_id) { 373 + room.autoplay_enabled = autoplay_enabled; 374 + room.skip_threshold = skip_threshold; 375 + } 376 + Ok(()) 327 377 } 328 378 329 379 async fn recent_chat( ··· 389 439 390 440 /// Create a new `SqliteStore` and run schema migrations. 391 441 /// 392 - /// Idempotent: all tables use `CREATE TABLE IF NOT EXISTS`. 442 + /// Idempotent: all tables use `CREATE TABLE IF NOT EXISTS`. New 443 + /// columns are added via `ALTER TABLE ADD COLUMN` with errors 444 + /// ignored (so re-running on an already-migrated DB is a no-op). 393 445 pub(crate) async fn new(pool: Pool<Sqlite>) -> Result<Self, sqlx::Error> { 394 446 sqlx::query( 395 447 "CREATE TABLE IF NOT EXISTS rooms ( ··· 402 454 .execute(&pool) 403 455 .await?; 404 456 457 + // Migration: add room settings columns. Existing rows pick up 458 + // the schema defaults. Errors on duplicate-column are ignored. 459 + for sql in [ 460 + "ALTER TABLE rooms ADD COLUMN autoplay_enabled INTEGER NOT NULL DEFAULT 1", 461 + "ALTER TABLE rooms ADD COLUMN skip_threshold INTEGER NOT NULL DEFAULT 34", 462 + ] { 463 + if let Err(e) = sqlx::query(sql).execute(&pool).await { 464 + // "duplicate column name" means the migration already 465 + // ran. Anything else is a real error. 466 + if !e.to_string().contains("duplicate column") { 467 + return Err(e); 468 + } 469 + } 470 + } 471 + 405 472 // Canonical resolved media keyed by URL. Tracks and playlist entries 406 473 // reference this table to avoid duplicating metadata and downloads. 407 474 sqlx::query( ··· 639 706 .execute(&mut *tx) 640 707 .await?; 641 708 } 709 + Effect::PersistRoomSettings { 710 + autoplay_enabled, 711 + skip_threshold, 712 + } => { 713 + sqlx::query( 714 + "UPDATE rooms 715 + SET autoplay_enabled = ?1, skip_threshold = ?2 716 + WHERE id = ?3", 717 + ) 718 + .bind(autoplay_enabled) 719 + .bind(i64::from(*skip_threshold)) 720 + .bind(room_id) 721 + .execute(&mut *tx) 722 + .await?; 723 + } 642 724 _ => {} 643 725 } 644 726 } ··· 744 826 } 745 827 746 828 async fn load(&self, room_id: &str) -> Result<Option<RoomSnapshot>, sqlx::Error> { 747 - // 1. Room existence check. 748 - let room_name: Option<String> = sqlx::query_scalar("SELECT name FROM rooms WHERE id = ?") 749 - .bind(room_id) 750 - .fetch_optional(&self.pool) 751 - .await?; 829 + // 1. Room existence check + settings. Reads in a single 830 + // SELECT so the existence, name, and settings come from the 831 + // same row snapshot. 832 + let room_row: Option<(String, bool, i64)> = sqlx::query_as( 833 + "SELECT name, autoplay_enabled, skip_threshold 834 + FROM rooms WHERE id = ?", 835 + ) 836 + .bind(room_id) 837 + .fetch_optional(&self.pool) 838 + .await?; 752 839 753 - let room_name = match room_name { 754 - Some(name) => name, 840 + let (room_name, autoplay_enabled, skip_threshold) = match room_row { 841 + Some((name, ap, st)) => (name, ap, st.clamp(0, 100) as u8), 755 842 None => return Ok(None), 756 843 }; 757 844 ··· 903 990 history, 904 991 playlist, 905 992 chat_messages, 993 + autoplay_enabled, 994 + skip_threshold, 906 995 })) 907 996 } 908 997 998 + async fn name_to_id(&self, name: &str) -> Result<Option<String>, sqlx::Error> { 999 + let id: Option<String> = sqlx::query_scalar("SELECT id FROM rooms WHERE name = ?") 1000 + .bind(name) 1001 + .fetch_optional(&self.pool) 1002 + .await?; 1003 + Ok(id) 1004 + } 1005 + 1006 + async fn update_room_settings( 1007 + &self, 1008 + room_id: &str, 1009 + autoplay_enabled: bool, 1010 + skip_threshold: u8, 1011 + ) -> Result<(), sqlx::Error> { 1012 + sqlx::query( 1013 + "UPDATE rooms 1014 + SET autoplay_enabled = ?1, skip_threshold = ?2 1015 + WHERE id = ?3", 1016 + ) 1017 + .bind(autoplay_enabled) 1018 + .bind(skip_threshold as i64) 1019 + .bind(room_id) 1020 + .execute(&self.pool) 1021 + .await?; 1022 + Ok(()) 1023 + } 1024 + 909 1025 async fn recent_chat( 910 1026 &self, 911 1027 room_id: &str, ··· 1674 1790 assert_eq!(t.title, "Loading..."); 1675 1791 assert_eq!(t.duration, "--:--"); 1676 1792 assert!(t.pending); 1793 + } 1794 + 1795 + #[tokio::test] 1796 + async fn sqlite_new_room_uses_default_room_settings() { 1797 + // A freshly created room picks up the column defaults 1798 + // (autoplay=true, skip_threshold=34) until explicitly changed. 1799 + let store = sqlite_test_store().await; 1800 + store.create_room("r", "R").await.unwrap(); 1801 + let snap = store.load("r").await.unwrap().unwrap(); 1802 + assert!(snap.autoplay_enabled); 1803 + assert_eq!(snap.skip_threshold, 34); 1804 + } 1805 + 1806 + #[tokio::test] 1807 + async fn sqlite_update_room_settings_persists_and_loads() { 1808 + let store = sqlite_test_store().await; 1809 + store.create_room("r", "R").await.unwrap(); 1810 + 1811 + store.update_room_settings("r", false, 75).await.unwrap(); 1812 + 1813 + let snap = store.load("r").await.unwrap().unwrap(); 1814 + assert!(!snap.autoplay_enabled); 1815 + assert_eq!(snap.skip_threshold, 75); 1816 + } 1817 + 1818 + #[tokio::test] 1819 + async fn sqlite_persist_room_settings_effect_round_trips() { 1820 + // The effect-driven path: actor sends 1821 + // Effect::PersistRoomSettings, store applies it, load reflects 1822 + // the new values. 1823 + let store = sqlite_test_store().await; 1824 + store.create_room("r", "R").await.unwrap(); 1825 + 1826 + store 1827 + .persist( 1828 + "r", 1829 + &[Effect::PersistRoomSettings { 1830 + autoplay_enabled: false, 1831 + skip_threshold: 50, 1832 + }], 1833 + ) 1834 + .await 1835 + .unwrap(); 1836 + 1837 + let snap = store.load("r").await.unwrap().unwrap(); 1838 + assert!(!snap.autoplay_enabled); 1839 + assert_eq!(snap.skip_threshold, 50); 1840 + } 1841 + 1842 + #[tokio::test] 1843 + async fn sqlite_name_to_id_resolves_existing_room() { 1844 + let store = sqlite_test_store().await; 1845 + store 1846 + .create_room("uuid-abc", "happy-otters-dance") 1847 + .await 1848 + .unwrap(); 1849 + let resolved = store.name_to_id("happy-otters-dance").await.unwrap(); 1850 + assert_eq!(resolved, Some("uuid-abc".to_string())); 1851 + } 1852 + 1853 + #[tokio::test] 1854 + async fn sqlite_name_to_id_returns_none_for_unknown_name() { 1855 + let store = sqlite_test_store().await; 1856 + store 1857 + .create_room("uuid-abc", "happy-otters-dance") 1858 + .await 1859 + .unwrap(); 1860 + let resolved = store.name_to_id("sad-otters-mope").await.unwrap(); 1861 + assert!(resolved.is_none()); 1862 + } 1863 + 1864 + #[tokio::test] 1865 + async fn sqlite_load_clamps_out_of_range_skip_threshold() { 1866 + // Defensive: if a future bug writes 200 into skip_threshold, 1867 + // the load should clamp to 100 rather than blow up the 1868 + // frontend's u8 JSON deserialization. 1869 + let store = sqlite_test_store().await; 1870 + store.create_room("r", "R").await.unwrap(); 1871 + // Bypass the public API to plant a bogus value. 1872 + let SqliteStore { pool } = &store; 1873 + let raw_pool = pool.clone(); 1874 + sqlx::query("UPDATE rooms SET skip_threshold = 200 WHERE id = 'r'") 1875 + .execute(&raw_pool) 1876 + .await 1877 + .unwrap(); 1878 + 1879 + let snap = store.load("r").await.unwrap().unwrap(); 1880 + assert_eq!(snap.skip_threshold, 100); 1677 1881 } 1678 1882 }