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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user