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:
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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"<!--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)}" />'
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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({
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</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 && (
|
||||
<button
|
||||
onClick={() => block.mutate()}
|
||||
|
||||
@@ -149,15 +149,53 @@ export default function Channels() {
|
||||
// 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
|
||||
// lose your scroll position). The new order takes effect next time the page loads.
|
||||
const bumpPriority = (id: string, current: number, delta: number) => {
|
||||
const next = current + delta;
|
||||
// Press-and-hold fires many bumps, so the PATCH is DEBOUNCED per channel: each bump reads the
|
||||
// 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) =>
|
||||
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
|
||||
);
|
||||
api
|
||||
.updateChannel(id, { priority: next })
|
||||
.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
|
||||
const timers = prioFlush.current;
|
||||
const pending = timers.get(id);
|
||||
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
|
||||
// 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,
|
||||
sortValue: (c) => c.priority,
|
||||
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",
|
||||
@@ -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({
|
||||
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<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 (
|
||||
<Tooltip hint={t("channels.row.priorityHint")}>
|
||||
<div className="inline-flex flex-col items-center cursor-help">
|
||||
<button
|
||||
onClick={() => onPriority(1)}
|
||||
className="text-muted hover:text-accent"
|
||||
aria-label={t("channels.row.raisePriority")}
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
|
||||
<button
|
||||
onClick={() => onPriority(-1)}
|
||||
className="text-muted hover:text-accent"
|
||||
aria-label={t("channels.row.lowerPriority")}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="inline-flex items-center gap-1.5 cursor-help">
|
||||
{/* 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. */}
|
||||
<span className="relative inline-block w-6 h-5 text-right leading-5">
|
||||
<button
|
||||
onClick={beginEdit}
|
||||
aria-label={t("channels.row.editPriority")}
|
||||
title={t("channels.row.editPriority")}
|
||||
className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||||
>
|
||||
{c.priority}
|
||||
</button>
|
||||
{editing && (
|
||||
<input
|
||||
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>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -194,7 +210,15 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
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 running = job.status === "running";
|
||||
return (
|
||||
@@ -218,7 +242,24 @@ function DownloadRow({ job, children }: { job: DownloadJob; children?: React.Rea
|
||||
{job.asset?.duration_s ? (
|
||||
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
|
||||
) : 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>
|
||||
{job.source_url && <SourceRef url={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 (
|
||||
<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 ------------------------------------------------------------------------------
|
||||
|
||||
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<string | null>(null);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||
const [shareJob, setShareJob] = 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 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() {
|
||||
<input
|
||||
value={addUrl}
|
||||
onChange={(e) => setAddUrl(e.target.value)}
|
||||
onFocus={() => pasteFromClipboard()}
|
||||
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
|
||||
placeholder={t("downloads.page.addUrl")}
|
||||
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
|
||||
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"
|
||||
@@ -769,7 +901,7 @@ export default function DownloadCenter() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{queue.map((job) => (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
<DownloadRow key={job.id} job={job} onShowError={setErrorJob}>
|
||||
{(job.status === "queued" || job.status === "running") && (
|
||||
<IconBtn
|
||||
onClick={() => act.mutate({ id: job.id, op: "pause" })}
|
||||
@@ -934,6 +1066,13 @@ export default function DownloadCenter() {
|
||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||
{errorJob && (
|
||||
<ErrorModal
|
||||
job={errorJob}
|
||||
onRetry={() => act.mutate({ id: errorJob.id, op: "resume" })}
|
||||
onClose={() => setErrorJob(null)}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 (
|
||||
<div>
|
||||
<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) => (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Link2, Lock, PlayCircle } from "lucide-react";
|
||||
|
||||
// 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
|
||||
// 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 = {
|
||||
needs_password: boolean;
|
||||
title?: string | null;
|
||||
@@ -80,6 +159,8 @@ export default function WatchPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
useBackgroundAudio(videoRef, meta, status === "ready");
|
||||
|
||||
useEffect(() => {
|
||||
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. */}
|
||||
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
playsInline
|
||||
src={meta.file_url}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"subscribers": "{{formatted}} subscribers",
|
||||
"videoCount_one": "{{count}} video",
|
||||
"videoCount_other": "{{count}} videos",
|
||||
"videoCountStored": "{{stored}} / {{total}} videos",
|
||||
"totalViews": "{{formatted}} views",
|
||||
"joined": "Joined {{date}}",
|
||||
"loadingVideos": "Loading this channel's videos…",
|
||||
@@ -24,5 +25,9 @@
|
||||
"language": "Language",
|
||||
"topics": "Topics",
|
||||
"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.",
|
||||
"raisePriority": "Raise priority",
|
||||
"lowerPriority": "Lower priority",
|
||||
"editPriority": "Click to type a priority (negatives allowed)",
|
||||
"recent": "recent",
|
||||
"recentSyncedHint": "Recent uploads synced.",
|
||||
"recentNotSyncedHint": "Recent uploads not fetched yet.",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"subtitle": "Download videos to the server, then save them to your device.",
|
||||
"addUrl": "Paste a YouTube link or video id…",
|
||||
"add": "Add",
|
||||
"paste": "Paste link from clipboard",
|
||||
"manage": "Manage formats"
|
||||
},
|
||||
"tabs": {
|
||||
@@ -205,6 +206,49 @@
|
||||
"empty_edit": "Choose a trim range or crop area first.",
|
||||
"source_not_ready": "The source download isn't ready to edit.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"subscribers": "{{formatted}} feliratkozó",
|
||||
"videoCount_one": "{{count}} videó",
|
||||
"videoCount_other": "{{count}} videó",
|
||||
"videoCountStored": "{{stored}} / {{total}} videó",
|
||||
"totalViews": "{{formatted}} megtekintés",
|
||||
"joined": "Csatlakozott: {{date}}",
|
||||
"loadingVideos": "A csatorna videóinak betöltése…",
|
||||
@@ -24,5 +25,9 @@
|
||||
"language": "Nyelv",
|
||||
"topics": "Témák",
|
||||
"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.",
|
||||
"raisePriority": "Prioritás növelése",
|
||||
"lowerPriority": "Prioritás csökkentése",
|
||||
"editPriority": "Kattints a prioritás beírásához (negatív is lehet)",
|
||||
"recent": "legutóbbi",
|
||||
"recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.",
|
||||
"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.",
|
||||
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
||||
"add": "Hozzáad",
|
||||
"paste": "Link beillesztése a vágólapról",
|
||||
"manage": "Formátumok kezelése"
|
||||
},
|
||||
"tabs": {
|
||||
@@ -205,6 +206,49 @@
|
||||
"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ő.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,8 @@ export interface ChannelDetail {
|
||||
explored: boolean;
|
||||
blocked: boolean;
|
||||
stored_videos: number;
|
||||
deep_requested: boolean;
|
||||
backfill_done: boolean;
|
||||
from_explore: boolean;
|
||||
details_synced: boolean;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,8 @@ export interface DownloadJob {
|
||||
speed_bps: number | null;
|
||||
eta_s: number | 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;
|
||||
created_at: string | null;
|
||||
source_kind: string;
|
||||
|
||||
@@ -14,6 +14,22 @@ export interface 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",
|
||||
date: "2026-07-28",
|
||||
|
||||
Reference in New Issue
Block a user