A refused Facebook cookie on a group post is no longer "no video in this post":
measured on dev, Facebook answers with its login page (no post id at all), so the
adapter now raises its own POST_NOT_SHOWN verdict and the catalog explains it as
the refused cookie. The adapter owns both post verdicts and the "cannot parse
data" wall wording, so the Chrome retry and the catalog read one constant. The
group-post "no formats" entry is cookie-scoped (with a cookie yt-dlp only gets the
post's video link) and uses the adapter's own group-post test instead of a
"/groups/" substring.
Job transitions that move an asset's ref count (resume, cancel, delete,
redownload) re-read the job under a row lock, asset first, so two overlapping
requests can no longer release the same hold. get_or_create_asset loses an insert
race inside a SAVEPOINT instead of rolling back the caller's pending writes (Retry
lost its cleared error). Every worker-side job failure goes through
finalize_if_running and writes error_detail, so a cancel that landed first is kept
and a failure never shows an earlier one's copy. The worker asks the download
child's own cookie gate (credentials.usable_cookie) when a job inherits a failed
asset.
UI: the Facebook copy no longer points at a Retry button the failure window does
not have, says "paste" like Settings, and the HU wording is fixed
("Újraletöltés" on the re-download confirm). A category this client has no copy
for falls back to the generic sentence instead of a raw key.
Tests: route serialization of error_detail, the probed duration through a real
_download, the page-read cap and a failed read, a Retry off a pending asset, the
confirm-button labels, the lock and savepoint races.
185 lines
6.9 KiB
TypeScript
185 lines
6.9 KiB
TypeScript
import { useState } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import Modal from "./ui/Modal";
|
|
import { api, type DownloadJob } from "../lib/api";
|
|
import { inputCls } from "./ui/form";
|
|
import { Button } from "./ui/Button";
|
|
import { IconBtn } from "./downloadRow";
|
|
import { failCopyKeys } from "../lib/downloadFailure";
|
|
|
|
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
|
// editing and the failure-detail window.
|
|
|
|
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
|
|
// All are display-only overrides — the on-disk file is never renamed.
|
|
export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const [name, setName] = useState(job.display_name || job.asset?.title || "");
|
|
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
|
|
const [channelUrl, setChannelUrl] = useState(
|
|
job.display_uploader_url || job.asset?.uploader_url || "",
|
|
);
|
|
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
|
|
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
|
|
|
|
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
|
|
const addLink = () => setLinks((ls) => [...ls, ""]);
|
|
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
|
|
|
|
const save = useMutation({
|
|
mutationFn: () =>
|
|
api.updateDownloadMeta(job.id, {
|
|
display_name: name.trim(),
|
|
display_uploader: channel.trim(),
|
|
display_uploader_url: channelUrl.trim(),
|
|
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
|
onClose();
|
|
},
|
|
meta: { failTitle: "errors.writeFailed.downloadDetails" },
|
|
});
|
|
|
|
return (
|
|
<Modal title={t("downloads.edit.title")} onClose={onClose}>
|
|
<div className="space-y-3">
|
|
<label className="block">
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className={inputCls}
|
|
autoFocus
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
|
|
<input
|
|
value={channel}
|
|
onChange={(e) => setChannel(e.target.value)}
|
|
className={inputCls}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
|
|
<input
|
|
value={channelUrl}
|
|
onChange={(e) => setChannelUrl(e.target.value)}
|
|
placeholder="https://…"
|
|
className={inputCls}
|
|
/>
|
|
</label>
|
|
<div>
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
|
|
<div className="space-y-2">
|
|
{links.map((l, i) => (
|
|
<div key={i} className="flex gap-2">
|
|
<input
|
|
value={l}
|
|
onChange={(e) => setLink(i, e.target.value)}
|
|
placeholder="https://…"
|
|
className={inputCls}
|
|
/>
|
|
<IconBtn
|
|
onClick={() => removeLink(i)}
|
|
title={t("downloads.edit.removeLink")}
|
|
danger
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</IconBtn>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={addLink}
|
|
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
|
|
>
|
|
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => save.mutate()}
|
|
disabled={!name.trim() || save.isPending}
|
|
>
|
|
{t("downloads.edit.save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
|
|
// unsupported-host/…), where a retry can't help, or need a fix outside this window first (sign-in: a
|
|
// cookie in Settings — its copy then sends the user to the row) — the queue row still keeps its own
|
|
// retry control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
|
|
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
|
|
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
|
|
const RETRYABLE_ERRORS = ["postprocess", "network"];
|
|
|
|
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
|
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
|
export function ErrorModal({
|
|
job,
|
|
onRetry,
|
|
onClose,
|
|
}: {
|
|
job: DownloadJob;
|
|
onRetry: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const code = job.error_code || "unknown";
|
|
const [showRaw, setShowRaw] = useState(false);
|
|
const retryable = RETRYABLE_ERRORS.includes(code);
|
|
return (
|
|
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
|
|
<div className="space-y-3">
|
|
<div className="font-medium">{t(failCopyKeys(job, "short"))}</div>
|
|
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
|
|
{job.error && (
|
|
<div>
|
|
<button
|
|
onClick={() => setShowRaw((v) => !v)}
|
|
className="text-xs text-muted hover:text-fg transition"
|
|
>
|
|
{t("downloads.errors.fail.detailsToggle")}
|
|
</button>
|
|
{showRaw && (
|
|
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
|
|
{job.error}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end gap-2 pt-1">
|
|
{retryable && (
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => {
|
|
onRetry();
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("downloads.actions.retry")}
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
{t("common.close")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|