Merge fixpack-downloads-channels → 0.57.0

Downloads & channels polish (S1–S4): queue-default/date/auto-link/thumb-fallback/clipboard-paste,
categorized download-failure dialog (migration 0061), channel full-history + honest count,
press-and-hold + inline-editable priority, iOS background audio on the watch page, FB OG image
dimensions. Two user-triggered /code-review high rounds (round 1: 5 findings → 4 fixed + 1 intended;
round 2: clean).
This commit is contained in:
2026-07-28 02:59:19 +02:00
24 changed files with 907 additions and 46 deletions
+1 -1
View File
@@ -1 +1 @@
0.56.0 0.57.0
@@ -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")
+76
View File
@@ -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] <id>: 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"
+59
View File
@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from app.config import settings from app.config import settings
from app.downloads import links as linksmod from app.downloads import links as linksmod
from app.downloads import storage
from app.models import DownloadJob, DownloadLink, MediaAsset from app.models import DownloadJob, DownloadLink, MediaAsset
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL) _OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
@@ -24,6 +25,50 @@ def _tag(prop: str, content: str, attr: str = "property") -> str:
return f'<meta {attr}="{prop}" content="{html.escape(content, quote=True)}" />' return f'<meta {attr}="{prop}" content="{html.escape(content, quote=True)}" />'
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: 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.""" """Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
link = db.execute( link = db.execute(
@@ -73,6 +118,20 @@ def _watch_tags(token: str, db: Session) -> str | None:
if image: if image:
# A real thumbnail → a large image card; without one, a plain (text) card is served. # A real thumbnail → a large image card; without one, a plain (text) card is served.
tags.append(_tag("og:image", image)) 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:card", "summary_large_image", attr="name"))
tags.append(_tag("twitter:image", image, attr="name")) tags.append(_tag("twitter:image", image, attr="name"))
else: else:
+2 -1
View File
@@ -877,7 +877,8 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
speed_bps: Mapped[int | None] = mapped_column(BigInteger) speed_bps: Mapped[int | None] = mapped_column(BigInteger)
eta_s: Mapped[int | None] = mapped_column(Integer) eta_s: Mapped[int | None] = mapped_column(Integer)
phase: Mapped[str | None] = mapped_column(String(32)) 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") queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+21 -5
View File
@@ -223,7 +223,13 @@ def _channel_summary(ch: Channel) -> dict:
def _channel_detail_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: ) -> dict:
return { return {
"id": channel.id, "id": channel.id,
@@ -245,6 +251,10 @@ def _channel_detail_dict(
"explored": explored, "explored": explored,
"blocked": blocked, "blocked": blocked,
"stored_videos": stored, "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, "from_explore": channel.from_explore,
"details_synced": channel.details_synced_at is not None, "details_synced": channel.details_synced_at is not None,
} }
@@ -274,11 +284,12 @@ def channel_detail(
except YouTubeError as exc: except YouTubeError as exc:
log.warning("channel detail enrich failed (%s): %s", channel_id, exc) log.warning("channel detail enrich failed (%s): %s", channel_id, exc)
db.rollback() db.rollback()
subscribed = db.execute( sub = db.execute(
select(Subscription.id).where( select(Subscription).where(
Subscription.user_id == user.id, Subscription.channel_id == channel_id 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( explored = db.execute(
select(ExploredChannel.id).where( select(ExploredChannel.id).where(
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
@@ -287,7 +298,12 @@ def channel_detail(
blocked = _is_blocked(db, user, channel_id) blocked = _is_blocked(db, user, channel_id)
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0 stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
return _channel_detail_dict( 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,
) )
+2
View File
@@ -81,6 +81,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"eta_s": job.eta_s, "eta_s": job.eta_s,
"phase": job.phase, "phase": job.phase,
"error": job.error, "error": job.error,
"error_code": job.error_code,
"queue_pos": job.queue_pos, "queue_pos": job.queue_pos,
"created_at": iso(job.created_at), "created_at": iso(job.created_at),
"source_kind": job.source_kind, "source_kind": job.source_kind,
@@ -468,6 +469,7 @@ def resume_download(
if job.status in ("paused", "error"): if job.status in ("paused", "error"):
job.progress = 0 job.progress = 0
job.error = None job.error = None
job.error_code = None
# If the shared asset itself failed, reset it to pending so the worker actually # 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 # 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). # stale error (the worker short-circuits a job whose asset is already errored).
+3
View File
@@ -31,6 +31,7 @@ from app import sysconfig
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.downloads import edit as editmod from app.downloads import edit as editmod
from app.downloads import errors as dlerrors
from app.downloads import formats, quota, service, storage from app.downloads import formats, quota, service, storage
from app.models import DownloadJob, MediaAsset from app.models import DownloadJob, MediaAsset
from app.titles import normalize_title 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: if job:
job.status = "error" job.status = "error"
job.error = msg job.error = msg
job.error_code = dlerrors.classify(msg)
job.phase = None job.phase = None
db.commit() db.commit()
log.warning("job %s failed: %s", job_id, msg) 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: if job:
job.status = "error" job.status = "error"
job.error = msg job.error = msg
job.error_code = dlerrors.classify(msg)
job.phase = None job.phase = None
db.commit() db.commit()
log.warning("edit job %s failed: %s", job_id, msg) log.warning("edit job %s failed: %s", job_id, msg)
+47
View File
@@ -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: <urlopen error timed out>": "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"
+64
View File
@@ -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
+45 -1
View File
@@ -8,6 +8,7 @@ import {
Ban, Ban,
Check, Check,
ExternalLink, ExternalLink,
History,
Loader2, Loader2,
Plus, Plus,
RefreshCw, RefreshCw,
@@ -150,6 +151,21 @@ export default function ChannelPage({
}, },
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), 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 onUnsubscribe = async () => {
const ok = await confirm({ const ok = await confirm({
title: t("channel.unsubTitle"), title: t("channel.unsubTitle"),
@@ -191,7 +207,14 @@ export default function ChannelPage({
ch?.subscriber_count != null ch?.subscriber_count != null
? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) })
: null, : 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 ch?.total_view_count != null
? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })
: null, : null,
@@ -330,6 +353,27 @@ export default function ChannelPage({
<SlidersHorizontal className="w-4 h-4" /> <SlidersHorizontal className="w-4 h-4" />
</button> </button>
)} )}
{/* 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 && (
<button
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
disabled={ch.deep_requested || deepBackfill.isPending}
title={
ch.deep_requested ? t("channel.fullHistoryLoading") : t("channel.fullHistory")
}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-60 disabled:hover:text-muted disabled:hover:border-border transition"
>
{ch.deep_requested ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<History className="w-4 h-4" />
)}
<span className="hidden sm:inline">
{ch.deep_requested ? t("channel.fullHistoryLoading") : t("channel.fullHistory")}
</span>
</button>
)}
{!me.is_demo && ( {!me.is_demo && (
<button <button
onClick={() => block.mutate()} onClick={() => block.mutate()}
+190 -22
View File
@@ -149,15 +149,53 @@ export default function Channels() {
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list
// doesn't jump (the server list is priority-sorted; refetching would reorder rows and // doesn't jump (the server list is priority-sorted; refetching would reorder rows and
// lose your scroll position). The new order takes effect next time the page loads. // lose your scroll position). The new order takes effect next time the page loads.
const bumpPriority = (id: string, current: number, delta: number) => { // Press-and-hold fires many bumps, so the PATCH is DEBOUNCED per channel: each bump reads the
const next = current + delta; // accumulated value from the cache, and the server call flushes once ~400ms after the last step
// (instead of one request per notch). Pending flushes are fired on unmount so a last-moment bump
// isn't lost.
const prioFlush = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const flushPriority = (id: string) => {
const latest = qc
.getQueryData<ManagedChannel[]>(qk.channels())
?.find((c) => c.id === id)?.priority;
if (latest == null) return;
api
.updateChannel(id, { priority: latest })
.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
};
// Set an absolute priority (used by both the arrows — via bumpPriority — and the inline edit),
// optimistically + a debounced flush.
const setPriority = (id: string, next: number) => {
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) => qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)), (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
); );
api const timers = prioFlush.current;
.updateChannel(id, { priority: next }) const pending = timers.get(id);
.catch(() => qc.invalidateQueries({ queryKey: qk.channels() })); if (pending) clearTimeout(pending);
timers.set(
id,
setTimeout(() => {
timers.delete(id);
flushPriority(id);
}, 400),
);
}; };
const bumpPriority = (id: string, delta: number) => {
const cur =
qc.getQueryData<ManagedChannel[]>(qk.channels())?.find((c) => c.id === id)?.priority ?? 0;
setPriority(id, cur + delta);
};
useEffect(() => {
const timers = prioFlush.current;
return () => {
timers.forEach((timer, id) => {
clearTimeout(timer);
flushPriority(id);
});
timers.clear();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Tagging is updated optimistically in place (no refetch of the whole channel list, which // Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. // made the toggle feel ~2s slow); only revert by refetching if the server call fails.
@@ -291,7 +329,13 @@ export default function Channels() {
hideInCard: true, hideInCard: true,
sortValue: (c) => c.priority, sortValue: (c) => c.priority,
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v }, filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
render: (c) => <PriorityCell c={c} onPriority={(d) => bumpPriority(c.id, c.priority, d)} />, render: (c) => (
<PriorityCell
c={c}
onPriority={(d) => bumpPriority(c.id, d)}
onSet={(v) => setPriority(c.id, v)}
/>
),
}, },
{ {
key: "actions", key: "actions",
@@ -775,32 +819,156 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str
); );
} }
// Module-level (NOT defined inside PriorityCell) so its identity is stable across PriorityCell
// re-renders — otherwise every hold-repeat tick would remount the button and drop the pointer
// capture set on pointerdown, letting a hold run away if the pointer drifts off the arrow.
function PriorityStepButton({
delta,
Icon,
label,
onHoldStart,
onStop,
onTap,
}: {
delta: number;
Icon: typeof ArrowUp;
label: string;
onHoldStart: (delta: number) => void;
onStop: () => void;
onTap: (delta: number) => void;
}) {
return (
<button
onPointerDown={(e) => {
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* invalid pointer id (e.g. synthetic events) — capture is a nicety, not required */
}
onHoldStart(delta);
}}
onPointerUp={onStop}
onPointerCancel={onStop}
onClick={() => onTap(delta)}
className="text-muted hover:text-accent leading-none"
aria-label={label}
>
<Icon className="w-3.5 h-3.5" />
</button>
);
}
function PriorityCell({ function PriorityCell({
c, c,
onPriority, onPriority,
onSet,
}: { }: {
c: ManagedChannel; c: ManagedChannel;
onPriority: (delta: number) => void; onPriority: (delta: number) => void;
onSet: (value: number) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const holdDelay = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdRepeat = useRef<ReturnType<typeof setTimeout> | 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 ( return (
<Tooltip hint={t("channels.row.priorityHint")}> <Tooltip hint={t("channels.row.priorityHint")}>
<div className="inline-flex flex-col items-center cursor-help"> <div className="inline-flex items-center gap-1.5 cursor-help">
<button {/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely
onClick={() => onPriority(1)} positioned OVER it (out of flow) so opening the editor never changes the row height/width —
className="text-muted hover:text-accent" it just overlays, extending left for a longer number. */}
aria-label={t("channels.row.raisePriority")} <span className="relative inline-block w-6 h-5 text-right leading-5">
> <button
<ArrowUp className="w-3.5 h-3.5" /> onClick={beginEdit}
</button> aria-label={t("channels.row.editPriority")}
<span className="text-xs text-muted tabular-nums">{c.priority}</span> title={t("channels.row.editPriority")}
<button className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
onClick={() => onPriority(-1)} >
className="text-muted hover:text-accent" {c.priority}
aria-label={t("channels.row.lowerPriority")} </button>
> {editing && (
<ArrowDown className="w-3.5 h-3.5" /> <input
</button> type="number"
value={draft}
autoFocus
onFocus={(e) => 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"
/>
)}
</span>
<span className="inline-flex flex-col">
<PriorityStepButton
delta={1}
Icon={ArrowUp}
label={t("channels.row.raisePriority")}
onHoldStart={start}
onStop={stop}
onTap={onTap}
/>
<PriorityStepButton
delta={-1}
Icon={ArrowDown}
label={t("channels.row.lowerPriority")}
onHoldStart={start}
onStop={stop}
onTap={onTap}
/>
</span>
</div> </div>
</Tooltip> </Tooltip>
); );
+149 -10
View File
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Ban, Ban,
CalendarClock,
ClipboardPaste,
Copy, Copy,
Download, Download,
Link2, Link2,
@@ -18,7 +20,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs from "./Tabs";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./QueryState"; import { LoadingState, StateMessage } from "./QueryState";
import Modal from "./Modal"; import Modal from "./Modal";
@@ -35,7 +37,7 @@ import { api, type DownloadJob } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads"; import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard"; 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"; import { inputCls } from "./ui/form";
const GiB = 1024 ** 3; const GiB = 1024 ** 3;
@@ -83,10 +85,24 @@ function StatusPill({ job }: { job: DownloadJob }) {
function Thumb({ job }: { job: DownloadJob }) { function Thumb({ job }: { job: DownloadJob }) {
// Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources. // 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 ( return (
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border"> <div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
{url ? <img src={url} alt="" loading="lazy" className="w-full h-full object-cover" /> : null} {src ? (
<img
src={src}
alt=""
loading="lazy"
className="w-full h-full object-cover"
onError={() => {
if (job.poster_url && src !== job.poster_url) setSrc(job.poster_url);
}}
/>
) : null}
</div> </div>
); );
} }
@@ -194,7 +210,15 @@ function ChannelLine({ job }: { job: DownloadJob }) {
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>; return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
} }
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 { t } = useTranslation();
const running = job.status === "running"; const running = job.status === "running";
return ( return (
@@ -218,7 +242,24 @@ function DownloadRow({ job, children }: { job: DownloadJob; children?: React.Rea
{job.asset?.duration_s ? ( {job.asset?.duration_s ? (
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span> <span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
) : null} ) : null}
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null} {job.status === "done" && job.created_at ? (
<span
className="text-muted inline-flex items-center gap-1"
title={new Date(job.created_at).toLocaleString()}
>
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
</span>
) : null}
{job.status === "error" ? (
<button
onClick={() => onShowError?.(job)}
className="text-red-400 hover:text-red-300 inline-flex items-center gap-1 truncate"
title={t("downloads.errors.fail.title")}
>
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
</button>
) : null}
</div> </div>
{job.source_url && <SourceRef url={job.source_url} />} {job.source_url && <SourceRef url={job.source_url} />}
{job.extra_links?.map((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 (
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
<div className="space-y-3">
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.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 break-words max-h-40 text-muted">
{job.error}
</pre>
)}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
{retryable && (
<button
onClick={() => {
onRetry();
onClose();
}}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition"
>
{t("downloads.actions.retry")}
</button>
)}
<button
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("common.close")}
</button>
</div>
</div>
</Modal>
);
}
// --- Usage bar ------------------------------------------------------------------------------ // --- Usage bar ------------------------------------------------------------------------------
function UsageBar() { function UsageBar() {
@@ -655,13 +761,16 @@ export default function DownloadCenter() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); 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 [addUrl, setAddUrl] = useState("");
const [addSource, setAddSource] = useState<string | null>(null); const [addSource, setAddSource] = useState<string | null>(null);
const [manage, setManage] = useState(false); const [manage, setManage] = useState(false);
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null); const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null); const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [editJob, setEditJob] = useState<DownloadJob | null>(null); const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 }); const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 });
const sharedQ = useQuery({ const sharedQ = useQuery({
@@ -706,14 +815,28 @@ export default function DownloadCenter() {
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn(); 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 = [ const tabs = [
{ id: "queue", label: t("downloads.tabs.queue"), badge: queue.length }, { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
{ id: "done", label: t("downloads.tabs.done"), badge: library.length }, { id: "done", label: t("downloads.tabs.done"), badge: library.length },
{ id: "shared", label: t("downloads.tabs.shared") }, { id: "shared", label: t("downloads.tabs.shared") },
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), ...(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 // Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session)
// account) — that would render a blank page with no active tab. Fall back to the default. // — that would render a blank page with no active tab. Fall back to the default.
useEffect(() => { useEffect(() => {
if (!tabs.some((tb) => tb.id === tab)) setTab("queue"); if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -756,10 +879,19 @@ export default function DownloadCenter() {
<input <input
value={addUrl} value={addUrl}
onChange={(e) => setAddUrl(e.target.value)} onChange={(e) => setAddUrl(e.target.value)}
onFocus={() => pasteFromClipboard()}
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())} onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
placeholder={t("downloads.page.addUrl")} placeholder={t("downloads.page.addUrl")}
className={inputCls} className={inputCls}
/> />
<button
onClick={() => pasteFromClipboard(true)}
title={t("downloads.page.paste")}
aria-label={t("downloads.page.paste")}
className="inline-flex items-center justify-center px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition shrink-0"
>
<ClipboardPaste className="w-4 h-4" />
</button>
<button <button
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())} onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition shrink-0" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition shrink-0"
@@ -769,7 +901,7 @@ export default function DownloadCenter() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{queue.map((job) => ( {queue.map((job) => (
<DownloadRow key={job.id} job={job}> <DownloadRow key={job.id} job={job} onShowError={setErrorJob}>
{(job.status === "queued" || job.status === "running") && ( {(job.status === "queued" || job.status === "running") && (
<IconBtn <IconBtn
onClick={() => act.mutate({ id: job.id, op: "pause" })} onClick={() => act.mutate({ id: job.id, op: "pause" })}
@@ -934,6 +1066,13 @@ export default function DownloadCenter() {
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />} {metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />} {shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />} {editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
{errorJob && (
<ErrorModal
job={errorJob}
onRetry={() => act.mutate({ id: errorJob.id, op: "resume" })}
onClose={() => setErrorJob(null)}
/>
)}
</Suspense> </Suspense>
</div> </div>
</> </>
+17 -2
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -271,6 +271,21 @@ function LinkShare({ job }: { job: DownloadJob }) {
}, },
}); });
// Open with a link already generated: sharing is almost always the goal, so mint a default
// (no password, no expiry, view-only) link the moment the dialog finds none — as if the user
// had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false);
useEffect(() => {
if (links.data && links.data.length === 0 && !autoTried.current && !create.isPending) {
autoTried.current = true;
create.mutate();
}
// Only links.data (empty → auto-create once) should drive this; `create` is a fresh object every
// render and would re-run the effect needlessly. The autoTried ref makes it fire exactly once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [links.data]);
return ( return (
<div> <div>
<div className="flex items-center gap-2 text-sm font-medium mb-1"> <div className="flex items-center gap-2 text-sm font-medium mb-1">
@@ -282,7 +297,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
{(links.data ?? []).map((l) => ( {(links.data ?? []).map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} /> <LinkRow key={l.id} link={l} onChanged={invalidate} />
))} ))}
{links.data && links.data.length === 0 && !creating && ( {links.data && links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p> <p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
)} )}
</div> </div>
+83 -1
View File
@@ -1,10 +1,89 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Download, Link2, Lock, PlayCircle } from "lucide-react"; import { Download, Link2, Lock, PlayCircle } from "lucide-react";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app // Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny // tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
// self-contained i18n dict (the public viewer has no app language preference). // self-contained i18n dict (the public viewer has no app language preference).
// Keep audio playing when the page is backgrounded (iOS Safari/Brave otherwise suspend a <video>
// the moment you leave the tab — the "background playback" a native app / YouTube Premium gets). Two
// pieces: (1) route the element's audio through a Web Audio graph — once that graph is live, iOS
// keeps the audio session running in the background while the video frame freezes; (2) Media Session
// metadata + handlers, which give lock-screen controls AND signal the OS that media is active. The
// file is same-origin (served by us), so createMediaElementSource isn't CORS-tainted. All of it is
// best-effort: any unsupported bit falls back to the plain <video>. Set up lazily on the first play
// (a user gesture — required to create/resume an AudioContext) and only once (a second
// createMediaElementSource on the same element throws).
function useBackgroundAudio(
videoRef: React.RefObject<HTMLVideoElement | null>,
meta: Meta | null,
ready: boolean,
) {
const ctxRef = useRef<AudioContext | null>(null);
const wiredRef = useRef(false);
useEffect(() => {
const video = videoRef.current;
if (!video || !ready) return;
// Only iOS suspends a backgrounded <video> — and only there does rerouting the audio through
// Web Audio buy anything. Every other platform backgrounds media audio natively, so we leave
// their <video> untouched rather than risk the reroute leaving foreground audio silent if the
// AudioContext can't resume. (iPadOS reports as MacIntel + touch.)
const isIOS =
/iP(hone|ad|od)/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (!isIOS) return;
const wire = () => {
if (wiredRef.current) {
ctxRef.current?.resume().catch(() => {});
return;
}
try {
const Ctx =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctx) return;
const ctx = new Ctx();
ctx.createMediaElementSource(video).connect(ctx.destination);
ctxRef.current = ctx;
wiredRef.current = true;
ctx.resume().catch(() => {});
} catch {
/* unsupported / already wired — plain <video> still plays, just not in the background */
}
};
video.addEventListener("play", wire);
return () => video.removeEventListener("play", wire);
}, [videoRef, ready]);
useEffect(() => {
if (!ready || !meta || !("mediaSession" in navigator)) return;
const video = videoRef.current;
try {
navigator.mediaSession.metadata = new MediaMetadata({
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
: [],
});
navigator.mediaSession.setActionHandler("play", () => void video?.play());
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
} catch {
/* MediaMetadata / handlers unsupported — ignore */
}
}, [ready, meta, videoRef]);
// Recover if the context gets suspended (e.g. iOS suspends it on background) once we're back.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible") ctxRef.current?.resume().catch(() => {});
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, []);
}
type Meta = { type Meta = {
needs_password: boolean; needs_password: boolean;
title?: string | null; title?: string | null;
@@ -80,6 +159,8 @@ export default function WatchPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [unlocking, setUnlocking] = useState(false); const [unlocking, setUnlocking] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
useBackgroundAudio(videoRef, meta, status === "ready");
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -176,6 +257,7 @@ export default function WatchPage() {
player instead of a wide box with black bars; a landscape clip fills the width. */} player instead of a wide box with black bars; a landscape clip fills the width. */}
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto"> <div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video <video
ref={videoRef}
controls controls
playsInline playsInline
src={meta.file_url} src={meta.file_url}
+6 -1
View File
@@ -10,6 +10,7 @@
"subscribers": "{{formatted}} subscribers", "subscribers": "{{formatted}} subscribers",
"videoCount_one": "{{count}} video", "videoCount_one": "{{count}} video",
"videoCount_other": "{{count}} videos", "videoCount_other": "{{count}} videos",
"videoCountStored": "{{stored}} / {{total}} videos",
"totalViews": "{{formatted}} views", "totalViews": "{{formatted}} views",
"joined": "Joined {{date}}", "joined": "Joined {{date}}",
"loadingVideos": "Loading this channel's videos…", "loadingVideos": "Loading this channel's videos…",
@@ -24,5 +25,9 @@
"language": "Language", "language": "Language",
"topics": "Topics", "topics": "Topics",
"keywords": "Keywords", "keywords": "Keywords",
"showInFeed": "Show in my feed" "showInFeed": "Show in my feed",
"fullHistory": "Full history",
"fullHistoryLoading": "Fetching history…",
"fullHistoryQueued": "Fetching this channel's full history — new videos will appear as they're found.",
"fullHistoryFailed": "Couldn't start the full-history fetch."
} }
@@ -90,6 +90,7 @@
"priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.", "priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.",
"raisePriority": "Raise priority", "raisePriority": "Raise priority",
"lowerPriority": "Lower priority", "lowerPriority": "Lower priority",
"editPriority": "Click to type a priority (negatives allowed)",
"recent": "recent", "recent": "recent",
"recentSyncedHint": "Recent uploads synced.", "recentSyncedHint": "Recent uploads synced.",
"recentNotSyncedHint": "Recent uploads not fetched yet.", "recentNotSyncedHint": "Recent uploads not fetched yet.",
@@ -20,6 +20,7 @@
"subtitle": "Download videos to the server, then save them to your device.", "subtitle": "Download videos to the server, then save them to your device.",
"addUrl": "Paste a YouTube link or video id…", "addUrl": "Paste a YouTube link or video id…",
"add": "Add", "add": "Add",
"paste": "Paste link from clipboard",
"manage": "Manage formats" "manage": "Manage formats"
}, },
"tabs": { "tabs": {
@@ -205,6 +206,49 @@
"empty_edit": "Choose a trim range or crop area first.", "empty_edit": "Choose a trim range or crop area first.",
"source_not_ready": "The source download isn't ready to edit.", "source_not_ready": "The source download isn't ready to edit.",
"generic": "That edit couldn't be created." "generic": "That edit couldn't be created."
},
"fail": {
"title": "Download failed",
"detailsLink": "details",
"detailsToggle": "Technical details",
"cat": {
"unavailable": {
"short": "Video unavailable",
"body": "This video is private, deleted, or otherwise no longer available at the source, so it can't be downloaded."
},
"login_required": {
"short": "Sign-in required",
"body": "This video needs a signed-in, age-verified, or members-only account to view, so it can't be downloaded here."
},
"geo_blocked": {
"short": "Region-blocked",
"body": "The source blocks this video in the server's region, so it can't be downloaded."
},
"format_unavailable": {
"short": "No downloadable format",
"body": "No downloadable video format was offered for this link — it may be images-only or an unsupported page."
},
"drm": {
"short": "DRM-protected",
"body": "This video is DRM-protected and cannot be downloaded."
},
"postprocess": {
"short": "Processing failed",
"body": "The video downloaded but a processing step (merge or convert) failed. A retry often fixes this."
},
"source_missing": {
"short": "Source unavailable",
"body": "The original download this clip is based on is no longer available."
},
"network": {
"short": "Network error",
"body": "A network error interrupted the download. A retry usually fixes this."
},
"unknown": {
"short": "Download failed",
"body": "Something went wrong while downloading this item. The technical details below may help."
}
}
} }
} }
} }
+6 -1
View File
@@ -10,6 +10,7 @@
"subscribers": "{{formatted}} feliratkozó", "subscribers": "{{formatted}} feliratkozó",
"videoCount_one": "{{count}} videó", "videoCount_one": "{{count}} videó",
"videoCount_other": "{{count}} videó", "videoCount_other": "{{count}} videó",
"videoCountStored": "{{stored}} / {{total}} videó",
"totalViews": "{{formatted}} megtekintés", "totalViews": "{{formatted}} megtekintés",
"joined": "Csatlakozott: {{date}}", "joined": "Csatlakozott: {{date}}",
"loadingVideos": "A csatorna videóinak betöltése…", "loadingVideos": "A csatorna videóinak betöltése…",
@@ -24,5 +25,9 @@
"language": "Nyelv", "language": "Nyelv",
"topics": "Témák", "topics": "Témák",
"keywords": "Kulcsszavak", "keywords": "Kulcsszavak",
"showInFeed": "Mutasd a hírfolyamomban" "showInFeed": "Mutasd a hírfolyamomban",
"fullHistory": "Teljes előzmény",
"fullHistoryLoading": "Előzmény letöltése…",
"fullHistoryQueued": "A csatorna teljes előzményének letöltése elindult — az új videók feltűnnek, ahogy előkerülnek.",
"fullHistoryFailed": "Nem sikerült elindítani a teljes előzmény letöltését."
} }
@@ -90,6 +90,7 @@
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.", "priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
"raisePriority": "Prioritás növelése", "raisePriority": "Prioritás növelése",
"lowerPriority": "Prioritás csökkentése", "lowerPriority": "Prioritás csökkentése",
"editPriority": "Kattints a prioritás beírásához (negatív is lehet)",
"recent": "legutóbbi", "recent": "legutóbbi",
"recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.", "recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.",
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.", "recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
@@ -20,6 +20,7 @@
"subtitle": "Töltsd le a videókat a szerverre, majd mentsd a saját gépedre.", "subtitle": "Töltsd le a videókat a szerverre, majd mentsd a saját gépedre.",
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…", "addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
"add": "Hozzáad", "add": "Hozzáad",
"paste": "Link beillesztése a vágólapról",
"manage": "Formátumok kezelése" "manage": "Formátumok kezelése"
}, },
"tabs": { "tabs": {
@@ -205,6 +206,49 @@
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet.", "empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet.",
"source_not_ready": "A forrásletöltés még nem szerkeszthető.", "source_not_ready": "A forrásletöltés még nem szerkeszthető.",
"generic": "Ezt a vágást nem sikerült létrehozni." "generic": "Ezt a vágást nem sikerült létrehozni."
},
"fail": {
"title": "A letöltés nem sikerült",
"detailsLink": "részletek",
"detailsToggle": "Technikai részletek",
"cat": {
"unavailable": {
"short": "A videó nem elérhető",
"body": "Ez a videó privát, törölt, vagy más okból már nem elérhető a forrásnál, ezért nem tölthető le."
},
"login_required": {
"short": "Bejelentkezés szükséges",
"body": "Ehhez a videóhoz bejelentkezett, kor-ellenőrzött vagy tagsági fiók kell, ezért itt nem tölthető le."
},
"geo_blocked": {
"short": "Régió szerint tiltva",
"body": "A forrás a szerver régiójában letiltja ezt a videót, ezért nem tölthető le."
},
"format_unavailable": {
"short": "Nincs letölthető formátum",
"body": "Ehhez a linkhez nem kínált fel letölthető videóformátumot — lehet csak képes, vagy nem támogatott oldal."
},
"drm": {
"short": "DRM-védett",
"body": "Ez a videó DRM-védett, ezért nem tölthető le."
},
"postprocess": {
"short": "A feldolgozás megszakadt",
"body": "A videó letöltődött, de egy feldolgozási lépés (összefűzés vagy konvertálás) meghiúsult. Újrapróbálásra gyakran megjavul."
},
"source_missing": {
"short": "A forrás nem elérhető",
"body": "Az eredeti letöltés, amelyből ez a vágás készült, már nem érhető el."
},
"network": {
"short": "Hálózati hiba",
"body": "Hálózati hiba szakította meg a letöltést. Újrapróbálásra általában megjavul."
},
"unknown": {
"short": "A letöltés nem sikerült",
"body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek."
}
}
} }
} }
} }
+2
View File
@@ -136,6 +136,8 @@ export interface ChannelDetail {
explored: boolean; explored: boolean;
blocked: boolean; blocked: boolean;
stored_videos: number; stored_videos: number;
deep_requested: boolean;
backfill_done: boolean;
from_explore: boolean; from_explore: boolean;
details_synced: boolean; details_synced: boolean;
} }
+2 -1
View File
@@ -69,7 +69,8 @@ export interface DownloadJob {
speed_bps: number | null; speed_bps: number | null;
eta_s: number | null; eta_s: number | null;
phase: string | null; phase: string | null;
error: string | null; error: string | null; // raw yt-dlp/ffmpeg text (technical detail)
error_code: string | null; // failure category, see backend downloads.errors.classify
queue_pos: number; queue_pos: number;
created_at: string | null; created_at: string | null;
source_kind: string; source_kind: string;
+16
View File
@@ -14,6 +14,22 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.57.0",
date: "2026-07-28",
summary:
"Downloads and channels polish — clearer download failures, a friendlier share flow, richer channel controls, and background audio for shared videos on iPhone/iPad.",
features: [
"Downloads: the page now opens on the Queue tab, the library shows when each item was downloaded, the Share dialog opens with a ready-to-copy link already created (all the other link options stay available), and you can paste a link from the clipboard with one tap.",
"Downloads: a failed item now explains WHY in a readable message — video unavailable, sign-in required, region-blocked, no downloadable format, and so on — with the raw technical detail one click away and a Retry offered when the failure looks temporary.",
"Channels: a channel's page now shows how many of its videos are stored versus its true total, with a “Full history” button to pull the rest. The priority arrows repeat (and speed up) while held instead of one click at a time, the value sits where the cursor can't cover it, and you can click the number to type any priority directly — negatives included.",
"Sharing: a shared video keeps playing audio in the background on iPhone/iPad (like YouTube) when you leave the browser.",
],
fixes: [
"Downloads: thumbnails that had gone missing (when the original video was deleted or its link expired) now fall back to the saved poster instead of showing a broken image.",
"Sharing: shared-link previews now reliably include the thumbnail when pasted into Facebook Messenger on the desktop web (dimensions are declared so its scraper shows the image on the first fetch).",
],
},
{ {
version: "0.56.0", version: "0.56.0",
date: "2026-07-28", date: "2026-07-28",