fix(downloads): iPad audio playback + honest non-retryable failure for auth-walled hosts

5a — the public watch page rendered every asset in a <video>, but WebKit (all
iOS browsers) refuses to play an audio-only mp3 in <video>, so shared audio
links were broken on iPad while working on desktop Chromium. public_meta now
flags audio-only assets (codec present, no video stream) and WatchPage renders
a native <audio> in a padded surface card (no artwork exists for these).

5c — a login-walled Facebook post failed as a bare 'unknown', which offered a
misleading Retry. Classify closed-group / cookie-suggestion auth walls as
login_required and genuinely unsupported sites as a new unsupported_host class
(both non-retryable), and drop unknown from the modal's retryable set — an
unclassified failure is most often an unparsed auth wall. The queue row keeps
its own force-retry for real transients.
This commit is contained in:
2026-08-28 02:22:28 +02:00
parent 11e32163b8
commit b7a6451dba
9 changed files with 158 additions and 21 deletions
+14
View File
@@ -24,6 +24,16 @@ _RULES: tuple[tuple[str, str], ...] = (
# Some extractors (e.g. Reddit) gate content behind a logged-in account: "Account
# authentication is required." — an auth wall we deliberately don't cross, not a transient error.
("authentication is required", "login_required"),
# Closed-group / private-post auth walls (Facebook groups, Instagram private, …). yt-dlp phrases
# these many ways; the common thread is "you must be signed in" or its own suggestion to supply
# cookies. Treating the cookie suggestion as an auth signal is what makes a login-walled link read
# honestly (and stay non-retryable) when the acting user has no usable cookie for that host.
("log in to", "login_required"),
("login required", "login_required"),
("you must be logged in", "login_required"),
("only available for registered users", "login_required"),
("use --cookies", "login_required"),
("--cookies-from-browser", "login_required"),
# Geo-restricted.
("in your country", "geo_blocked"),
("available in your location", "geo_blocked"),
@@ -42,6 +52,10 @@ _RULES: tuple[tuple[str, str], ...] = (
("account associated with this video has been terminated", "unavailable"),
("this video is not available", "unavailable"),
("this video has been removed", "unavailable"),
# yt-dlp doesn't support this site at all ("Unsupported URL: …") — distinct from an auth wall on a
# supported site. Non-retryable: no cookie or retry can make an unsupported host downloadable.
("unsupported url", "unsupported_host"),
("no video could be found in this url", "unsupported_host"),
# DRM.
("drm protected", "drm"),
("drm-protected", "drm"),
+5
View File
@@ -83,6 +83,10 @@ def public_meta(
from app.downloads import service
source_url = service.reference_url(job, asset)
# Audio-only assets (mp3 rips, extracted audio) carry a codec but no video stream. WebKit (all
# iOS browsers) refuses to play them inside a <video> element, so the watch page must render a
# native <audio> instead — flag it here from the recorded codecs (a real video always has vcodec).
is_audio = asset.vcodec is None and asset.acodec is not None
return {
"title": (job and job.display_name) or asset.title,
"uploader": (job and job.display_uploader) or asset.uploader,
@@ -92,6 +96,7 @@ def public_meta(
"duration_s": asset.duration_s,
"width": asset.width,
"height": asset.height,
"audio": is_audio,
"allow_download": link.allow_download,
"file_url": file_url,
"poster_url": poster_url,
+7
View File
@@ -14,6 +14,13 @@ def test_real_ytdlp_messages_map_to_categories():
"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: [Reddit] 1vi1x56: Account authentication is required.": "login_required",
# Closed-group / private-post auth walls: yt-dlp either says to sign in or suggests cookies.
"ERROR: [facebook] 123: You must be logged in to see this post": "login_required",
"ERROR: [facebook] 123: Login required. Use --cookies for the authentication.": "login_required",
"ERROR: [instagram] abc: This content is only available for registered users": "login_required",
"ERROR: [youtube] abc: Sign in to confirm... Use --cookies-from-browser or --cookies": "login_required",
# A site yt-dlp has no extractor for at all — distinct from an auth wall, still non-retryable.
"ERROR: Unsupported URL: https://example.com/whatever": "unsupported_host",
"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",
+46 -1
View File
@@ -4,7 +4,14 @@ token, rides in the file URL (a <video src> can't send a header), and expires.""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from app.downloads.links import _GRANT_TTL, check_grant, is_expired, make_grant, new_token
from app.downloads.links import (
_GRANT_TTL,
check_grant,
is_expired,
make_grant,
new_token,
public_meta,
)
def test_new_token_is_unguessable_and_unique():
@@ -48,6 +55,44 @@ class TestGrant:
assert check_grant("tok", bad, 0) is False
def _asset(**over):
base = dict(
title="Clip",
uploader="Someone",
uploader_url=None,
duration_s=42,
width=1280,
height=720,
vcodec="h264",
acodec="aac",
source_webpage_url=None,
source_kind="url",
)
base.update(over)
return SimpleNamespace(**base)
class TestPublicMetaAudioFlag:
"""The watch page picks <audio> vs <video> from this flag; WebKit refuses an audio-only mp3 in a
<video>, so an audio-only asset (codec present, no video stream) must report audio=True."""
_link = SimpleNamespace(allow_download=True)
def test_audio_only_asset_is_flagged_audio(self):
asset = _asset(vcodec=None, acodec="mp3", width=None, height=None)
meta = public_meta(self._link, None, asset, "/f.mp3")
assert meta["audio"] is True
def test_a_video_asset_is_not_audio(self):
meta = public_meta(self._link, None, _asset(), "/f.mp4")
assert meta["audio"] is False
def test_a_silent_video_is_not_audio(self):
# Video present but no audio track — still a video player, not the audio bar.
meta = public_meta(self._link, None, _asset(acodec=None), "/f.mp4")
assert meta["audio"] is False
class TestIsExpired:
def test_none_expiry_never_expires(self):
assert is_expired(SimpleNamespace(expires_at=None)) is False
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
//
// S3 item 5a: the public watch page must render a native <audio> element for an audio-only asset
// (mp3) and a <video> for everything else. WebKit (all iOS browsers) refuses to play an audio-only
// mp3 inside a <video>, which is exactly why the branch exists — this locks it so a refactor can't
// silently drop the audio player and reintroduce the iPad-broken behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, waitFor, cleanup } from "@testing-library/react";
import WatchPage from "./WatchPage";
function mockMeta(meta: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => meta })),
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("WatchPage media element (S3 · 5a)", () => {
it("renders a native <audio> for an audio-only asset", async () => {
window.history.replaceState({}, "", "/watch/tok-audio");
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
const { container } = render(<WatchPage />);
await waitFor(() => expect(container.querySelector("audio")).not.toBeNull());
expect(container.querySelector("video")).toBeNull();
});
it("renders a <video> for a normal video asset", async () => {
window.history.replaceState({}, "", "/watch/tok-video");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const { container } = render(<WatchPage />);
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
expect(container.querySelector("audio")).toBeNull();
});
});
+33 -17
View File
@@ -11,10 +11,11 @@ import { Download, Link2, Lock, PlayCircle } from "lucide-react";
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video> +
// MediaSession leaves the browser's native background handling in charge.
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
// so it drives either the video or the audio-only player.
function useMediaSession(
videoRef: React.RefObject<HTMLVideoElement | null>,
videoRef: React.RefObject<HTMLMediaElement | null>,
meta: Meta | null,
ready: boolean,
) {
@@ -45,6 +46,7 @@ type Meta = {
source_url?: string | null;
extra_links?: string[];
duration_s?: number | null;
audio?: boolean;
allow_download?: boolean;
file_url?: string;
poster_url?: string | null;
@@ -112,8 +114,13 @@ 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);
useMediaSession(videoRef, meta, status === "ready");
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
// both without a per-element typed ref.
const mediaRef = useRef<HTMLMediaElement | null>(null);
const setMedia = (el: HTMLMediaElement | null) => {
mediaRef.current = el;
};
useMediaSession(mediaRef, meta, status === "ready");
useEffect(() => {
if (!token) {
@@ -206,18 +213,27 @@ export default function WatchPage() {
{status === "ready" && meta && (
<div>
{/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
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}
poster={meta.poster_url || undefined}
className="max-h-[80vh] max-w-full mx-auto block"
/>
</div>
{meta.audio ? (
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
<audio>. No poster exists for these, so present it as a deliberate padded bar on
the surface (not the black video box) so it looks intentional without artwork. */
<div className="rounded-xl border border-border bg-surface p-4">
<audio ref={setMedia} controls src={meta.file_url} className="w-full block" />
</div>
) : (
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
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={setMedia}
controls
playsInline
src={meta.file_url}
poster={meta.poster_url || undefined}
className="max-h-[80vh] max-w-full mx-auto block"
/>
</div>
)}
<div className="mt-3 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
+6 -3
View File
@@ -114,9 +114,12 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
);
}
// 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"];
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
// sign-in/unsupported-host/…), where a retry can't help — the queue row still keeps its own retry
// control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
const RETRYABLE_ERRORS = ["postprocess", "network"];
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
@@ -220,6 +220,10 @@
"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."
},
"unsupported_host": {
"short": "Site not supported",
"body": "This site isn't supported for downloading, so nothing could be fetched from this link."
},
"geo_blocked": {
"short": "Region-blocked",
"body": "The source blocks this video in the server's region, so it can't be downloaded."
@@ -220,6 +220,10 @@
"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."
},
"unsupported_host": {
"short": "Nem támogatott oldal",
"body": "Ez az oldal nem támogatott a letöltéshez, ezért erről a linkről semmit sem sikerült lekérni."
},
"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."