diff --git a/Cargo.lock b/Cargo.lock index 1662a5e..73c5ecc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1280,7 +1280,7 @@ dependencies = [ [[package]] name = "flex" -version = "0.16.1" +version = "0.17.0" dependencies = [ "anyhow", "chrono", @@ -1304,7 +1304,7 @@ dependencies = [ [[package]] name = "flex-anilist" -version = "0.16.1" +version = "0.17.0" dependencies = [ "flex-plugin", "ureq", @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "flex-core" -version = "0.16.1" +version = "0.17.0" dependencies = [ "anyhow", "directories", @@ -1337,7 +1337,7 @@ dependencies = [ [[package]] name = "flex-lotus-lamp" -version = "0.16.1" +version = "0.17.0" dependencies = [ "flex-plugin", "lotus-lamp", @@ -1346,7 +1346,7 @@ dependencies = [ [[package]] name = "flex-plugin" -version = "0.16.1" +version = "0.17.0" dependencies = [ "serde", "serde_json", @@ -1354,7 +1354,7 @@ dependencies = [ [[package]] name = "flex-trakt" -version = "0.16.1" +version = "0.17.0" dependencies = [ "flex-plugin", "ureq", @@ -1362,7 +1362,7 @@ dependencies = [ [[package]] name = "flex-web" -version = "0.16.1" +version = "0.17.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 3ad935a..c990c98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ default-members = ["crates/*"] [workspace.package] edition = "2024" -version = "0.16.1" +version = "0.17.0" license = "MIT" [profile.release] diff --git a/README.md b/README.md index 90237f8..7af08cc 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ what you've watched: Settings → Sync with other devices, then pick a folder th reach, like a folder on your NAS. Each Flex keeps its own libraries (pointing at wherever the media is on that machine, as long as the libraries have the same names) and they swap watched state, resume points, history, the watchlist, matches you fixed by hand and -YouTube subscriptions through that folder every 30 seconds or so. +YouTube subscriptions and what you've watched there through that folder every 30 seconds or so. ## YouTube @@ -77,7 +77,8 @@ Add channels under Channels by pasting a link or an @handle, or import your exis subscriptions from Google Takeout (`subscriptions.csv`), FreeTube, NewPipe or an OPML file. New videos are picked up from each channel's feed every half an hour, so there's no API key to set up. Search works too, either on the YouTube page or from the main search -(pick the YouTube scope at the top of the results). +(pick the YouTube scope at the top of the results). History lists the videos you've +played, most recent first. Videos take a few seconds to start, since yt-dlp has to ask YouTube for them first, but Flex starts on whichever video you have highlighted, so it's usually ready by the time you diff --git a/crates/flex-app/Cargo.toml b/crates/flex-app/Cargo.toml index cf827f5..e72e5c5 100644 --- a/crates/flex-app/Cargo.toml +++ b/crates/flex-app/Cargo.toml @@ -9,9 +9,9 @@ anyhow = "1.0.104" chrono = { version = "0.4.45", default-features = false, features = ["clock"] } eframe = { version = "0.36", default-features = false, features = ["glow", "default_fonts"] } env_logger = "0.11.11" -flex-core = { version = "0.16.1", path = "../flex-core" } -flex-plugin = { version = "0.16.1", path = "../flex-plugin" } -flex-web = { version = "0.16.1", path = "../flex-web" } +flex-core = { version = "0.17.0", path = "../flex-core" } +flex-plugin = { version = "0.17.0", path = "../flex-plugin" } +flex-web = { version = "0.17.0", path = "../flex-web" } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } libloading = "0.9" log = "0.4.34" diff --git a/crates/flex-app/src/screens/youtube.rs b/crates/flex-app/src/screens/youtube.rs index f643269..503d9ae 100644 --- a/crates/flex-app/src/screens/youtube.rs +++ b/crates/flex-app/src/screens/youtube.rs @@ -235,9 +235,9 @@ pub struct YouTubeHome { loaded: Option, } -/// Search, Channels, and which channels' videos to show. -fn home_buttons(sh: &Shared) -> [&'static str; 3] { - ["Search…", "Channels", if sh.youtube_focused_only() { "Showing: focused" } else { "Showing: all channels" }] +/// Search, Channels, History, and which channels' videos to show. +fn home_buttons(sh: &Shared) -> [&'static str; 4] { + ["Search…", "Channels", "History", if sh.youtube_focused_only() { "Showing: focused" } else { "Showing: all channels" }] } impl YouTubeHome { @@ -275,6 +275,7 @@ impl Screen for YouTubeHome { match self.view.handle(press, home_buttons(sh).len(), self.items.len()) { GridInput::Button(0) => Nav::push(TextEntry::youtube_search()), GridInput::Button(1) => Nav::push(ChannelList::default()), + GridInput::Button(2) => Nav::push(YouTubeHistory::default()), GridInput::Button(_) => { let on = !sh.youtube_focused_only(); sh.set_youtube_focused_only(on); @@ -307,6 +308,59 @@ impl Screen for YouTubeHome { } } +const HISTORY: usize = 500; + +/// Videos played, most recently first (synced like the rest of YouTube's progress). +#[derive(Default)] +pub struct YouTubeHistory { + view: VideoGrid, + items: Vec, + loaded: Option, +} + +impl YouTubeHistory { + fn refresh(&mut self, sh: &Shared) { + if self.loaded == Some(sh.youtube.version) { + return; + } + self.loaded = Some(sh.youtube.version); + let now = now_unix(); + self.items = sh + .yt_history(HISTORY) + .into_iter() + .map(|(id, p)| { + let how = match (p.watched, p.position, p.duration) { + (true, _, _) => "watched".to_owned(), + (false, Some(pos), Some(dur)) => format!("{} left", theme::format_time(dur - pos)), + _ => "started".to_owned(), + }; + let detail = format!("{} · {} · {how}", p.channel, ago(p.updated_at, now)); + let item = VideoItem { id, title: p.title.clone(), channel: p.channel.clone(), channel_id: String::new(), detail, fraction: None, watched: false }; + with_progress(item, Some(&p)) + }) + .collect(); + } +} + +impl Screen for YouTubeHistory { + fn handle(&mut self, sh: &mut Shared, press: Press) -> Nav { + if press.is(Action::Back) { + return Nav::Pop; + } + self.refresh(sh); + video_input(self.view.handle(press, 0, self.items.len()), &self.items).unwrap_or(Nav::None) + } + + fn ui(&mut self, ui: &mut Ui, sh: &mut Shared, rect: Rect, focused: bool) -> Nav { + self.refresh(sh); + let body = widgets::header(ui.painter(), rect, "History", "YouTube", ui.style()); + let items = std::mem::take(&mut self.items); + self.view.ui(ui, sh, body, &[], &items, "Nothing watched yet", focused); + self.items = items; + Nav::None + } +} + /// One channel's videos. pub struct ChannelPage { id: String, diff --git a/crates/flex-app/src/shared.rs b/crates/flex-app/src/shared.rs index d0cefc6..8c69193 100644 --- a/crates/flex-app/src/shared.rs +++ b/crates/flex-app/src/shared.rs @@ -1744,6 +1744,10 @@ impl Shared { self.db.yt_progress(id).ok().flatten() } + pub fn yt_history(&self, limit: usize) -> Vec<(String, flex_core::youtube::store::WatchProgress)> { + self.db.yt_history(limit).unwrap_or_default() + } + pub fn yt_progress_all(&self) -> HashMap { self.db.yt_progress_all().unwrap_or_default() } diff --git a/crates/flex-core/Cargo.toml b/crates/flex-core/Cargo.toml index 83b33fb..dbd1203 100644 --- a/crates/flex-core/Cargo.toml +++ b/crates/flex-core/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] anyhow = "1.0.104" directories = "6.0.0" -flex-plugin = { version = "0.16.1", path = "../flex-plugin" } +flex-plugin = { version = "0.17.0", path = "../flex-plugin" } hunch = "2" lofty = "0.25.4" log = "0.4.34" diff --git a/crates/flex-core/src/youtube/store.rs b/crates/flex-core/src/youtube/store.rs index a69146a..1171059 100644 --- a/crates/flex-core/src/youtube/store.rs +++ b/crates/flex-core/src/youtube/store.rs @@ -145,6 +145,22 @@ impl Db { Ok(rows.collect::>()?) } + /// Videos actually played (not only marked watched), most recently first: (id, progress). + /// One entry per video, at the last time it was played. + pub fn yt_history(&self, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, title, channel, position, duration, watched, updated_at FROM yt_progress + WHERE duration IS NOT NULL ORDER BY updated_at DESC LIMIT ?1", + )?; + let rows = stmt.query_map([limit as i64], |r| { + Ok(( + r.get::<_, String>(0)?, + WatchProgress { title: r.get(1)?, channel: r.get(2)?, position: r.get(3)?, duration: r.get(4)?, watched: r.get(5)?, updated_at: r.get(6)? }, + )) + })?; + Ok(rows.collect::>()?) + } + /// Records where playback got to. Past `threshold` (e.g. 0.9) of the way through, the /// video counts as watched and won't resume. Returns the saved progress. #[allow(clippy::too_many_arguments)] @@ -269,4 +285,18 @@ mod tests { assert!(!db.yt_progress("v").unwrap().unwrap().watched); assert_eq!(db.yt_progress_all().unwrap().len(), 2); } + + #[test] + fn history_lists_played_videos_newest_first() { + let db = Db::open_in_memory().unwrap(); + db.record_yt_progress("a", "A", "C", 60.0, 600.0, 0.9, 10).unwrap(); + db.record_yt_progress("b", "B", "C", 590.0, 600.0, 0.9, 20).unwrap(); + // Marked watched from a list without being played: not history. + db.set_yt_watched("c", "C", "C", true, 30).unwrap(); + let ids = |db: &Db| db.yt_history(10).unwrap().into_iter().map(|(id, _)| id).collect::>(); + assert_eq!(ids(&db), ["b", "a"]); + db.record_yt_progress("a", "A", "C", 120.0, 600.0, 0.9, 40).unwrap(); + assert_eq!(ids(&db), ["a", "b"], "playing again moves it to the top"); + assert_eq!(db.yt_history(1).unwrap().len(), 1); + } } diff --git a/crates/flex-web/Cargo.toml b/crates/flex-web/Cargo.toml index 7e05202..15ad9bc 100644 --- a/crates/flex-web/Cargo.toml +++ b/crates/flex-web/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] anyhow = "1.0.104" axum = "0.8.9" -flex-core = { version = "0.16.1", path = "../flex-core" } +flex-core = { version = "0.17.0", path = "../flex-core" } log = "0.4.34" serde = { version = "1.0.229", features = ["derive", "rc"] } serde_json = "1.0.151" diff --git a/plugins/anilist/Cargo.toml b/plugins/anilist/Cargo.toml index fedb288..5dc4fd1 100644 --- a/plugins/anilist/Cargo.toml +++ b/plugins/anilist/Cargo.toml @@ -7,5 +7,5 @@ license.workspace = true publish = false [dependencies] -flex-plugin = { version = "0.16.1", path = "../../crates/flex-plugin" } +flex-plugin = { version = "0.17.0", path = "../../crates/flex-plugin" } ureq = { version = "3", features = ["json"] } diff --git a/plugins/anilist/plugin.toml b/plugins/anilist/plugin.toml index 0502cc4..8d61e6c 100644 --- a/plugins/anilist/plugin.toml +++ b/plugins/anilist/plugin.toml @@ -1,6 +1,6 @@ name = "AniList" description = "Updates your AniList progress as you watch anime in Flex" -version = "0.16.1" +version = "0.17.0" command = "flex-anilist" subscribe = ["playback"] diff --git a/plugins/lotus-lamp/Cargo.toml b/plugins/lotus-lamp/Cargo.toml index f6da222..0c3762a 100644 --- a/plugins/lotus-lamp/Cargo.toml +++ b/plugins/lotus-lamp/Cargo.toml @@ -7,6 +7,6 @@ license.workspace = true publish = false [dependencies] -flex-plugin = { version = "0.16.1", path = "../../crates/flex-plugin" } +flex-plugin = { version = "0.17.0", path = "../../crates/flex-plugin" } lotus-lamp = { git = "https://github.com/bombpersons/lotus-lamp-rs", rev = "59cf5ccaa0007f006f1812fd02cc5991aff11e72" } tokio = { version = "1.53.2", features = ["rt", "time", "sync", "macros"] } diff --git a/plugins/lotus-lamp/plugin.toml b/plugins/lotus-lamp/plugin.toml index 0d4d655..dd63a73 100644 --- a/plugins/lotus-lamp/plugin.toml +++ b/plugins/lotus-lamp/plugin.toml @@ -1,6 +1,6 @@ name = "LED strip (Lotus Lamp)" description = "Lights a Bluetooth LED strip behind the TV with the colours on screen" -version = "0.16.1" +version = "0.17.0" command = "flex-lotus-lamp" subscribe = ["light"] diff --git a/plugins/trakt/Cargo.toml b/plugins/trakt/Cargo.toml index dc0cbd1..f97d159 100644 --- a/plugins/trakt/Cargo.toml +++ b/plugins/trakt/Cargo.toml @@ -7,5 +7,5 @@ license.workspace = true publish = false [dependencies] -flex-plugin = { version = "0.16.1", path = "../../crates/flex-plugin" } +flex-plugin = { version = "0.17.0", path = "../../crates/flex-plugin" } ureq = { version = "3", features = ["json"] } diff --git a/plugins/trakt/plugin.toml b/plugins/trakt/plugin.toml index 93e9c1d..f042d05 100644 --- a/plugins/trakt/plugin.toml +++ b/plugins/trakt/plugin.toml @@ -1,6 +1,6 @@ name = "Trakt" description = "Keeps your Trakt history up to date with what you watch in Flex" -version = "0.16.1" +version = "0.17.0" command = "flex-trakt" subscribe = ["playback"]