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:
@@ -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