diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 14bbb41..05f7726 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -62,6 +62,12 @@ impl Db { Self::init(conn) } + /// In-memory database, for tests only. + pub fn open_in_memory_pub() -> Result { + let conn = Connection::open_in_memory().map_err(|e| e.to_string())?; + Self::init(conn) + } + #[cfg(test)] pub fn open_in_memory() -> Result { let conn = Connection::open_in_memory().map_err(|e| e.to_string())?; diff --git a/src-tauri/tests/fixtures/subscriptions.csv b/src-tauri/tests/fixtures/subscriptions.csv new file mode 100644 index 0000000..75f01f7 --- /dev/null +++ b/src-tauri/tests/fixtures/subscriptions.csv @@ -0,0 +1,13 @@ +Channel Id,Channel Url,Channel Title +UCXuqSBlHAE6Xw-yeJA0Tunw,http://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw,"Linus Tech Tips" +UCsXVk37bltHxD1rDPwtNM8Q,http://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q,"Kurzgesagt – In a Nutshell" +UCHnyfMqiRRG1u-2MsSQLbXA,http://www.youtube.com/channel/UCHnyfMqiRRG1u-2MsSQLbXA,"Veritasium" +UC6nSFpj9HTCZ5t-N3Rm3-HA,http://www.youtube.com/channel/UC6nSFpj9HTCZ5t-N3Rm3-HA,"Vsauce" +UCYO_jab_esuFRV4b17AJtAw,http://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw,"3Blue1Brown" +UC9-y-6csu5WGm29I7JiwpnA,http://www.youtube.com/channel/UC9-y-6csu5WGm29I7JiwpnA,"Computerphile" +UC2C_jShtL725hvbm1arSV9w,http://www.youtube.com/channel/UC2C_jShtL725hvbm1arSV9w,"CGP Grey" +UCsooa4yRKGN_zEE8iknghZA,http://www.youtube.com/channel/UCsooa4yRKGN_zEE8iknghZA,"TED-Ed" +UCBJycsmduvYEL83R_U4JriQ,http://www.youtube.com/channel/UCBJycsmduvYEL83R_U4JriQ,"Marques Brownlee" +UCJ0-OtVpF0wOKEqT2Z1HEtA,http://www.youtube.com/channel/UCJ0-OtVpF0wOKEqT2Z1HEtA,"ElectroBOOM" +UCR1IuLEqb6UEA_zQ81kwXfg,http://www.youtube.com/channel/UCR1IuLEqb6UEA_zQ81kwXfg,"Real Engineering" +UC7_gcs09iThXybpVgjHZ_7g,http://www.youtube.com/channel/UC7_gcs09iThXybpVgjHZ_7g,"PBS Space Time" diff --git a/src-tauri/tests/pipeline.rs b/src-tauri/tests/pipeline.rs new file mode 100644 index 0000000..2294dc6 --- /dev/null +++ b/src-tauri/tests/pipeline.rs @@ -0,0 +1,68 @@ +//! End-to-end check of the real pipeline: Takeout CSV -> live Atom feeds -> +//! SQLite -> feed query. Hits the network, so it is ignored by default. +//! Run with: cargo test --test pipeline -- --ignored --nocapture + +use flighttube_lib::{db::Db, feed, models::FeedFilter, takeout}; + +const CSV: &str = include_str!("fixtures/subscriptions.csv"); + +#[tokio::test] +#[ignore] +async fn full_pipeline_against_live_feeds() { + let channels = takeout::parse_csv(CSV).expect("CSV should parse"); + println!("parsed {} channels", channels.len()); + assert!(channels.len() >= 10); + + let mut db = Db::open_in_memory_pub().expect("db"); + db.upsert_channels(&channels).unwrap(); + + let http = reqwest::Client::builder() + .user_agent("FlightTube/0.1 (+desktop)") + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap(); + + let mut total = 0; + for c in &channels { + match feed::fetch_channel(&http, &c.id).await { + Ok(v) => { + println!(" {:<30} {} videos", c.title, v.len()); + total += v.len(); + db.upsert_videos(&v).unwrap(); + } + Err(e) => println!(" {:<30} FAILED: {e}", c.title), + } + } + assert!(total > 50, "expected a real feed, got {total} videos"); + + let all = db.list_feed(&FeedFilter::default()).unwrap(); + println!("\nfeed rows: {}", all.len()); + + // The whole point: newest first, across all channels. + for w in all.windows(2) { + assert!(w[0].published >= w[1].published, "feed must be newest-first"); + } + println!("top 5 newest across all subscriptions:"); + for item in all.iter().take(5) { + println!( + " [{}] {} — {}", + item.channel_title, + item.title.chars().take(60).collect::(), + item.published + ); + } + + let no_shorts = db + .list_feed(&FeedFilter { hide_shorts: true, ..Default::default() }) + .unwrap(); + println!("\nwith shorts hidden: {} (was {})", no_shorts.len(), all.len()); + assert!(no_shorts.len() <= all.len()); + assert!(no_shorts.iter().all(|i| !i.is_short)); + + // Nothing is downloaded, so the offline view must be empty. + let offline = db + .list_feed(&FeedFilter { downloaded_only: true, ..Default::default() }) + .unwrap(); + assert_eq!(offline.len(), 0, "nothing downloaded yet"); + println!("offline view with no downloads: {} rows (correct)", offline.len()); +} diff --git a/src/App.tsx b/src/App.tsx index 6d8d703..767b371 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,172 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + cancelDownload, deleteDownload, downloadVideo, onRefreshProgress, + openExternal, refreshFeeds, +} from "./api"; +import Player from "./components/Player"; +import Settings from "./components/Settings"; +import Sidebar from "./components/Sidebar"; +import TopBar from "./components/TopBar"; +import VideoRow from "./components/VideoRow"; +import { useConnectivity } from "./hooks/useConnectivity"; +import { useDownloads } from "./hooks/useDownloads"; +import { useFeed } from "./hooks/useFeed"; +import type { FeedFilter, FeedItem, RefreshProgress } from "./types"; + export default function App() { + const [channelId, setChannelId] = useState(null); + const [search, setSearch] = useState(""); + const [downloadedOnly, setDownloadedOnly] = useState(false); + const [hideShorts, setHideShorts] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [playing, setPlaying] = useState<{ item: FeedItem; path: string } | null>(null); + const [refreshing, setRefreshing] = useState(false); + const [refreshProgress, setRefreshProgress] = useState(null); + const [notice, setNotice] = useState(null); + + const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity(); + + // Offline, the only videos that can be played are the ones already on disk, + // so the feed collapses to those regardless of the toggle. + const effectiveDownloadedOnly = downloadedOnly || !online; + + const filter: FeedFilter = useMemo( + () => ({ + channel_id: channelId, + search: search.trim() || null, + downloaded_only: effectiveDownloadedOnly, + hide_shorts: hideShorts, + limit: 500, + }), + [channelId, search, effectiveDownloadedOnly, hideShorts], + ); + + const { items, channels, loading, error, reload } = useFeed(filter); + const live = useDownloads(reload); + + useEffect(() => { + let un: (() => void) | undefined; + onRefreshProgress(setRefreshProgress).then((u) => (un = u)); + return () => un?.(); + }, []); + + const totalVideos = useMemo( + () => channels.reduce((n, c) => n + c.video_count, 0), + [channels], + ); + + const doRefresh = useCallback(async () => { + setRefreshing(true); + setNotice(null); + try { + const s = await refreshFeeds(); + const failed = s.failures.length; + setNotice( + `Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}` + + (failed ? ` · ${failed} failed` : ""), + ); + await reload(); + } catch (e) { + setNotice(String(e)); + } finally { + setRefreshing(false); + setRefreshProgress(null); + } + }, [reload]); + + const openItem = useCallback( + async (item: FeedItem) => { + const path = live[item.id]?.path ?? item.path; + const done = (live[item.id]?.state ?? item.state) === "done"; + if (done && path) { + setPlaying({ item, path }); + } else if (online) { + await openExternal(`https://www.youtube.com/watch?v=${item.id}`); + } else { + setNotice("That video isn't downloaded, and you're offline."); + } + }, + [live, online], + ); + + const emptyMessage = () => { + if (loading) return "Loading…"; + if (channels.length === 0) + return "No subscriptions yet. Open Settings and import your Takeout subscriptions.csv."; + if (totalVideos === 0) return "Subscriptions imported. Hit Refresh to pull in their latest videos."; + if (!online) return "You're offline, and nothing has been downloaded yet."; + if (effectiveDownloadedOnly) return "No downloaded videos match this filter."; + return "Nothing matches this filter."; + }; + return ( -
-

