diff --git a/VERSION b/VERSION index c11ca46..78756de 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.56.0 \ No newline at end of file +0.57.0 \ No newline at end of file diff --git a/backend/alembic/versions/0061_dl_job_error_code.py b/backend/alembic/versions/0061_dl_job_error_code.py new file mode 100644 index 0000000..e4ea75f --- /dev/null +++ b/backend/alembic/versions/0061_dl_job_error_code.py @@ -0,0 +1,26 @@ +"""add download_jobs.error_code — a category for a failed download (S2) + +The worker already stores the raw yt-dlp/ffmpeg failure text in `download_jobs.error`. This adds a +small category code (`unavailable` / `login_required` / `geo_blocked` / `format_unavailable` / +`drm` / `postprocess` / `source_missing` / `network` / `unknown`) computed by +`app.downloads.errors.classify`, so the UI can show a clean localized explanation and decide whether +a retry makes sense, keeping the raw text as a technical-detail disclosure. Nullable; only set on the +error path. Forward-only — an existing errored row simply has NULL until its next failure/retry. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0061_dl_job_error_code" +down_revision: Union[str, None] = "0060_plex_jsonb_null_normalize" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("download_jobs", sa.Column("error_code", sa.String(length=24), nullable=True)) + + +def downgrade() -> None: + op.drop_column("download_jobs", "error_code") diff --git a/backend/app/downloads/errors.py b/backend/app/downloads/errors.py new file mode 100644 index 0000000..ae57b56 --- /dev/null +++ b/backend/app/downloads/errors.py @@ -0,0 +1,76 @@ +"""Classify a raw yt-dlp / ffmpeg failure string into a stable category code. + +The worker stores the raw exception text (truncated) for technical detail, but a raw +`ERROR: [youtube] : Private video. Sign in…` is noise to a user. `classify()` maps that text +to one of a small set of category codes; the frontend turns the code into a localized, human +explanation (and decides whether a retry makes sense). The raw text stays available behind a +"details" disclosure. This is the same string-matching approach the worker already trusts for +`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this". +""" + +# (marker, category) — first match wins, so order most-specific → most-general. Matching is +# case-insensitive on a substring, against the raw yt-dlp/ffmpeg message. +_RULES: tuple[tuple[str, str], ...] = ( + # Login / age / members / bot-check — the user must be signed in (we deliberately are not). + ("sign in to confirm your age", "login_required"), + ("confirm your age", "login_required"), + ("sign in to confirm you're not a bot", "login_required"), + ("not a bot", "login_required"), + ("sign in to confirm", "login_required"), + ("members-only", "login_required"), + ("available to this channel's members", "login_required"), + ("join this channel", "login_required"), + ("this video is only available to", "login_required"), + # Geo-restricted. + ("in your country", "geo_blocked"), + ("available in your location", "geo_blocked"), + ("not available from your location", "geo_blocked"), + ("geo restricted", "geo_blocked"), + # An edit's source download went away — our own wording, and MORE specific than the generic + # "no longer available" below, so it must be matched first. + ("source download is no longer available", "source_missing"), + ("source file is missing", "source_missing"), + # Private / deleted / removed / terminated. + ("private video", "unavailable"), + ("video unavailable", "unavailable"), + ("no longer available", "unavailable"), + ("has been removed", "unavailable"), + ("removed by the uploader", "unavailable"), + ("account associated with this video has been terminated", "unavailable"), + ("this video is not available", "unavailable"), + ("this video has been removed", "unavailable"), + # DRM. + ("drm protected", "drm"), + ("drm-protected", "drm"), + # No usable media format. + ("requested format is not available", "format_unavailable"), + ("only images are available", "format_unavailable"), + ("no video formats found", "format_unavailable"), + ("unable to extract", "format_unavailable"), + # Our own post-processing (ffmpeg) step failed. + ("ffmpeg failed", "postprocess"), + ("ffmpeg stalled", "postprocess"), + ("postprocessing", "postprocess"), + ("produced no output file", "postprocess"), + ("no output file", "postprocess"), + # Network / transport. + ("unable to download", "network"), + ("http error", "network"), + ("timed out", "network"), + ("read timed out", "network"), + ("temporary failure in name resolution", "network"), + ("connection reset", "network"), + ("connection refused", "network"), + ("failed to resolve", "network"), +) + + +def classify(msg: str | None) -> str: + """Return a category code for a raw failure message (``"unknown"`` if nothing matches).""" + if not msg: + return "unknown" + low = msg.lower() + for marker, category in _RULES: + if marker in low: + return category + return "unknown" diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py index a28ee99..23331fd 100644 --- a/backend/app/downloads/og.py +++ b/backend/app/downloads/og.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import Session from app.config import settings from app.downloads import links as linksmod +from app.downloads import storage from app.models import DownloadJob, DownloadLink, MediaAsset _OG_BLOCK = re.compile(r".*?", re.DOTALL) @@ -24,6 +25,50 @@ def _tag(prop: str, content: str, attr: str = "property") -> str: return f'' +def _jpeg_size(path: Path) -> tuple[int, int] | None: + """Read (width, height) from a JPEG's SOF marker without an image library (Pillow isn't a dep). + + Facebook's scraper fetches og:image ASYNCHRONOUSLY on a URL's first scrape, so a card built then + has NO image unless og:image:width/height are declared up front — which is exactly why a freshly + shared /watch link unfurled title-only on desktop Messenger. We can't declare dimensions we don't + know, and the poster's aspect differs from the video's, so read them straight from the JPEG here + (cheap — only on a crawler hit). Returns None for a non-JPEG/truncated file, in which case the + dimensions are simply omitted (the prior behaviour).""" + try: + with path.open("rb") as f: + if f.read(2) != b"\xff\xd8": # SOI — not a JPEG + return None + while True: + b = f.read(1) + if not b: + return None + if b != b"\xff": + continue + marker = f.read(1) + while marker == b"\xff": # skip fill bytes + marker = f.read(1) + if not marker: + return None + m = marker[0] + if m == 0x01 or 0xD0 <= m <= 0xD9: # standalone markers, no length payload + continue + seg = f.read(2) + if len(seg) < 2: + return None + length = (seg[0] << 8) + seg[1] + # SOF0..SOF15 carry the frame dimensions (skip DHT/DAC/RST, which share the range). + if 0xC0 <= m <= 0xCF and m not in (0xC4, 0xC8, 0xCC): + data = f.read(5) + if len(data) < 5: + return None + height = (data[1] << 8) + data[2] + width = (data[3] << 8) + data[4] + return width, height + f.seek(length - 2, 1) # skip this segment's payload + except OSError: + return None + + def _watch_tags(token: str, db: Session) -> str | None: """Build the OG/Twitter meta block for a watch token, or None to keep the generic card.""" link = db.execute( @@ -73,6 +118,20 @@ def _watch_tags(token: str, db: Session) -> str | None: if image: # A real thumbnail → a large image card; without one, a plain (text) card is served. tags.append(_tag("og:image", image)) + # Facebook's scraper is pickier than Apple's on-device unfurl: spell out that the image is a + # public HTTPS JPEG, and declare its dimensions so the card includes the image on the very + # first (async) scrape instead of unfurling title-only — see _jpeg_size. + if image.startswith("https://"): + tags.append(_tag("og:image:secure_url", image)) + if image.endswith("poster.jpg") and asset.poster_path: + tags.append(_tag("og:image:type", "image/jpeg")) + # safe_abs_path returns None if the poster file is missing (gc'd) or escapes the root — + # guard it, else _jpeg_size(None) would raise an uncaught AttributeError on this public page. + poster_abs = storage.safe_abs_path(settings.download_root, asset.poster_path) + dims = _jpeg_size(poster_abs) if poster_abs else None + if dims: + tags.append(_tag("og:image:width", str(dims[0]))) + tags.append(_tag("og:image:height", str(dims[1]))) tags.append(_tag("twitter:card", "summary_large_image", attr="name")) tags.append(_tag("twitter:image", image, attr="name")) else: diff --git a/backend/app/models.py b/backend/app/models.py index a40b5e9..d871b8e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -877,7 +877,8 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin): speed_bps: Mapped[int | None] = mapped_column(BigInteger) eta_s: Mapped[int | None] = mapped_column(Integer) phase: Mapped[str | None] = mapped_column(String(32)) - error: Mapped[str | None] = mapped_column(Text) + error: Mapped[str | None] = mapped_column(Text) # raw yt-dlp/ffmpeg text (technical detail) + error_code: Mapped[str | None] = mapped_column(String(24)) # category, see downloads.errors.classify queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0") diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index dd04589..3325d50 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -223,7 +223,13 @@ def _channel_summary(ch: Channel) -> dict: def _channel_detail_dict( - channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int + channel: Channel, + *, + subscribed: bool, + explored: bool, + blocked: bool, + stored: int, + deep_requested: bool, ) -> dict: return { "id": channel.id, @@ -245,6 +251,10 @@ def _channel_detail_dict( "explored": explored, "blocked": blocked, "stored_videos": stored, + # Deep-backfill state, so the channel page can offer "get full history" when the catalog + # holds only the recent-backfill/RSS subset (video_count is YouTube's raw total). + "deep_requested": deep_requested, + "backfill_done": channel.backfill_done, "from_explore": channel.from_explore, "details_synced": channel.details_synced_at is not None, } @@ -274,11 +284,12 @@ def channel_detail( except YouTubeError as exc: log.warning("channel detail enrich failed (%s): %s", channel_id, exc) db.rollback() - subscribed = db.execute( - select(Subscription.id).where( + sub = db.execute( + select(Subscription).where( Subscription.user_id == user.id, Subscription.channel_id == channel_id ) - ).first() is not None + ).scalar_one_or_none() + subscribed = sub is not None explored = db.execute( select(ExploredChannel.id).where( ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id @@ -287,7 +298,12 @@ def channel_detail( blocked = _is_blocked(db, user, channel_id) stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0 return _channel_detail_dict( - channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored) + channel, + subscribed=subscribed, + explored=explored, + blocked=blocked, + stored=int(stored), + deep_requested=bool(sub.deep_requested) if sub else False, ) diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 2c5fefd..7c1c965 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -81,6 +81,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: "eta_s": job.eta_s, "phase": job.phase, "error": job.error, + "error_code": job.error_code, "queue_pos": job.queue_pos, "created_at": iso(job.created_at), "source_kind": job.source_kind, @@ -468,6 +469,7 @@ def resume_download( if job.status in ("paused", "error"): job.progress = 0 job.error = None + job.error_code = None # If the shared asset itself failed, reset it to pending so the worker actually # re-downloads — otherwise the requeued job would instantly re-inherit the asset's # stale error (the worker short-circuits a job whose asset is already errored). diff --git a/backend/app/worker.py b/backend/app/worker.py index 97819ec..8257923 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -31,6 +31,7 @@ from app import sysconfig from app.config import settings from app.db import SessionLocal from app.downloads import edit as editmod +from app.downloads import errors as dlerrors from app.downloads import formats, quota, service, storage from app.models import DownloadJob, MediaAsset from app.titles import normalize_title @@ -654,6 +655,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe if job: job.status = "error" job.error = msg + job.error_code = dlerrors.classify(msg) job.phase = None db.commit() log.warning("job %s failed: %s", job_id, msg) @@ -685,6 +687,7 @@ def _fail_edit(job_id: int, asset_id: int, msg: str) -> None: if job: job.status = "error" job.error = msg + job.error_code = dlerrors.classify(msg) job.phase = None db.commit() log.warning("edit job %s failed: %s", job_id, msg) diff --git a/backend/tests/test_download_errors.py b/backend/tests/test_download_errors.py new file mode 100644 index 0000000..85013f5 --- /dev/null +++ b/backend/tests/test_download_errors.py @@ -0,0 +1,47 @@ +"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2).""" +from app.downloads.errors import classify + + +def test_real_ytdlp_messages_map_to_categories(): + # Verbatim-shaped yt-dlp / ffmpeg output → the category the UI explains. + cases = { + "ERROR: [youtube] xxxxxxxxxxx: Video unavailable": "unavailable", + "ERROR: [youtube] abc: Private video. Sign in if you've been granted access": "unavailable", + "ERROR: [youtube] abc: This video is no longer available because the uploader has closed their " + "account": "unavailable", + "ERROR: [youtube] abc: Sign in to confirm your age. This video may be inappropriate": "login_required", + "ERROR: [youtube] abc: Sign in to confirm you're not a bot": "login_required", + "ERROR: [youtube] abc: Join this channel to get access to members-only content": "login_required", + "ERROR: [youtube] abc: The uploader has not made this video available in your country": "geo_blocked", + "ERROR: [youtube] abc: Requested format is not available": "format_unavailable", + "ERROR: [youtube] abc: Only images are available for download": "format_unavailable", + "ERROR: [generic] This video is DRM protected": "drm", + "ffmpeg failed (1): Conversion failed!": "postprocess", + "The source download is no longer available.": "source_missing", + "The source file is missing.": "source_missing", + "ERROR: unable to download video data: HTTP Error 403: Forbidden": "network", + "ERROR: Unable to download webpage: ": "network", + } + for msg, expected in cases.items(): + assert classify(msg) == expected, f"{msg!r} → {classify(msg)!r}, expected {expected!r}" + + +def test_unmatched_and_empty_are_unknown(): + assert classify("some totally novel failure text") == "unknown" + assert classify("") == "unknown" + assert classify(None) == "unknown" + + +def test_matching_is_case_insensitive(): + assert classify("VIDEO UNAVAILABLE") == "unavailable" + assert classify("drm protected") == "drm" + + +def test_login_beats_generic_unavailable_when_both_present(): + # A members-only video also reads as "unavailable" to a signed-out client; the sign-in cause is + # the more useful, more specific one, so it must win (rule order guards this). + msg = ( + "ERROR: [youtube] abc: Join this channel to get access to members-only content. " + "This video is unavailable" + ) + assert classify(msg) == "login_required" diff --git a/backend/tests/test_og.py b/backend/tests/test_og.py new file mode 100644 index 0000000..9e0cd6b --- /dev/null +++ b/backend/tests/test_og.py @@ -0,0 +1,64 @@ +"""JPEG dimension parsing for the /watch OG image tags (S2, item 6). + +Facebook's scraper fetches og:image asynchronously on first scrape, so the card is image-less +unless og:image:width/height are declared — which needs the poster's real dimensions, read from the +JPEG header without an image library. This pins that parser.""" +import struct + +from app.downloads.og import _jpeg_size + + +def _jpeg(width: int, height: int, sof_marker: int = 0xC0) -> bytes: + """A minimal but well-formed JPEG: SOI + a JFIF APP0 segment (which the parser must SKIP) + a + SOFn frame header carrying the dimensions + EOI. sof_marker=0xC0 baseline, 0xC2 progressive.""" + soi = b"\xff\xd8" + # APP0 JFIF: length 0x0010 (16) = the 2 length bytes + 14 payload bytes. + app0 = b"\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + # SOFn: length 0x0011 (17) = 2 + precision(1) + height(2) + width(2) + 1 comp * 9? Payload here is + # precision + h + w + numComp(1) + 3 components * 3 bytes = 1+2+2+1+9 = 15, +2 length = 17. + sof = ( + b"\xff" + + bytes([sof_marker]) + + b"\x00\x11\x08" + + struct.pack(">H", height) + + struct.pack(">H", width) + + b"\x03\x01\x22\x00\x02\x11\x01\x03\x11\x01" + ) + return soi + app0 + sof + b"\xff\xd9" + + +def test_reads_baseline_jpeg_dimensions(tmp_path): + p = tmp_path / "poster.jpg" + p.write_bytes(_jpeg(1280, 720)) + assert _jpeg_size(p) == (1280, 720) + + +def test_reads_progressive_jpeg_dimensions(tmp_path): + # ffmpeg posters can be progressive (SOF2); the width/height live in the same place. + p = tmp_path / "poster.jpg" + p.write_bytes(_jpeg(640, 1138, sof_marker=0xC2)) + assert _jpeg_size(p) == (640, 1138) + + +def test_skips_preceding_segments(tmp_path): + # The JFIF APP0 segment sits before the SOF; a naive scan that didn't skip it by length would + # misread. The helper builds that layout, so a correct result proves the skip. + p = tmp_path / "poster.jpg" + p.write_bytes(_jpeg(1920, 3413)) + assert _jpeg_size(p) == (1920, 3413) + + +def test_non_jpeg_returns_none(tmp_path): + p = tmp_path / "not.jpg" + p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32) + assert _jpeg_size(p) is None + + +def test_truncated_before_sof_returns_none(tmp_path): + p = tmp_path / "trunc.jpg" + p.write_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00") # SOI + start of APP0, then nothing + assert _jpeg_size(p) is None + + +def test_missing_file_returns_none(tmp_path): + assert _jpeg_size(tmp_path / "nope.jpg") is None diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 320e800..a8bef92 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -8,6 +8,7 @@ import { Ban, Check, ExternalLink, + History, Loader2, Plus, RefreshCw, @@ -150,6 +151,21 @@ export default function ChannelPage({ }, onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), }); + // "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's + // uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full + // back-catalog pager. Same trigger the channel manager uses. + const deepBackfill = useMutation({ + mutationFn: () => api.updateChannel(channelId, { deep_requested: true }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.channel(channelId) }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); + notify({ level: "info", message: t("channel.fullHistoryQueued") }); + }, + onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")), + }); + const onUnsubscribe = async () => { const ok = await confirm({ title: t("channel.unsubTitle"), @@ -191,7 +207,14 @@ export default function ChannelPage({ ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) : null, - ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null, + // Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the + // gap between the browsable videos and YouTube's raw count is honest rather than looking like + // missing data (the "get full history" button pulls the rest). + ch?.video_count != null + ? ch.subscribed && !ch.backfill_done && ch.stored_videos < ch.video_count + ? t("channel.videoCountStored", { stored: ch.stored_videos, total: ch.video_count }) + : t("channel.videoCount", { count: ch.video_count }) + : null, ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null, @@ -330,6 +353,27 @@ export default function ChannelPage({ )} + {/* Only the recent subset is stored → offer to pull the channel's full back-catalogue. + Once queued (deep_requested) it shows as loading; hidden once the backfill is done. */} + {ch?.subscribed && !ch.backfill_done && !me.is_demo && ( + + )} {!me.is_demo && ( + ); +} + function PriorityCell({ c, onPriority, + onSet, }: { c: ManagedChannel; onPriority: (delta: number) => void; + onSet: (value: number) => void; }) { const { t } = useTranslation(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const holdDelay = useRef | null>(null); + const holdRepeat = useRef | null>(null); + const suppressClick = useRef(false); + + const stop = useCallback(() => { + if (holdDelay.current) clearTimeout(holdDelay.current); + if (holdRepeat.current) clearTimeout(holdRepeat.current); + holdDelay.current = holdRepeat.current = null; + }, []); + useEffect(() => stop, [stop]); // clear any running timers on unmount + + // Hold past a threshold → auto-repeat at an accelerating rate, and suppress the trailing click so + // a hold doesn't double-count its final notch. A quick tap never crosses the threshold, so it + // falls through to onClick — which also keeps the keyboard path (Enter/Space) working. + const start = (delta: number) => { + suppressClick.current = false; + holdDelay.current = setTimeout(() => { + suppressClick.current = true; + let rate = 140; + const tick = () => { + onPriority(delta); + rate = Math.max(40, rate - 12); + holdRepeat.current = setTimeout(tick, rate); + }; + tick(); + }, 350); + }; + const onTap = (delta: number) => { + if (suppressClick.current) { + suppressClick.current = false; + return; + } + onPriority(delta); + }; + + const commitEdit = () => { + const parsed = parseInt(draft, 10); + if (!Number.isNaN(parsed) && parsed !== c.priority) onSet(parsed); + setEditing(false); + }; + const beginEdit = () => { + setDraft(String(c.priority)); + setEditing(true); + }; + + // The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the + // arrows while you press-and-hold — never covers the live value you're setting. Clicking the value + // opens an inline edit to type any integer directly (negatives allowed). return ( -
- - {c.priority} - +
+ {/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely + positioned OVER it (out of flow) so opening the editor never changes the row height/width — + it just overlays, extending left for a longer number. */} + + + {editing && ( + e.currentTarget.select()} + onChange={(e) => setDraft(e.target.value)} + onBlur={commitEdit} + onKeyDown={(e) => { + if (e.key === "Enter") commitEdit(); + else if (e.key === "Escape") setEditing(false); + }} + aria-label={t("channels.row.priorityHint")} + className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded px-1 outline-none z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + /> + )} + + + + +
); diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 84d68db..2effa7f 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, + CalendarClock, + ClipboardPaste, Copy, Download, Link2, @@ -18,7 +20,7 @@ import { Trash2, } from "lucide-react"; import clsx from "clsx"; -import Tabs, { usePersistedTab } from "./Tabs"; +import Tabs from "./Tabs"; import { PageToolbar } from "./PageShell"; import { LoadingState, StateMessage } from "./QueryState"; import Modal from "./Modal"; @@ -35,7 +37,7 @@ import { api, type DownloadJob } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; import { copyText } from "../lib/clipboard"; -import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format"; +import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format"; import { inputCls } from "./ui/form"; const GiB = 1024 ** 3; @@ -83,10 +85,24 @@ function StatusPill({ job }: { job: DownloadJob }) { function Thumb({ job }: { job: DownloadJob }) { // Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources. - const url = job.asset?.thumbnail_url || job.poster_url; + const primary = job.asset?.thumbnail_url || job.poster_url; + // A remote i.ytimg.com thumbnail 404s once the source video is deleted/privated, leaving a + // broken image. On load failure fall back to our always-present self-hosted poster. + const [src, setSrc] = useState(primary); + useEffect(() => setSrc(primary), [primary]); return (
- {url ? : null} + {src ? ( + { + if (job.poster_url && src !== job.poster_url) setSrc(job.poster_url); + }} + /> + ) : null}
); } @@ -194,7 +210,15 @@ function ChannelLine({ job }: { job: DownloadJob }) { return
{name}
; } -function DownloadRow({ job, children }: { job: DownloadJob; children?: React.ReactNode }) { +function DownloadRow({ + job, + onShowError, + children, +}: { + job: DownloadJob; + onShowError?: (job: DownloadJob) => void; + children?: React.ReactNode; +}) { const { t } = useTranslation(); const running = job.status === "running"; return ( @@ -218,7 +242,24 @@ function DownloadRow({ job, children }: { job: DownloadJob; children?: React.Rea {job.asset?.duration_s ? ( · {formatDuration(job.asset.duration_s)} ) : null} - {job.error ? · {job.error} : null} + {job.status === "done" && job.created_at ? ( + + · {relativeTime(job.created_at)} + + ) : null} + {job.status === "error" ? ( + + ) : null}
{job.source_url && } {job.extra_links?.map((url) => ( @@ -359,6 +400,71 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void ); } +// --- Error detail modal --------------------------------------------------------------------- + +// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…), +// where a retry can't help — the queue row still keeps its own retry control regardless. +const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"]; + +// 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. +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 ( + +
+
{t(`downloads.errors.fail.cat.${code}.short`)}
+

{t(`downloads.errors.fail.cat.${code}.body`)}

+ {job.error && ( +
+ + {showRaw && ( +
+                {job.error}
+              
+ )} +
+ )} +
+ {retryable && ( + + )} + +
+
+
+ ); +} + // --- Usage bar ------------------------------------------------------------------------------ function UsageBar() { @@ -655,13 +761,16 @@ export default function DownloadCenter() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const [tab, setTab] = usePersistedTab("siftlode.downloadTab", "queue"); + // Always land on the queue tab (not a persisted last-tab) — the queue is where new work and + // any failures are, so it's the useful default every visit. + const [tab, setTab] = useState("queue"); const [addUrl, setAddUrl] = useState(""); const [addSource, setAddSource] = useState(null); const [manage, setManage] = useState(false); const [metaJob, setMetaJob] = useState(null); const [shareJob, setShareJob] = useState(null); const [editJob, setEditJob] = useState(null); + const [errorJob, setErrorJob] = useState(null); const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 }); const sharedQ = useQuery({ @@ -706,14 +815,28 @@ export default function DownloadCenter() { if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn(); }; + // Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context + + // a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste + // button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if + // the browser refuses without a click). Only accept an http(s) URL, don't clobber existing text. + const pasteFromClipboard = async (force = false) => { + if (!force && addUrl.trim()) return; + try { + const text = (await navigator.clipboard?.readText())?.trim(); + if (text && /^https?:\/\/\S+$/i.test(text)) setAddUrl(text); + } catch { + /* permission denied or unsupported — the manual paste button still works on a click */ + } + }; + const tabs = [ { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length }, { id: "done", label: t("downloads.tabs.done"), badge: library.length }, { id: "shared", label: t("downloads.tabs.shared") }, ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), ]; - // A persisted tab can point at one no longer available (e.g. "system" restored for a now-non-admin - // account) — that would render a blank page with no active tab. Fall back to the default. + // Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session) + // — that would render a blank page with no active tab. Fall back to the default. useEffect(() => { if (!tabs.some((tb) => tb.id === tab)) setTab("queue"); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -756,10 +879,19 @@ export default function DownloadCenter() { setAddUrl(e.target.value)} + onFocus={() => pasteFromClipboard()} onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())} placeholder={t("downloads.page.addUrl")} className={inputCls} /> +