diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 98b0f2d..b6aeb53 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -44,8 +44,9 @@ pub struct AppState { /// URLs last hours, so replaying a video should not pay for yt-dlp again. pub streams: Arc), (String, std::time::Instant)>>>, /// argv prefix that runs yt-dlp: either a system binary, or the bundled - /// Python interpreter followed by the zipapp. - pub yt_dlp: Vec, + /// Python interpreter followed by the zipapp. Mutable so an in-app update + /// takes effect without a restart. + pub yt_dlp_argv: Arc>>, /// Value for yt-dlp's --cookies-from-browser, when signed in. pub cookies_from: Arc>>, } @@ -53,8 +54,9 @@ pub struct AppState { impl AppState { /// A ready-to-configure yt-dlp process, carrying cookies when configured. async fn yt_dlp(&self) -> tokio::process::Command { - let mut cmd = tokio::process::Command::new(&self.yt_dlp[0]); - cmd.args(&self.yt_dlp[1..]); + let argv = self.yt_dlp_argv.lock().await.clone(); + let mut cmd = tokio::process::Command::new(&argv[0]); + cmd.args(&argv[1..]); if let Some(from) = self.cookies_from.lock().await.clone() { cmd.arg("--cookies-from-browser").arg(from); } @@ -252,7 +254,7 @@ fn bin(name: &str) -> String { /// the app runs its own interpreter against the yt-dlp zipapp. The 3MB zipapp /// plus a portable Python starts in about half a second; the official /// PyInstaller binary took eight, because it unpacks 37MB on every call. -fn resolve_yt_dlp(app: &AppHandle) -> Vec { +fn resolve_yt_dlp(app: &AppHandle, app_data: &std::path::Path) -> Vec { for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] { let candidate = format!("{prefix}/yt-dlp"); if std::path::Path::new(&candidate).exists() { @@ -263,7 +265,9 @@ fn resolve_yt_dlp(app: &AppHandle) -> Vec { // python3 and python are symlinks; name the real file so the bundle // does not depend on symlinks surviving the copy. let python = res.join("python/bin/python3.12"); - let zipapp = res.join("yt-dlp.pyz"); + // An in-app update lands in app data; the bundle is read-only. + let updated = updated_yt_dlp(app_data); + let zipapp = if updated.exists() { updated } else { res.join("yt-dlp.pyz") }; if python.exists() && zipapp.exists() { return vec![ python.to_string_lossy().to_string(), @@ -326,10 +330,19 @@ pub async fn check_prereqs(state: State<'_, AppState>) -> Result>().join(" "); @@ -729,13 +742,23 @@ const DURATION_CONCURRENCY: usize = 1; /// /// yt-dlp would cost seconds per video; a ranged GET of the watch page costs /// about one, and only ever runs for videos whose length is still unknown. +/// +/// `visible` is what the user is actually looking at. Filling globally by date +/// instead left most of a channel's videos blank forever, because the newest +/// few across all subscriptions always won the queue. #[tauri::command] -pub async fn fetch_durations(state: State<'_, AppState>) -> Result { - let ids = state - .db - .lock() - .await - .videos_missing_duration(DURATION_BATCH as i64)?; +pub async fn fetch_durations( + visible: Vec, + state: State<'_, AppState>, +) -> Result { + let ids = { + let db = state.db.lock().await; + if visible.is_empty() { + db.videos_missing_duration(DURATION_BATCH as i64)? + } else { + db.filter_missing_duration(&visible, DURATION_BATCH as i64)? + } + }; if ids.is_empty() { return Ok(0); } @@ -793,6 +816,97 @@ pub fn parse_length_seconds(body: &str) -> Option { rest[..end].parse().ok().filter(|n| *n > 0) } +/// Where an updated yt-dlp is kept. The bundle is read-only, so a newer copy +/// lives in app data and takes precedence over the shipped one. +fn updated_yt_dlp(app_data: &std::path::Path) -> PathBuf { + app_data.join("bin").join("yt-dlp.pyz") +} + +#[derive(Serialize)] +pub struct UpdateStatus { + pub current: Option, + pub latest: Option, + pub up_to_date: bool, +} + +/// Asks GitHub what the newest yt-dlp release is. +/// +/// Only yt-dlp is checked. It breaks whenever YouTube changes something, so +/// staying current matters; ffmpeg is stable and ships with the app. +#[tauri::command] +pub async fn check_yt_dlp_update(state: State<'_, AppState>) -> Result { + let current = version_of(&state.yt_dlp_argv.lock().await.clone(), "--version").await; + + let latest = state + .http + .get("https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest") + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .await + .map_err(|e| format!("Could not reach GitHub: {e}"))? + .text() + .await + .map_err(|e| format!("Unexpected reply from GitHub: {e}"))?; + let latest = serde_json::from_str::(&latest) + .ok() + .and_then(|v| v.get("tag_name").and_then(|t| t.as_str()).map(str::to_string)); + + let up_to_date = match (¤t, &latest) { + (Some(c), Some(l)) => c.trim() == l.trim(), + _ => false, + }; + Ok(UpdateStatus { current, latest, up_to_date }) +} + +/// Downloads the newest yt-dlp zipapp into app data and switches to it. +#[tauri::command] +pub async fn update_yt_dlp(state: State<'_, AppState>) -> Result { + let dest = updated_yt_dlp(&state.app_data); + if let Some(dir) = dest.parent() { + tokio::fs::create_dir_all(dir) + .await + .map_err(|e| format!("Cannot create {}: {e}", dir.display()))?; + } + + let bytes = state + .http + .get("https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp") + .send() + .await + .map_err(|e| format!("Download failed: {e}"))? + .bytes() + .await + .map_err(|e| format!("Download failed: {e}"))?; + + if bytes.len() < 1_000_000 { + return Err("That download does not look like yt-dlp; leaving the current one in place.".into()); + } + + // Write beside the target then rename, so a failure never leaves a + // half-written interpreter in place of a working one. + let tmp = dest.with_extension("pyz.part"); + tokio::fs::write(&tmp, &bytes) + .await + .map_err(|e| format!("Cannot write update: {e}"))?; + tokio::fs::rename(&tmp, &dest) + .await + .map_err(|e| format!("Cannot install update: {e}"))?; + + // Point at the new copy without a restart. + let mut argv = state.yt_dlp_argv.lock().await; + if argv.len() > 1 { + let last = argv.len() - 1; + argv[last] = dest.to_string_lossy().to_string(); + } + let probe = argv.clone(); + drop(argv); + + *state.prereqs.lock().await = None; + version_of(&probe, "--version") + .await + .ok_or_else(|| "The update was installed but will not run.".to_string()) +} + #[tauri::command] pub async fn list_channels(state: State<'_, AppState>) -> Result, String> { state.db.lock().await.list_channels() @@ -844,12 +958,17 @@ pub async fn refresh_feeds( done += 1; match res { Ok(videos) => { + let mut db = state.db.lock().await; if !videos.is_empty() { - let mut db = state.db.lock().await; new_videos += db.upsert_videos(&videos)?; } + // A channel that recovers should stop being flagged. + db.set_channel_result(&cid, None)?; + } + Err(e) => { + state.db.lock().await.set_channel_result(&cid, Some(&e))?; + failures.push(format!("{cid}: {e}")); } - Err(e) => failures.push(format!("{cid}: {e}")), } let _ = app.emit( "refresh:progress", @@ -1331,6 +1450,8 @@ pub fn build_state(app: &AppHandle) -> Result { .build() .map_err(|e| format!("Cannot build HTTP client: {e}"))?; + let yt_dlp_argv = resolve_yt_dlp(app, &app_data); + Ok(AppState { db: Arc::new(Mutex::new(db)), http, @@ -1341,7 +1462,7 @@ pub fn build_state(app: &AppHandle) -> Result { playlists: PlaylistServer::start()?, prereqs: Arc::new(Mutex::new(None)), streams: Arc::new(Mutex::new(HashMap::new())), - yt_dlp: resolve_yt_dlp(app), + yt_dlp_argv: Arc::new(Mutex::new(yt_dlp_argv)), cookies_from: Arc::new(Mutex::new(None)), }) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index c246020..e8a2cc9 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -90,9 +90,22 @@ impl Db { // columns need adding explicitly. The error when it already exists is // the expected case, not a failure. let _ = conn.execute("ALTER TABLE videos ADD COLUMN duration INTEGER", []); + let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_error TEXT", []); + let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_checked INTEGER", []); Ok(Db { conn }) } + /// Notes how a channel's last refresh went. `error` of None clears it. + pub fn set_channel_result(&self, id: &str, error: Option<&str>) -> Result<(), String> { + self.conn + .execute( + "UPDATE channels SET last_error = ?2, last_checked = ?3 WHERE id = ?1", + params![id, error, now()], + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + /// Records a video's length in seconds. pub fn set_duration(&self, video_id: &str, seconds: i64) -> Result<(), String> { if seconds <= 0 { @@ -107,6 +120,32 @@ impl Db { Ok(()) } + /// Of the given videos, those whose length is still unknown, in the order + /// they were given so what is on screen first is filled first. + pub fn filter_missing_duration( + &self, + ids: &[String], + limit: i64, + ) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT duration FROM videos WHERE id = ?1") + .map_err(|e| e.to_string())?; + let mut out = Vec::new(); + for id in ids { + if out.len() as i64 >= limit { + break; + } + let known: Option> = stmt + .query_row(params![id], |r| r.get::<_, Option>(0)) + .ok(); + if matches!(known, Some(None)) { + out.push(id.clone()); + } + } + Ok(out) + } + /// Videos whose length is still unknown, newest first. pub fn videos_missing_duration(&self, limit: i64) -> Result, String> { let mut stmt = self @@ -275,7 +314,8 @@ impl Db { (SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id), (SELECT COUNT(*) FROM videos v JOIN downloads d ON d.video_id = v.id - WHERE v.channel_id = c.id AND d.state = 'done') + WHERE v.channel_id = c.id AND d.state = 'done'), + c.last_error FROM channels c ORDER BY c.title COLLATE NOCASE ASC", ) @@ -289,6 +329,7 @@ impl Db { url: r.get(2)?, video_count: r.get(3)?, downloaded_count: r.get(4)?, + last_error: r.get(5)?, }) }) .map_err(|e| e.to_string())?; @@ -793,6 +834,44 @@ mod tests { assert_eq!(feed[0].id, "b"); } + #[test] + fn only_the_visible_videos_without_a_length_are_queued() { + let db = seeded(); + db.set_duration("b", 120).unwrap(); + // Order follows what was asked for, so the top of the screen fills first. + let want = vec!["c".to_string(), "b".to_string(), "a".to_string()]; + assert_eq!( + db.filter_missing_duration(&want, 10).unwrap(), + vec!["c".to_string(), "a".to_string()] + ); + // Unknown ids are simply skipped rather than queued forever. + assert!(db + .filter_missing_duration(&["nope".to_string()], 10) + .unwrap() + .is_empty()); + assert_eq!(db.filter_missing_duration(&want, 1).unwrap().len(), 1); + } + + #[test] + fn a_channel_failure_is_remembered_and_can_be_cleared() { + let db = seeded(); + assert!(db.list_channels().unwrap().iter().all(|c| c.last_error.is_none())); + + db.set_channel_result("UC1", Some("Feed returned HTTP 404")).unwrap(); + let failed = db.list_channels().unwrap(); + let alpha = failed.iter().find(|c| c.id == "UC1").unwrap(); + assert_eq!(alpha.last_error.as_deref(), Some("Feed returned HTTP 404")); + // Other channels are untouched. + assert!(failed.iter().find(|c| c.id == "UC2").unwrap().last_error.is_none()); + + db.set_channel_result("UC1", None).unwrap(); + assert!(db + .list_channels() + .unwrap() + .iter() + .all(|c| c.last_error.is_none())); + } + #[test] fn a_recorded_duration_reaches_the_feed() { let db = seeded(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2bebc14..2278333 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -164,6 +164,8 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { commands::list_browsers, commands::set_cookie_source, commands::test_youtube, + commands::check_yt_dlp_update, + commands::update_yt_dlp, commands::get_connectivity, commands::set_library_path, commands::open_external, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index a924c37..5315536 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -30,6 +30,8 @@ pub struct ChannelWithCount { pub url: String, pub video_count: i64, pub downloaded_count: i64, + /// Why the last refresh of this channel failed, if it did. + pub last_error: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 47dc157..f2fd89a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,7 +15,7 @@ "title": "FlightTube", "width": 1320, "height": 820, - "minWidth": 1260, + "minWidth": 1080, "minHeight": 620, "center": true, "titleBarStyle": "Overlay", diff --git a/src/App.tsx b/src/App.tsx index 218a255..1f29fc6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,11 +24,12 @@ const TOAST_MS = 2400; /** How often to pull new videos while online, so the feed stays live. */ const AUTO_REFRESH_MS = 10 * 60 * 1000; /** - * Video lengths trickle in. This was once every 12s and it got the whole IP - * challenged by YouTube, which broke playback and downloads too — the feed - * being fully annotated is not worth that. + * Video lengths trickle in, four at a time, for whatever is on screen. An + * early version fetched two pages a second across the whole feed and got the + * IP challenged by YouTube, breaking playback and downloads too — so this stays + * slow on purpose. */ -const DURATION_FILL_MS = 5 * 60 * 1000; +const DURATION_FILL_MS = 30 * 1000; function remembered(key: string): boolean { try { @@ -241,6 +242,14 @@ export default function App() { return () => clearInterval(id); }, [online, refreshing, playingIndex, doRefresh]); + // Ids on screen still lacking a length, newest first. Joined into a string + // so the effect below only re-runs when the set actually changes. + const missingDurations = useMemo( + () => items.filter((i) => i.duration == null).slice(0, 40).map((i) => i.id), + [items], + ); + const missingKey = missingDurations.join(","); + // The Atom feed carries no duration, so lengths are looked up a batch at a // time in the background and cached. Paused while the player is open. useEffect(() => { @@ -252,7 +261,7 @@ export default function App() { const tick = async () => { if (stop || refused || playingIndex != null) return; try { - if ((await fetchDurations()) > 0 && !stop) await reload(); + if ((await fetchDurations(missingDurations)) > 0 && !stop) await reload(); } catch { refused = true; } @@ -263,7 +272,10 @@ export default function App() { stop = true; clearInterval(id); }; - }, [online, playingIndex, reload]); + // Re-runs when the visible set changes, so switching channel fills that + // channel rather than whatever is newest overall. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [online, playingIndex, missingKey]); // Refresh once on launch, as soon as there is a connection and something to // refresh, so the feed is current without anyone pressing anything. @@ -288,6 +300,11 @@ export default function App() { } }, [items, clearLive, reload, say]); + const activeChannelError = + channelId == null + ? null + : (channels.find((c) => c.id === channelId)?.last_error ?? null); + const emptyMessage = () => { if (loading) return "Loading…"; if (channels.length === 0) @@ -352,7 +369,6 @@ export default function App() { { setForcedOffline(!forcedOffline); probe(); }} onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress} @@ -364,6 +380,17 @@ export default function App() { titleBarInset={titleBarInset} /> + {activeChannelError && ( +
+ This channel failed to refresh.{" "} + {activeChannelError.replace(/\.?$/, ".")} Anything listed below is from the + last successful check. +
+ )} + {!online && (
invoke("check_prereqs"); @@ -30,7 +31,9 @@ export const downloadVideo = (videoId: string, quality: Quality, subLangs: strin export const deleteAllDownloads = () => invoke("delete_all_downloads"); /** Fills in missing video lengths, a batch at a time. Returns how many. */ -export const fetchDurations = () => invoke("fetch_durations"); +/** Fills lengths for the videos on screen first. */ +export const fetchDurations = (visible: string[]) => + invoke("fetch_durations", { visible }); /** Browsers installed here that yt-dlp can read cookies from: [id, label]. */ export const listBrowsers = () => invoke>("list_browsers"); @@ -42,6 +45,11 @@ export const setCookieSource = (browser: string) => /** Resolves a known video to check whether YouTube is currently reachable. */ export const testYoutube = () => invoke("test_youtube"); +export const checkYtDlpUpdate = () => invoke("check_yt_dlp_update"); + +/** Downloads the newest yt-dlp and switches to it. Returns its version. */ +export const updateYtDlp = () => invoke("update_yt_dlp"); + /** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */ export const listSubtitles = (videoId: string) => invoke>("list_subtitles", { videoId }); diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 6ec66c6..89ef52c 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -107,6 +107,10 @@ export default function Player({ const [src, setSrc] = useState(path ? fileUrl(path) : null); const [error, setError] = useState(null); const [buffering, setBuffering] = useState(true); + // The height actually being decoded. With an adaptive stream this changes as + // the player switches rendition, so it is read from the element rather than + // assumed from the setting. + const [height, setHeight] = useState(0); // WebVTT files yt-dlp saved next to a download, so subtitles work offline. const [sidecars, setSidecars] = useState>([]); @@ -264,7 +268,11 @@ export default function Player({ {streaming && ( - {error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading…"} + {error + ? "Unavailable" + : src && !buffering + ? `Streaming${height ? ` · ${height}p` : ""}` + : "Loading…"} )} @@ -330,6 +338,8 @@ export default function Player({ onCanPlay={() => setBuffering(false)} onPlaying={() => setBuffering(false)} onSeeked={() => setBuffering(false)} + onResize={() => setHeight(videoRef.current?.videoHeight ?? 0)} + onLoadedData={() => setHeight(videoRef.current?.videoHeight ?? 0)} className="absolute inset-0 size-full object-contain" > {sidecars.map(([lang, file]) => ( @@ -409,10 +419,14 @@ export default function Player({ )} +
-
- Import -

- Importing replaces your current subscription list — the CSV becomes the - whole truth. Channels no longer in it are removed along with their videos and - downloads. You'll see exactly what goes before anything is deleted. -

- -
-
Download quality

@@ -241,6 +258,22 @@ export default function Settings({

+
+ Feed + +

+ Keeps YouTube Shorts out of the feed entirely. +

+
+
Sign in to YouTube

@@ -301,7 +334,29 @@ export default function Settings({

Status
- +
+ yt-dlp + + + {prereqs?.yt_dlp ?? "Not found"} + + {update && !update.up_to_date && update.latest && ( + + )} + {update?.up_to_date && ( + + up to date + + )} + +
Library @@ -316,9 +371,10 @@ export default function Settings({

- yt-dlp and ffmpeg ship inside the app, so nothing needs installing. A copy - on your system is used instead if one is present, which is how you can run - a newer yt-dlp than the bundled one. + Both ship inside the app, so nothing needs installing. yt-dlp breaks + whenever YouTube changes something, so it can be updated here; ffmpeg is + stable and comes with each release. A copy on your system takes precedence + over either.

{missing && ( @@ -335,6 +391,16 @@ export default function Settings({
+ {guide && ( + setGuide(false)} wide> +

+ YouTube has no public API for someone else's subscription list, so FlightTube + reads the export Google gives you. It takes about two minutes. +

+ +
+ )} + {pending && p && ( c.last_error).length; + const row = "flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " + "text-[13px] transition-colors"; @@ -88,7 +90,17 @@ export default function Sidebar({ {channels.length > 0 && ( -

Channels

+

+ Channels + {failing > 0 && ( + + {failing} failing + + )} +

)}
    @@ -99,7 +111,15 @@ export default function Sidebar({ title={c.title} className={`${row} ${activeChannel === c.id ? active : inactive}`} > - {c.title} + + {c.last_error && ( + + )} + {c.title} + {c.downloaded_count > 0 && `${c.downloaded_count}/`} {c.video_count} diff --git a/src/components/TakeoutGuide.tsx b/src/components/TakeoutGuide.tsx index 377167f..9fe52aa 100644 --- a/src/components/TakeoutGuide.tsx +++ b/src/components/TakeoutGuide.tsx @@ -87,11 +87,11 @@ const STEPS: Step[] = [ ), }, { - title: "Import it below", + title: "Import it", body: ( <> - Pick that subscriptions.csv with the Import button, then hit{" "} - Refresh to pull in each channel's latest videos. + Close this, then pick that subscriptions.csv with{" "} + Import subscriptions.csv. The feed refreshes itself afterwards. ), }, diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index a7857fa..aa1643c 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -7,8 +7,6 @@ interface Props { onSearch: (v: string) => void; downloadedOnly: boolean; onDownloadedOnly: (v: boolean) => void; - hideShorts: boolean; - onHideShorts: (v: boolean) => void; online: boolean; reachable: boolean; forcedOffline: boolean; @@ -57,7 +55,7 @@ function Toggle({ } export default function TopBar({ - search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts, + search, onSearch, downloadedOnly, onDownloadedOnly, online, reachable, forcedOffline, onToggleForcedOffline, onRefresh, refreshing, refreshProgress, resultCount, view, onView, sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset, @@ -92,7 +90,7 @@ export default function TopBar({ value={search} onChange={(e) => onSearch(e.target.value)} placeholder="Search videos and channels" - className={`${INPUT} min-w-[17rem] max-w-md flex-1`} + className={`${INPUT} min-w-[17rem] max-w-sm flex-1`} /> @@ -103,8 +101,8 @@ export default function TopBar({ onDownloadedOnly(!downloadedOnly)} disabled={!online} - title={online ? "Show only downloaded videos" : "Offline: showing downloads only"}> - Downloaded only + title={online ? "Show only what is on this Mac" : "Offline: showing local videos only"}> + Local {downloadedOnly && onDeleteAll && ( @@ -121,11 +119,6 @@ export default function TopBar({ )} - onHideShorts(!hideShorts)} - title="Hide Shorts from the feed"> - Hide Shorts - - = [ { value: "it", label: "Italiano" }, { value: "pt", label: "Português" }, ]; + +export interface UpdateStatus { + current: string | null; + latest: string | null; + up_to_date: boolean; +}