FlightTube

+
+ setShowSettings(true)} totalVideos={totalVideos} /> + +
+ { setForcedOffline(!forcedOffline); probe(); }} + onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress} + /> + + {!online && ( +
+ Offline — showing only videos you've downloaded. + {forcedOffline && " (Offline mode is forced on.)"} +
+ )} + + {(notice || error) && ( +
+ {error ?? notice} + +
+ )} + +
+ {items.length === 0 ? ( +
+

+ {emptyMessage()} +

+
+ ) : ( +
+ {items.map((item) => ( + openItem(item)} + onDownload={() => downloadVideo(item.id).catch((e) => setNotice(String(e)))} + onCancel={() => cancelDownload(item.id).catch((e) => setNotice(String(e)))} + onDelete={() => + deleteDownload(item.id).then(reload).catch((e) => setNotice(String(e))) + } /> + ))} +
+ )} +
+
+ + {playing && ( + setPlaying(null)} + onDelete={async () => { + await deleteDownload(playing.item.id); + setPlaying(null); + reload(); + }} /> + )} + + {showSettings && ( + setShowSettings(false)} onImported={() => reload()} /> + )}
); } diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..79d8b7e --- /dev/null +++ b/src/api.ts @@ -0,0 +1,79 @@ +import { invoke, convertFileSrc } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { open } from "@tauri-apps/plugin-dialog"; +import type { + ChannelWithCount, + DownloadProgressEvent, + DownloadStateEvent, + FeedFilter, + FeedItem, + Prereqs, + RefreshProgress, + RefreshSummary, +} from "./types"; + +export const checkPrereqs = () => invoke("check_prereqs"); + +export const listChannels = () => invoke("list_channels"); + +export const listFeed = (filter: FeedFilter) => + invoke("list_feed", { filter }); + +export const refreshFeeds = () => invoke("refresh_feeds"); + +export const downloadVideo = (videoId: string) => + invoke("download_video", { videoId }); + +export const cancelDownload = (videoId: string) => + invoke("cancel_download", { videoId }); + +export const deleteDownload = (videoId: string) => + invoke("delete_download", { videoId }); + +export const getConnectivity = () => invoke("get_connectivity"); + +export const setLibraryPath = (path: string) => + invoke("set_library_path", { path }); + +export const openExternal = (url: string) => + invoke("open_external", { url }); + +/** Opens the native file picker for a Takeout subscriptions.csv. */ +export async function pickAndImportTakeout(): Promise { + const path = await open({ + multiple: false, + directory: false, + filters: [{ name: "Takeout subscriptions", extensions: ["csv"] }], + }); + if (typeof path !== "string") return null; + return invoke("import_takeout_csv", { path }); +} + +export async function pickLibraryFolder(): Promise { + const path = await open({ directory: true, multiple: false }); + if (typeof path !== "string") return null; + return setLibraryPath(path); +} + +export const onRefreshProgress = (cb: (p: RefreshProgress) => void) => + listen("refresh:progress", (e) => cb(e.payload)); + +export const onDownloadProgress = (cb: (p: DownloadProgressEvent) => void) => + listen("download:progress", (e) => cb(e.payload)); + +export const onDownloadState = (cb: (p: DownloadStateEvent) => void) => + listen("download:state", (e) => cb(e.payload)); + +export type { UnlistenFn }; + +/** Local file path -> a URL the webview is allowed to load. */ +export const fileUrl = (path: string) => convertFileSrc(path); + +/** + * Prefers the on-disk thumbnail so the feed still renders with no network, + * falling back to the remote URL for videos not yet cached. + */ +export const thumbSrc = (item: FeedItem, online: boolean) => { + if (item.thumb_path) return convertFileSrc(item.thumb_path); + return online ? item.thumb_url : ""; +}; diff --git a/src/components/DownloadButton.tsx b/src/components/DownloadButton.tsx new file mode 100644 index 0000000..84507b2 --- /dev/null +++ b/src/components/DownloadButton.tsx @@ -0,0 +1,86 @@ +import type { LiveDownload } from "../hooks/useDownloads"; +import type { FeedItem } from "../types"; +import { humanEta } from "./format"; + +interface Props { + item: FeedItem; + live?: LiveDownload; + online: boolean; + onDownload: () => void; + onCancel: () => void; + onDelete: () => void; +} + +/** A ring that fills as the download progresses; indeterminate until yt-dlp + * knows the total size, which it doesn't until the stream is resolved. */ +function ProgressRing({ pct }: { pct: number | null }) { + const r = 9; + const circumference = 2 * Math.PI * r; + const offset = pct == null ? circumference * 0.7 : circumference * (1 - pct / 100); + return ( + + + + + ); +} + +export default function DownloadButton({ + item, live, online, onDownload, onCancel, onDelete, +}: Props) { + const state = live?.state ?? item.state ?? null; + const pct = live?.pct ?? item.pct ?? null; + const error = live?.error ?? item.error ?? null; + + const base = + "inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors shrink-0"; + + if (state === "done") { + return ( + + ); + } + + if (state === "running" || state === "queued") { + const label = + state === "queued" ? "Queued" : pct != null ? `${pct.toFixed(0)}%` : "Starting"; + return ( + + ); + } + + const failed = state === "failed"; + return ( + + ); +} diff --git a/src/components/Player.tsx b/src/components/Player.tsx new file mode 100644 index 0000000..4f74c41 --- /dev/null +++ b/src/components/Player.tsx @@ -0,0 +1,52 @@ +import { fileUrl } from "../api"; +import type { FeedItem } from "../types"; +import { compactViews, relativeTime } from "./format"; + +interface Props { + item: FeedItem; + path: string; + onClose: () => void; + onDelete: () => void; +} + +/** + * Plays the local file through Tauri's asset protocol. Every download is + * H.264/AAC in MP4 precisely so WKWebView can decode it natively. + */ +export default function Player({ item, path, onClose, onDelete }: Props) { + return ( +
+
+ + {item.channel_title} + +
+ +
+
+ +
+

{item.title}

+
+ {[compactViews(item.views), relativeTime(item.published)] + .filter(Boolean) + .join(" · ")} +
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx new file mode 100644 index 0000000..5bc3ae9 --- /dev/null +++ b/src/components/Settings.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import { checkPrereqs, pickAndImportTakeout, pickLibraryFolder } from "../api"; +import type { Prereqs } from "../types"; + +interface Props { + onClose: () => void; + onImported: (count: number) => void; +} + +function StatusRow({ label, value, hint }: { label: string; value: string | null; hint?: string }) { + return ( +
+ {label} + + {value ?? hint ?? "Not found"} + +
+ ); +} + +export default function Settings({ onClose, onImported }: Props) { + const [prereqs, setPrereqs] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null)); + useEffect(() => { load(); }, []); + + const doImport = async () => { + setBusy(true); + setMessage(null); + try { + const n = await pickAndImportTakeout(); + if (n != null) { + setMessage(`Imported ${n} subscription${n === 1 ? "" : "s"}.`); + onImported(n); + } + } catch (e) { + setMessage(String(e)); + } finally { + setBusy(false); + } + }; + + const doPickFolder = async () => { + try { + const p = await pickLibraryFolder(); + if (p) { setMessage(`Library moved to ${p}`); load(); } + } catch (e) { + setMessage(String(e)); + } + }; + + const missing = prereqs && (!prereqs.yt_dlp || !prereqs.ffmpeg); + + return ( +
+
e.stopPropagation()} + className="w-full max-w-lg rounded-2xl bg-surface border border-edge p-6 space-y-5"> +
+

Settings

+ +
+ +
+

Subscriptions

+

+ Export YouTube subscriptions from Google Takeout, + then import the subscriptions.csv file here. + Re-importing merges with what you already have. +

+ +
+ +
+

Status

+ + +
+ Library + +
+ {missing && ( +
+

+ Downloads need both tools. Install them with: +

+ + brew install yt-dlp ffmpeg + +
+ )} +
+ + {message &&

{message}

} +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..5931085 --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,61 @@ +import type { ChannelWithCount } from "../types"; + +interface Props { + channels: ChannelWithCount[]; + activeChannel: string | null; + onSelect: (id: string | null) => void; + onOpenSettings: () => void; + totalVideos: number; +} + +export default function Sidebar({ + channels, activeChannel, onSelect, onOpenSettings, totalVideos, +}: Props) { + const rowBase = + "w-full text-left px-3 py-2 rounded-lg text-sm flex items-center justify-between gap-2 transition-colors cursor-pointer"; + + return ( + + ); +} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx new file mode 100644 index 0000000..273fd81 --- /dev/null +++ b/src/components/TopBar.tsx @@ -0,0 +1,92 @@ +interface Props { + search: string; + onSearch: (v: string) => void; + downloadedOnly: boolean; + onDownloadedOnly: (v: boolean) => void; + hideShorts: boolean; + onHideShorts: (v: boolean) => void; + online: boolean; + reachable: boolean; + forcedOffline: boolean; + onToggleForcedOffline: () => void; + onRefresh: () => void; + refreshing: boolean; + refreshProgress: { done: number; total: number } | null; +} + +function Toggle({ + active, onClick, children, title, disabled, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; + title?: string; + disabled?: boolean; +}) { + return ( + + ); +} + +export default function TopBar({ + search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts, + online, reachable, forcedOffline, onToggleForcedOffline, + onRefresh, refreshing, refreshProgress, +}: Props) { + return ( +
+
+ onSearch(e.target.value)} + placeholder="Search videos and channels" + className="w-full rounded-full bg-surface border border-edge px-4 py-2 text-sm + placeholder:text-muted/70 focus:outline-none focus:border-accent" /> +
+ + onDownloadedOnly(!downloadedOnly)} + disabled={!online} + title={online ? "Show only downloaded videos" : "Offline: showing downloads only"}> + Downloaded only + + + onHideShorts(!hideShorts)} + title="Hide Shorts from the feed"> + Hide Shorts + + + + + +
+ ); +} diff --git a/src/components/VideoRow.tsx b/src/components/VideoRow.tsx new file mode 100644 index 0000000..fca686a --- /dev/null +++ b/src/components/VideoRow.tsx @@ -0,0 +1,72 @@ +import { thumbSrc } from "../api"; +import type { LiveDownload } from "../hooks/useDownloads"; +import type { FeedItem } from "../types"; +import DownloadButton from "./DownloadButton"; +import { compactViews, relativeTime } from "./format"; + +interface Props { + item: FeedItem; + live?: LiveDownload; + online: boolean; + onOpen: () => void; + onDownload: () => void; + onCancel: () => void; + onDelete: () => void; +} + +export default function VideoRow({ + item, live, online, onOpen, onDownload, onCancel, onDelete, +}: Props) { + const downloaded = (live?.state ?? item.state) === "done"; + const src = thumbSrc(item, online); + + return ( +
+ + +
+ +
{item.channel_title}
+
+ {[compactViews(item.views), relativeTime(item.published)] + .filter(Boolean) + .join(" · ")} +
+ {(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && ( +
+ {live?.error ?? item.error} +
+ )} +
+ +
+ +
+
+ ); +} diff --git a/src/components/format.ts b/src/components/format.ts new file mode 100644 index 0000000..5e0daf5 --- /dev/null +++ b/src/components/format.ts @@ -0,0 +1,43 @@ +export function relativeTime(unixSeconds: number): string { + if (!unixSeconds) return ""; + const diff = Date.now() / 1000 - unixSeconds; + const units: Array<[number, string]> = [ + [31536000, "year"], + [2592000, "month"], + [604800, "week"], + [86400, "day"], + [3600, "hour"], + [60, "minute"], + ]; + for (const [secs, name] of units) { + const n = Math.floor(diff / secs); + if (n >= 1) return `${n} ${name}${n > 1 ? "s" : ""} ago`; + } + return "just now"; +} + +export function compactViews(n: number): string { + if (n <= 0) return ""; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M views`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K views`; + return `${n} views`; +} + +export function humanBytes(n: number | null): string { + if (!n) return ""; + const units = ["B", "KB", "MB", "GB"]; + let v = n; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; +} + +export function humanEta(seconds: number | null): string { + if (seconds == null) return ""; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return m > 0 ? `${m}m ${s}s left` : `${s}s left`; +} diff --git a/src/hooks/useConnectivity.ts b/src/hooks/useConnectivity.ts new file mode 100644 index 0000000..bd092a0 --- /dev/null +++ b/src/hooks/useConnectivity.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useState } from "react"; +import { getConnectivity } from "../api"; + +const POLL_MS = 30_000; + +/** + * Real reachability, not `navigator.onLine` — which reports link state and is + * true on a plane's captive-portal wifi with no actual internet. The manual + * override exists so offline mode can be exercised without touching wifi. + */ +export function useConnectivity() { + const [reachable, setReachable] = useState(true); + const [forcedOffline, setForcedOffline] = useState(false); + + const probe = useCallback(async () => { + try { + setReachable(await getConnectivity()); + } catch { + setReachable(false); + } + }, []); + + useEffect(() => { + probe(); + const id = setInterval(probe, POLL_MS); + const onUp = () => probe(); + const onDown = () => setReachable(false); + window.addEventListener("online", onUp); + window.addEventListener("offline", onDown); + return () => { + clearInterval(id); + window.removeEventListener("online", onUp); + window.removeEventListener("offline", onDown); + }; + }, [probe]); + + return { + online: reachable && !forcedOffline, + reachable, + forcedOffline, + setForcedOffline, + probe, + }; +} diff --git a/src/hooks/useDownloads.ts b/src/hooks/useDownloads.ts new file mode 100644 index 0000000..7d84775 --- /dev/null +++ b/src/hooks/useDownloads.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { onDownloadProgress, onDownloadState } from "../api"; +import type { DownloadState } from "../types"; + +export interface LiveDownload { + state: DownloadState; + pct: number | null; + speed: number | null; + eta: number | null; + error: string | null; + path: string | null; +} + +/** + * Live download state keyed by video id, driven by backend events. Overlays the + * snapshot the feed query returns, so progress updates don't require refetching. + */ +export function useDownloads(onFinished: () => void) { + const [live, setLive] = useState>({}); + + useEffect(() => { + const unlisteners: Array<() => void> = []; + + onDownloadProgress((p) => { + setLive((prev) => ({ + ...prev, + [p.video_id]: { + state: "running", + pct: p.pct, + speed: p.speed, + eta: p.eta, + error: null, + path: prev[p.video_id]?.path ?? null, + }, + })); + }).then((u) => unlisteners.push(u)); + + onDownloadState((s) => { + setLive((prev) => ({ + ...prev, + [s.video_id]: { + state: s.state, + pct: s.state === "done" ? 100 : (prev[s.video_id]?.pct ?? null), + speed: null, + eta: null, + error: s.error, + path: s.path, + }, + })); + if (s.state === "done" || s.state === "failed" || s.state === "cancelled") { + onFinished(); + } + }).then((u) => unlisteners.push(u)); + + return () => unlisteners.forEach((u) => u()); + }, [onFinished]); + + return live; +} diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts new file mode 100644 index 0000000..94a5675 --- /dev/null +++ b/src/hooks/useFeed.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useState } from "react"; +import { listChannels, listFeed } from "../api"; +import type { ChannelWithCount, FeedFilter, FeedItem } from "../types"; + +export function useFeed(filter: FeedFilter) { + const [items, setItems] = useState([]); + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + try { + const [feed, chans] = await Promise.all([listFeed(filter), listChannels()]); + setItems(feed); + setChannels(chans); + setError(null); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + // Filter is a plain object rebuilt each render; compare by value. + }, [JSON.stringify(filter)]); + + useEffect(() => { + reload(); + }, [reload]); + + return { items, channels, loading, error, reload }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..233139e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,77 @@ +// Mirrors of the Rust structs in src-tauri/src/models.rs. Field names match +// serde's output exactly so no mapping layer is needed. + +export type DownloadState = + | "queued" + | "running" + | "done" + | "failed" + | "cancelled"; + +export interface ChannelWithCount { + id: string; + title: string; + url: string; + video_count: number; + downloaded_count: number; +} + +export interface FeedItem { + id: string; + channel_id: string; + channel_title: string; + title: string; + description: string; + /** Unix seconds. */ + published: number; + thumb_url: string; + thumb_path: string | null; + views: number; + is_short: boolean; + state: DownloadState | null; + path: string | null; + pct: number | null; + error: string | null; +} + +export interface FeedFilter { + channel_id?: string | null; + search?: string | null; + downloaded_only: boolean; + hide_shorts: boolean; + limit?: number | null; +} + +export interface Prereqs { + yt_dlp: string | null; + ffmpeg: string | null; + library_path: string; +} + +export interface RefreshSummary { + channels: number; + new_videos: number; + failures: string[]; +} + +export interface RefreshProgress { + done: number; + total: number; + channel: string; +} + +export interface DownloadProgressEvent { + video_id: string; + pct: number | null; + bytes_done: number; + bytes_total: number | null; + speed: number | null; + eta: number | null; +} + +export interface DownloadStateEvent { + video_id: string; + state: DownloadState; + error: string | null; + path: string | null; +}