feat: read the subscription list off YouTube, and an app-shaped menu bar panel

Takeout is a snapshot you have to go and fetch. This reads the live list
from youtube.com/feed/channels in a browser already signed in.

It runs JavaScript in the page rather than fetching it here, because
the session cannot be borrowed: Chromium encrypts its cookie store with
a per-app Keychain key, and Arc's cookies read with Chrome's key came
back undecryptable — 1346 of 2196, the session cookies among them. The
list is also lazily loaded, so it has to be scrolled to the end, which
only the page can do. Measured against a hand-scrolled count: 208 both
ways.

The browser is brought to the front first. A background tab has its DOM
discarded, so it reports its address while having nothing on it, which
reads exactly like an empty subscription list.

The page gives handles, not channel ids. Most belong to channels already
known here and are matched by name, costing nothing; only the rest are
looked up, four at a time. That mattered more than it sounds: with the
name selector wrong every name came back empty, nothing matched, all 208
were looked up blind, and the result claimed 58 channels should be
deleted. With names read correctly it is 4 lookups and 0 deletions.
Names arriving doubled — "3D OCD 3D OCD", which reads fine and matches
nothing — are collapsed.

osascript prints a string result in source form, quoted with the
newlines escaped, so a list of rows arrived as one line that looked
like no rows at all. It is unquoted before parsing.

The menu bar item is now a window, not an NSMenu: the app's own type,
spacing and colours, hanging from the icon and closing on blur. Adding a
channel is gone from it — the sidebar already does that.

Replacing the subscription list is confirmed in the main window, never
in a panel that closes when you look away, and the confirmation counts
what would go before anything happens.

Verified end to end: 208 read, 4 looked up, 0 removed, 48 downloads
untouched.
This commit is contained in:
Vincent Rozenberg
2026-09-04 01:34:29 +02:00
parent 5cc3612b5f
commit 41d638b232
12 changed files with 853 additions and 88 deletions
+21
View File
@@ -58,6 +58,27 @@ export const checkYtDlpUpdate = () => invoke<UpdateStatus>("check_yt_dlp_update"
export const updateYtDlp = () => invoke<string>("update_yt_dlp");
/** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */
export interface ScrapeResult {
channels: Array<{ id: string; title: string; url: string }>;
unresolved: string[];
looked_up: number;
}
/** Reads the subscription list out of a signed-in browser page. */
export const scrapeSubscriptions = () => invoke<ScrapeResult>("scrape_subscriptions");
export const previewScrapedImport = (channels: ScrapeResult["channels"]) =>
invoke<ImportPreview>("preview_scraped_import", { channels });
export const importScraped = (channels: ScrapeResult["channels"]) =>
invoke<number>("import_scraped", { channels });
/** The menu bar panel's own actions. */
export const traySaveVideo = () => invoke<void>("tray_save_video");
export const showMainWindow = () => invoke<void>("show_main_window");
export const hidePanel = () => invoke<void>("hide_panel");
export const quitApp = () => invoke<void>("quit_app");
/** Adds one channel from any YouTube link. Returns its title. */
export const addChannel = (url: string) => invoke<string>("add_channel", { url });