feat(settings): upload a Google Takeout export in Settings › Account

A "YouTube history" section under YouTube access: the watch history and
the Watch later csv are dropped or picked (one request each, each file's
outcome said on its own), and the section then says what the import did
- only the counts it produced - or that it is running, failed or was cut
off by a restart. While it runs it asks again until it ends; a failed
re-read shows the failure, not the previous answer.

The drop zone the Files upload had inline is now ui/DropZone, used by
both. The e2e seed clears the account's kept Takeout videos.
This commit is contained in:
2026-10-23 18:51:05 +02:00
parent 5d9952ab1e
commit 3553eac8c4
16 changed files with 749 additions and 84 deletions
+59
View File
@@ -0,0 +1,59 @@
import { test, expect } from "@playwright/test";
import { openApp, gotoPage } from "./helpers";
// Settings › Account › YouTube history: a Google Takeout watch history, uploaded through the real
// picker, is read, imported in the background, and the section says what it did — a video the
// catalog knows is marked watched, one it does not know is remembered for later. The seed clears
// the account's kept Takeout videos each run, but the projects share one account: a later one's
// feed no longer holds the video an earlier one marked, so what it adds is counted against what was
// there before it.
test("a Takeout watch history uploads, imports, and the section says what it did", async ({
page,
}) => {
await openApp(page);
// A video the catalog knows: one on the account's feed (not a Short — the feed leaves them out).
const known = await page.getByTestId("video-card").first().getAttribute("data-video-id");
expect(known).toBeTruthy();
await gotoPage(page, "settings");
await page.getByRole("tab", { name: "Account" }).click();
await expect(page.getByRole("heading", { name: "YouTube history" })).toBeVisible();
// The first project's upload is an import already made: its "New import" opens the drop zone.
const zone = page.getByRole("button", { name: "Drop the files here, or choose them" });
const again = page.getByRole("button", { name: "New import" });
await expect(zone.or(again)).toBeVisible();
if (await again.isVisible()) await again.click();
await expect(zone).toBeVisible();
const before = (await (await page.request.get("/api/takeout")).json()) as {
watched?: Record<string, number>;
};
const watchedBefore = before.watched?.watched ?? 0;
// Watched now: later than any state the seed set, which the import would otherwise leave alone.
const now = new Date().toISOString();
const history = [
{ titleUrl: `https://www.youtube.com/watch?v=${known}`, time: now },
{ titleUrl: "https://www.youtube.com/watch?v=e2eUnknown0", time: now },
{
titleUrl: "https://www.youtube.com/watch?v=e2eAdvert00",
time: now,
details: [{ name: "From Google Ads" }],
},
];
const uploaded = page.waitForResponse(
(r) => r.url().endsWith("/api/takeout") && r.request().method() === "POST",
);
await page.locator('input[type="file"][accept=".json,.csv"]').setInputFiles({
name: "watch-history.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(history)),
});
expect((await uploaded).ok()).toBeTruthy();
await expect(page.getByText("watch-history.json: 2 videos read")).toBeVisible();
const line = page.getByTestId("takeout-last");
await expect(line).toContainText("Last import:");
await expect(line).toContainText(`${watchedBefore + 1} watched`);
await expect(line).toContainText("1 remembered");
await expect(page.getByRole("button", { name: "New import" })).toBeVisible();
});
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
//
// ui/DropZone: a drop or a pick hands over the files (only the first unless `multiple`), the drag
// highlight survives the cursor crossing the zone's own children, and a disabled zone takes nothing.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { DropZone } from "./DropZone";
afterEach(cleanup);
const files = (...names: string[]) => names.map((n) => new File(["x"], n));
function mount(props: Partial<Parameters<typeof DropZone>[0]> = {}) {
const onFiles = vi.fn();
render(
<DropZone onFiles={onFiles} {...props}>
{(over) => <span data-testid="inner">{over ? "over" : "idle"}</span>}
</DropZone>,
);
return { onFiles, zone: screen.getByRole("button"), inner: screen.getByTestId("inner") };
}
describe("DropZone", () => {
it("a drop hands over the first file, or all of them when multiple", () => {
const one = mount();
fireEvent.drop(one.zone, { dataTransfer: { files: files("a", "b") } });
expect(one.onFiles.mock.calls[0]![0].map((f: File) => f.name)).toEqual(["a"]);
cleanup();
const many = mount({ multiple: true });
fireEvent.drop(many.zone, { dataTransfer: { files: files("a", "b") } });
expect(many.onFiles.mock.calls[0]![0].map((f: File) => f.name)).toEqual(["a", "b"]);
});
it("a pick hands over the picked files; an empty pick hands over nothing", () => {
const { onFiles } = mount({ multiple: true });
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
expect(input.multiple).toBe(true);
fireEvent.change(input, { target: { files: [] } });
expect(onFiles).not.toHaveBeenCalled();
fireEvent.change(input, { target: { files: files("c") } });
expect(onFiles.mock.calls[0]![0].map((f: File) => f.name)).toEqual(["c"]);
});
it("stays highlighted while the cursor crosses its own children, and clears on the last leave", () => {
const { zone, inner } = mount();
fireEvent.dragEnter(zone);
fireEvent.dragEnter(inner); // into a child: the zone gets its leave after this enter
fireEvent.dragLeave(zone);
expect(inner.textContent).toBe("over");
fireEvent.dragLeave(inner);
expect(inner.textContent).toBe("idle");
});
it("a disabled zone takes no drop", () => {
const { zone, onFiles } = mount({ disabled: true });
fireEvent.drop(zone, { dataTransfer: { files: files("a") } });
expect(onFiles).not.toHaveBeenCalled();
});
it("an invalid zone reads as invalid and points at the reason", () => {
const { zone } = mount({ invalid: true, describedBy: "why" });
expect(zone.getAttribute("aria-invalid")).toBe("true");
expect(zone.getAttribute("aria-describedby")).toBe("why");
});
});
+97
View File
@@ -0,0 +1,97 @@
import { useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { Pressable } from "./Pressable";
// A place to drop files onto, which is also the button that opens the native file dialog: the
// dashed surface an upload starts from. It owns what each such surface needed and the first one
// got right only after a flicker: dragenter/dragleave fire as the cursor crosses the zone's OWN
// children (an icon, a line of text), so a plain boolean flickers — enter and leave are counted
// and the highlight clears at depth 0. The caller draws the inside, told whether a drag is over it.
export function DropZone({
onFiles,
multiple = false,
accept,
disabled = false,
invalid = false,
describedBy,
className,
children,
}: {
/** The files dropped or picked, never empty. One unless `multiple`. */
onFiles: (files: [File, ...File[]]) => void;
multiple?: boolean;
/** The picker's filter (`<input accept>`); a drop is not filtered by it. */
accept?: string;
disabled?: boolean;
/** What the zone holds is refused: the border turns danger and `describedBy` says why. */
invalid?: boolean;
describedBy?: string;
/** Lays the zone out (its padding); the border and the drag paint are the zone's. */
className?: string;
children: (dragOver: boolean) => ReactNode;
}) {
const [dragOver, setDragOver] = useState(false);
const depth = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const take = (list: FileList | null | undefined) => {
const [first, ...rest] = [...(list ?? [])];
if (first) onFiles(multiple ? [first, ...rest] : [first]);
};
return (
<>
<Pressable
align="center"
onClick={() => inputRef.current?.click()}
onDragEnter={(e) => {
e.preventDefault();
depth.current += 1;
setDragOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragOver(false);
}}
onDrop={(e) => {
e.preventDefault();
depth.current = 0;
setDragOver(false);
if (!disabled) take(e.dataTransfer.files);
}}
disabled={disabled}
// The refusal is tied to the control it invalidates, not left to a one-shot announcement:
// the zone reads as invalid and points at the sentence, so a reader who tabs back to it
// hears WHY. (Measured, because `aria-invalid` is not in `button`'s ARIA-supported list:
// Chromium's accessibility node for a native button does carry `invalid` from it.) The
// signal that is NOT colour is the caller's sentence — the border only reinforces it.
aria-invalid={invalid}
aria-describedby={describedBy}
// `dragOver` outranks `invalid`: a drag is transient and the refusal repaints the instant
// the drop lands, whereas a zone that stays red under the pointer tells the user their drop
// target is dead — in the one state where they are trying to replace the file.
className={clsx(
"w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed transition disabled:opacity-60",
dragOver
? "border-accent bg-accent/5"
: invalid
? "border-danger"
: "border-border hover:border-fg/40",
className,
)}
>
{children(dragOver)}
</Pressable>
<input
ref={inputRef}
type="file"
className="hidden"
multiple={multiple}
accept={accept}
onChange={(e) => {
take(e.target.files);
e.target.value = ""; // let re-picking the same file fire onChange again
}}
/>
</>
);
}
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| area | components |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| area | components |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c)
@@ -52,7 +52,7 @@ const PLAIN: Record<string, [number, string]> = {
"modules/settings/SettingsPanel.tsx": [1, "a colour swatch"],
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
"features/channel/TagManager.tsx": [1, "a suggestion in a picker"],
"modules/files/UploadDialog.tsx": [1, "the drop zone"],
"components/ui/DropZone.tsx": [1, "the drop zone (its text is its own prompt)"],
"modules/files/VideoEditor.tsx": [2, "timeline glyphs"],
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
"modules/playlists/playlistColumns.tsx": [1, "the thumbnail"],
+1
View File
@@ -87,6 +87,7 @@
"purgeDiscovery": "Couldn't clear the discovery data",
"plexWatchLink": "Couldn't change the Plex watch-state sync",
"plexWatchImport": "Couldn't import the watch state from Plex",
"takeoutUpload": "Couldn't upload the Takeout file",
"shareLinkDownload": "Couldn't change whether the link allows downloading",
"shareLinkRevoke": "Couldn't revoke the link",
"shareLinkPassword": "Couldn't change the link's password",
@@ -19,6 +19,41 @@
"never": "Not imported yet",
"importing": "Importing your Plex watch history…"
},
"takeout": {
"title": "YouTube history",
"intro": "Upload “watch-history.json” from your Google Takeout export (and “Watch later-videos.csv”, if you have it). Videos known here show as watched; the rest are remembered and marked watched once they arrive here. Shorts are skipped, and nothing you set here is overwritten.",
"drop": "Drop the files here, or choose them",
"dropActive": "Drop to upload",
"uploading": "Uploading {{name}}…",
"read_one": "{{name}}: {{n}} video read",
"read_other": "{{name}}: {{n}} videos read",
"running": "Processing…",
"last": "Last import: {{when}}",
"failed": "The last import ({{when}}) failed. Upload the file again to retry.",
"interrupted": "The last import ({{when}}) was cut off by a server restart. Upload the file again to finish it.",
"newImport": "New import",
"loadFailed": "Couldn't load the last import",
"howTo": "How to export from Takeout?",
"howToBody": "At takeout.google.com select only “YouTube and YouTube Music”. In its options keep only “history” and “playlists”, and choose JSON for the history. Untick “videos”, or every video you ever uploaded comes with it.",
"count": {
"watched_one": "{{n}} watched",
"watched_other": "{{n}} watched",
"waiting_one": "{{n}} remembered",
"waiting_other": "{{n}} remembered",
"short_one": "{{n}} Short skipped",
"short_other": "{{n}} Shorts skipped",
"kept_one": "{{n}} left as you set it here",
"kept_other": "{{n}} left as you set them here",
"added_one": "{{n}} added to Watch later",
"added_other": "{{n}} added to Watch later",
"present_one": "{{n}} already in Watch later",
"present_other": "{{n}} already in Watch later",
"missing_one": "{{n}} no longer on YouTube",
"missing_other": "{{n}} no longer on YouTube",
"laterWaiting_one": "{{n}} waiting for Watch later",
"laterWaiting_other": "{{n}} waiting for Watch later"
}
},
"save": {
"saving": "Saving…",
"saved": "Settings saved",
+1
View File
@@ -87,6 +87,7 @@
"purgeDiscovery": "Nem sikerült törölni a felfedezési adatokat",
"plexWatchLink": "Nem sikerült módosítani a Plex megtekintésiállapot-szinkront",
"plexWatchImport": "Nem sikerült importálni a megtekintési állapotot a Plexből",
"takeoutUpload": "Nem sikerült feltölteni a Takeout-fájlt",
"shareLinkDownload": "Nem sikerült módosítani, hogy a link engedi-e a letöltést",
"shareLinkRevoke": "Nem sikerült visszavonni a linket",
"shareLinkPassword": "Nem sikerült módosítani a link jelszavát",
@@ -19,6 +19,41 @@
"never": "Még nincs importálva",
"importing": "Plex-előzmény importálása…"
},
"takeout": {
"title": "YouTube-előzmények",
"intro": "Töltsd fel a Google Takeout exportodból a „watch-history.json” fájlt (és ha van, a „Watch later-videos.csv” fájlt). Az itt ismert videók megnézettként jelennek meg; a többit megjegyezzük, és megnézettek lesznek, amint bekerülnek ide. A Shortsot kihagyjuk, és amit itt beállítottál, azt nem írjuk felül.",
"drop": "Húzd ide a fájlokat, vagy válaszd ki őket",
"dropActive": "Engedd el a feltöltéshez",
"uploading": "{{name}} feltöltése…",
"read_one": "{{name}}: {{n}} videó beolvasva",
"read_other": "{{name}}: {{n}} videó beolvasva",
"running": "Feldolgozás…",
"last": "Utolsó import: {{when}}",
"failed": "Az utolsó import ({{when}}) nem sikerült. Töltsd fel újra a fájlt.",
"interrupted": "Az utolsó importot ({{when}}) megszakította a szerver újraindulása. Töltsd fel újra a fájlt a befejezéshez.",
"newImport": "Új import",
"loadFailed": "Nem sikerült betölteni az utolsó importot",
"howTo": "Hogyan exportálj a Takeoutból?",
"howToBody": "A takeout.google.com oldalon csak a „YouTube és YouTube Music” kell. A beállításai közül csak az „előzmények” és a „lejátszási listák” maradjon, az előzményekhez JSON formátummal. A „videók” pipát vedd ki, különben az összes feltöltött videód is benne lesz.",
"count": {
"watched_one": "{{n}} megnézett",
"watched_other": "{{n}} megnézett",
"waiting_one": "{{n}} megjegyezve",
"waiting_other": "{{n}} megjegyezve",
"short_one": "{{n}} Short kihagyva",
"short_other": "{{n}} Short kihagyva",
"kept_one": "{{n}} úgy maradt, ahogy itt beállítottad",
"kept_other": "{{n}} úgy maradt, ahogy itt beállítottad",
"added_one": "{{n}} a „Megnézem később” listába",
"added_other": "{{n}} a „Megnézem később” listába",
"present_one": "{{n}} már a „Megnézem később” listán",
"present_other": "{{n}} már a „Megnézem később” listán",
"missing_one": "{{n}} már nincs fent a YouTube-on",
"missing_other": "{{n}} már nincs fent a YouTube-on",
"laterWaiting_one": "{{n}} vár a „Megnézem később” listára",
"laterWaiting_other": "{{n}} vár a „Megnézem később” listára"
}
},
"save": {
"saving": "Mentés…",
"saved": "Beállítások elmentve",
+33 -1
View File
@@ -1,7 +1,8 @@
// Feed, video watch-state/progress, and live YouTube search. `filterParams`/`feedQuery` translate
// the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay
// module-local (no consumer names them). Only touches core's req/beacon.
import { req, beacon } from "./core";
import { req, beacon, uploadReq } from "./core";
import type { RunStatus } from "./admin";
import { TAG_CATEGORIES, type TagModes } from "./tagModes";
// Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and
@@ -148,6 +149,34 @@ function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string
return p.toString();
}
// A Google Takeout file, uploaded: which one it was, the videos read from it, and its entries left
// out (an ad, a view of something that is not a video, a row that could not be read).
export interface TakeoutUpload {
ok: true;
kind: TakeoutKind;
videos: number;
entries: number;
ads: number;
other: number;
malformed: number;
}
export type TakeoutKind = "watched" | "watch_later";
// A kept video's outcome (`reader.models.TakeoutOutcome`), or "waiting" while the catalog does not
// know it yet: watched / kept (the user's own state here stood) / short for the history; added /
// present / missing for Watch later.
export type TakeoutCounts = Partial<Record<string, number>>;
export interface TakeoutStatus {
run: {
status: RunStatus;
started_at: string | null;
finished_at: string | null;
result: Record<string, unknown> | null;
failure: { failure: string; [key: string]: unknown } | null;
} | null;
watched?: TakeoutCounts;
watch_later?: TakeoutCounts;
}
export const feedApi = {
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
@@ -210,4 +239,7 @@ export const feedApi = {
duration_seconds: Math.floor(durationSeconds),
}),
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
// One file of a Google Takeout export, its bytes as they are; the server tells the two apart.
takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file),
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"),
};
+1
View File
@@ -129,6 +129,7 @@ export const qk = {
comfortPins: (lang?: string) => ["comfort-pins", ...(lang ? [lang] : [])] as const,
plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const,
takeout: () => ["takeout"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
// Discover surface: the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches it and
// the Surprise draw below for a prefix invalidation after a watched-toggle.
+26 -74
View File
@@ -15,7 +15,7 @@ import type { DownloadJob } from "../../lib/api";
import { useFilesActions } from "./filesContext";
import { formatBytes } from "../../lib/format";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { Pressable } from "../../components/ui/Pressable";
import { DropZone } from "../../components/ui/DropZone";
import { Input } from "../../components/ui/Input";
// Local-file ingest. A general upload — ANY file type, no format list: media
@@ -46,13 +46,8 @@ export default function UploadDialog({
const qc = useQueryClient();
const [file, setFile] = useState<File | null>(initialFile);
const [name, setName] = useState("");
const [dragOver, setDragOver] = useState(false);
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
const inputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
// dragenter/dragleave fire as the cursor crosses the drop zone's OWN children (icon/text), so a
// plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0.
const dragDepth = useRef(0);
const busy = pct !== null;
// The per-file cap, so an over-cap file is refused HERE instead of after the user has pushed it up
// the wire. Both server-side caps are still the boundary — this is only the pleasant half of it.
@@ -86,8 +81,6 @@ export default function UploadDialog({
// footer Cancel wired the abort; this covers every other dismissal.
useEffect(() => () => abortRef.current?.abort(), []);
const pick = () => inputRef.current?.click();
const submit = async () => {
// `tooBig` here is belt and braces, NOT the mechanism: `ui/Button` is focus-safe, so while the
// button holds focus it swallows the activation itself, and while it does not it is natively
@@ -138,74 +131,33 @@ export default function UploadDialog({
<Modal initialFocus="field" title={t("downloads.upload.title")} onClose={onClose}>
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
<Pressable
align="center"
onClick={pick}
onDragEnter={(e) => {
e.preventDefault();
dragDepth.current += 1;
setDragOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragOver(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) pick_(f);
}}
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog.
With a file picked, its label shows the file's name rather than `dropActive`, so the
zone's drag border is the only feedback that it will take a replacement. */}
<DropZone
className="px-4 py-8"
onFiles={([f]) => pick_(f)}
disabled={busy}
// The refusal is tied to the control it invalidates, not left to a one-shot announcement:
// the drop zone reads as invalid and points at the sentence, so a reader who tabs back to
// it hears WHY. (Measured, because `aria-invalid` is not in `button`'s ARIA-supported
// list: Chromium's accessibility node for a native button does carry `invalid` from this
// attribute.) The signal that is NOT colour is the sentence below, visible to everyone —
// the border only reinforces it.
aria-invalid={tooBig}
aria-describedby={tooBig ? refusalId : undefined}
// `dragOver` outranks `tooBig`: a drag is transient and the refusal repaints the instant
// the drop lands, whereas a zone that stays red under the pointer tells the user their
// drop target is dead — in the one state where they are trying to replace the file. With
// a file picked, the label below shows its name rather than `dropActive`, so this border
// is the ONLY feedback that the zone will accept the drop.
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
dragOver
? "border-accent bg-accent/5"
: tooBig
? "border-danger"
: "border-border hover:border-fg/40"
}`}
invalid={tooBig}
describedBy={tooBig ? refusalId : undefined}
>
{file ? (
<>
<FileIcon className="w-6 h-6 text-muted" />
<span className="text-sm font-medium break-all">{file.name}</span>
<span className="text-xs text-muted">{formatBytes(file.size, lang)}</span>
</>
) : (
<>
<UploadCloud className="w-7 h-7 text-muted" />
<span className="text-sm text-muted">
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
</span>
</>
)}
</Pressable>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) pick_(f);
e.target.value = ""; // let re-picking the same file fire onChange again
}}
/>
{(dragOver) =>
file ? (
<>
<FileIcon className="w-6 h-6 text-muted" />
<span className="text-sm font-medium break-all">{file.name}</span>
<span className="text-xs text-muted">{formatBytes(file.size, lang)}</span>
</>
) : (
<>
<UploadCloud className="w-7 h-7 text-muted" />
<span className="text-sm text-muted">
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
</span>
</>
)
}
</DropZone>
{tooBig && (
<p key={pickSeq} id={refusalId} className="text-xs text-danger mt-2" role="alert">
@@ -19,6 +19,7 @@ import {
import { DownloadFormats } from "./ProfileEditor";
import DiagnosticsSettings from "./DiagnosticsSettings";
import ApiTokens from "./ApiTokens";
import TakeoutImport from "./TakeoutImport";
import { type SettingsTabId } from "../../lib/settingsTab";
import { SCHEMES, type Scheme } from "../../lib/theme";
import { api, HttpError, type Me } from "../../lib/api";
@@ -870,6 +871,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</Section>
) : null}
<TakeoutImport />
<Section title={t("settings.account.dangerZone")}>
<p className="text-xs text-muted leading-relaxed mb-2">
{t("settings.account.deleteHint")}
@@ -0,0 +1,203 @@
// @vitest-environment jsdom
//
// Settings › Account › YouTube history: the Google Takeout files are dropped (or picked) and sent one
// request each, every file's outcome said on its own; the section then says what the import did —
// only the counts it produced, history first — or that it is running, failed or was cut off. Before
// the first import the drop zone is open; after one, "New import" opens it. i18n is key-passthrough
// (a key's options ride along in brackets).
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, HttpError, type TakeoutStatus, type TakeoutUpload } from "../../lib/api";
const mocks = vi.hoisted(() => ({ notify: vi.fn(), notifyActionError: vi.fn() }));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key,
}),
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: mocks.notify,
}));
vi.mock("../../lib/actionErrors", () => ({ notifyActionError: mocks.notifyActionError }));
vi.mock("../../lib/format", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/format")>()),
formatDateTime: (iso: string) => `at ${iso}`,
}));
import TakeoutImport from "./TakeoutImport";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.notify.mockClear();
mocks.notifyActionError.mockClear();
});
const run = (status: string, extra: Partial<NonNullable<TakeoutStatus["run"]>> = {}) => ({
status: status as NonNullable<TakeoutStatus["run"]>["status"],
started_at: "S",
finished_at: status === "running" || status === "interrupted" ? null : "F",
result: null,
failure: null,
...extra,
});
const uploaded = (videos: number): TakeoutUpload => ({
ok: true,
kind: "watched",
videos,
entries: videos,
ads: 0,
other: 0,
malformed: 0,
});
function mount(status: TakeoutStatus | Error | Promise<never>) {
const spy = vi.spyOn(api, "takeoutStatus");
if (status instanceof Error) spy.mockRejectedValue(status);
else if (status instanceof Promise) spy.mockReturnValue(status);
else spy.mockResolvedValue(status);
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<TakeoutImport />
</QueryClientProvider>,
);
return spy;
}
const zone = () => screen.queryByRole("button", { name: /settings\.takeout\.drop/ });
const line = () => screen.getByTestId("takeout-last").textContent;
const drop = (files: File[]) =>
act(async () => {
fireEvent.drop(zone()!, { dataTransfer: { files } });
});
const file = (name: string) => new File(["[]"], name);
describe("YouTube history import · what it says", () => {
it("before the first import: the drop zone, and no line about an import", async () => {
mount({ run: null, watched: {}, watch_later: {} });
await waitFor(() => expect(zone()).toBeTruthy());
expect(screen.queryByTestId("takeout-last")).toBeNull();
});
it("not while the last import is still being asked for", () => {
mount(new Promise<never>(() => {}));
expect(zone()).toBeNull();
});
it("after one: when, then only the counts it produced, the history's first", async () => {
mount({
run: run("ok"),
watched: { watched: 1020, waiting: 6785, short: 0, kept: 81 },
watch_later: { added: 41, waiting: 2 },
});
await waitFor(() => expect(screen.getByTestId("takeout-last")).toBeTruthy());
expect(line()).toBe(
[
"settings.takeout.last(at F)",
"settings.takeout.count.watched(1020,1,020)",
"settings.takeout.count.waiting(6785,6,785)",
"settings.takeout.count.kept(81,81)",
"settings.takeout.count.added(41,41)",
"settings.takeout.count.laterWaiting(2,2)",
].join(" · "),
);
expect(zone()).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "settings.takeout.newImport" }));
expect(zone()).toBeTruthy();
});
it("a failed run and one a restart cut off say so, not counts", async () => {
mount({ run: run("error"), watched: { watched: 3 } });
await waitFor(() => expect(line()).toBe("settings.takeout.failed(at F)"));
cleanup();
mount({ run: run("interrupted"), watched: { watched: 3 } });
await waitFor(() => expect(line()).toBe("settings.takeout.interrupted(at S)"));
});
it("a running import: the running line, no drop zone, asked again until it ends", async () => {
const spy = mount({ run: run("running"), watched: {} });
await waitFor(() =>
expect(screen.getByTestId("takeout-running").textContent).toBe("settings.takeout.running"),
);
expect(zone()).toBeNull();
spy.mockResolvedValue({ run: run("ok"), watched: { watched: 5 } });
await waitFor(() => expect(line()).toContain("settings.takeout.count.watched(5,5)"), {
timeout: 3000,
});
});
it("a status that could not be read says so, with a retry — not an empty section", async () => {
mount(new HttpError(500, "boom"));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain("settings.takeout.loadFailed"),
);
expect(zone()).toBeNull();
expect(screen.queryByTestId("takeout-last")).toBeNull();
});
it("a re-read that fails does not leave the previous answer standing under it", async () => {
const status = mount({ run: run("ok", { finished_at: "OLD" }), watched: {} });
await waitFor(() => expect(line()).toContain("OLD"));
fireEvent.click(screen.getByRole("button", { name: "settings.takeout.newImport" }));
vi.spyOn(api, "takeoutUpload").mockResolvedValue(uploaded(1));
status.mockRejectedValue(new HttpError(500, "boom"));
await drop([file("watch-history.json")]);
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain("settings.takeout.loadFailed"),
);
expect(screen.queryByTestId("takeout-last")).toBeNull();
});
});
describe("YouTube history import · the upload", () => {
it("sends each dropped file on its own, says each outcome, and reads the new run", async () => {
const status = mount({ run: null, watched: {} });
await waitFor(() => expect(zone()).toBeTruthy());
const refused = new HttpError(422, "refused");
const sent: string[] = [];
vi.spyOn(api, "takeoutUpload").mockImplementation(async (f: File) => {
sent.push(f.name);
if (f.name === "bad.html") throw refused;
return uploaded(f.name === "watch-history.json" ? 9034 : 56);
});
status.mockResolvedValue({ run: run("ok"), watched: { watched: 7 } });
await drop([file("watch-history.json"), file("bad.html"), file("Watch later-videos.csv")]);
await waitFor(() => expect(line()).toContain("settings.takeout.count.watched(7,7)"));
expect(sent).toEqual(["watch-history.json", "bad.html", "Watch later-videos.csv"]);
expect(mocks.notify.mock.calls.map(([n]) => (n as { message: string }).message)).toEqual([
"settings.takeout.read(watch-history.json,9034,9,034)",
"settings.takeout.read(Watch later-videos.csv,56,56)",
]);
expect(mocks.notifyActionError).toHaveBeenCalledWith(
refused,
"errors.writeFailed.takeoutUpload",
);
});
it("names the file being sent, then never shows the previous import's line before the new one", async () => {
const status = mount({ run: run("ok", { finished_at: "OLD" }), watched: {} });
await waitFor(() => expect(line()).toContain("OLD"));
fireEvent.click(screen.getByRole("button", { name: "settings.takeout.newImport" }));
let finish!: (v: TakeoutUpload) => void;
vi.spyOn(api, "takeoutUpload").mockReturnValue(new Promise((r) => (finish = r)));
let answer!: (v: TakeoutStatus) => void;
status.mockReturnValue(new Promise((r) => (answer = r)));
await drop([file("watch-history.json")]);
expect(screen.getByTestId("takeout-running").textContent).toBe(
"settings.takeout.uploading(watch-history.json)",
);
await act(async () => finish(uploaded(1)));
// The new run is still being read: the upload's line stays, the old import's does not return.
expect(screen.queryByTestId("takeout-last")).toBeNull();
await act(async () => answer({ run: run("ok", { finished_at: "NEW" }), watched: {} }));
await waitFor(() => expect(line()).toContain("NEW"));
});
});
@@ -0,0 +1,179 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw, UploadCloud } from "lucide-react";
import { Section } from "../../components/ui/form";
import { Button } from "../../components/ui/Button";
import { DropZone } from "../../components/ui/DropZone";
import { StateMessage } from "../../components/ui/QueryState";
import { api, type TakeoutStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { formatDateTime, formatNumber } from "../../lib/format";
// Settings › Account: the user's Google Takeout export, imported. What they watched on YouTube and
// their YouTube Watch later list reach no API, so the export's two files are uploaded here, one
// request each; the server tells them apart and runs the import in the background, and its outcome
// also reaches the inbox. The import has no steps to count (the whole history is applied in one
// go), so while it runs the section says so and asks again until it is done.
const POLL_MS = 1500;
// The outcome counts, in the order they are said: the history's, then Watch later's. A count the
// import did not produce is not said at all.
const HISTORY = [
["watched", "watched"],
["waiting", "waiting"],
["short", "short"],
["kept", "kept"],
] as const;
const WATCH_LATER = [
["added", "added"],
["present", "present"],
["missing", "missing"],
["waiting", "laterWaiting"],
] as const;
function countParts(s: TakeoutStatus, say: (key: string, n: number) => string): string[] {
const parts: string[] = [];
for (const [outcome, key] of HISTORY) {
const n = s.watched?.[outcome] ?? 0;
if (n) parts.push(say(key, n));
}
for (const [outcome, key] of WATCH_LATER) {
const n = s.watch_later?.[outcome] ?? 0;
if (n) parts.push(say(key, n));
}
return parts;
}
export default function TakeoutImport() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const status = useQuery({
queryKey: qk.takeout(),
queryFn: api.takeoutStatus,
refetchInterval: (q) => (q.state.data?.run?.status === "running" ? POLL_MS : false),
});
const [uploading, setUploading] = useState<string | null>(null);
// "New import" reopens the drop zone over a finished one; before the first import it is open.
const [reopened, setReopened] = useState(false);
const run = status.data?.run ?? null;
const running = uploading !== null || run?.status === "running";
const finishedAt = run?.finished_at ?? null;
// A run that ended has changed what the feed and Watch later show: those lists are read again
// when the user next opens them, not 30 s later from the cache.
useEffect(() => {
if (!finishedAt) return;
void qc.invalidateQueries({ queryKey: qk.feed() });
void qc.invalidateQueries({ queryKey: qk.playlist() });
}, [finishedAt, qc]);
const upload = async (files: File[]) => {
for (const file of files) await uploadOne(file);
// The new run read first, then the upload's line let go: the other way round the previous
// import's line showed for a moment, as if this upload had done nothing.
await qc.invalidateQueries({ queryKey: qk.takeout() });
setUploading(null);
setReopened(false);
};
const uploadOne = async (file: File) => {
setUploading(file.name);
try {
const res = await api.takeoutUpload(file);
notify({
level: "success",
message: t("settings.takeout.read", {
name: file.name,
count: res.videos,
n: formatNumber(res.videos, lang),
}),
});
} catch (e) {
// A refusal names its reason (not a Takeout file, nothing in it); the next file still goes.
notifyActionError(e, t("errors.writeFailed.takeoutUpload"));
}
};
const say = (key: string, n: number) =>
t(`settings.takeout.count.${key}`, { count: n, n: formatNumber(n, lang) });
const when = (iso: string | null) => (iso ? formatDateTime(iso, lang) : "");
// Not while the last import is still being asked for: a zone that flashes before the line that
// replaces it would offer a first import to someone who has done one.
const showZone = status.isSuccess && !running && (run === null || reopened);
return (
<Section title={t("settings.takeout.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.takeout.intro")}</p>
{status.isError && (
<StateMessage compact tone="error" onRetry={() => void status.refetch()}>
{t("settings.takeout.loadFailed")}
</StateMessage>
)}
{showZone && (
<>
<DropZone className="px-4 py-5" multiple accept=".json,.csv" onFiles={upload}>
{(dragOver) => (
<>
<UploadCloud className="w-6 h-6 text-muted" />
<span className="text-sm text-muted">
{dragOver ? t("settings.takeout.dropActive") : t("settings.takeout.drop")}
</span>
</>
)}
</DropZone>
<details className="mt-2">
<summary className="text-xs text-muted cursor-pointer">
{t("settings.takeout.howTo")}
</summary>
<p className="mt-1 text-xs text-muted leading-relaxed">
{t("settings.takeout.howToBody")}
</p>
</details>
</>
)}
{running ? (
<div
className="mt-2 flex items-center gap-2 text-xs text-muted"
role="status"
data-testid="takeout-running"
>
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
{uploading !== null
? t("settings.takeout.uploading", { name: uploading })
: t("settings.takeout.running")}
</div>
) : (
// A failed re-read keeps the previous answer: it is not shown under the line saying the
// read failed, as if it were current.
run &&
status.data &&
!status.isError && (
<div className="mt-2 flex items-center justify-between gap-3">
<p className="text-xs text-muted" data-testid="takeout-last">
{run.status === "error"
? t("settings.takeout.failed", { when: when(run.finished_at) })
: run.status === "interrupted"
? t("settings.takeout.interrupted", { when: when(run.started_at) })
: [
t("settings.takeout.last", { when: when(run.finished_at) }),
...countParts(status.data, say),
].join(" · ")}
</p>
{!reopened && (
<Button variant="outline" size="sm" onClick={() => setReopened(true)}>
{t("settings.takeout.newImport")}
</Button>
)}
</div>
)
)}
</Section>
);
}