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 # 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." — an auth wall we deliberately don't cross, not a transient error.
("authentication is required", "login_required"), ("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. # Geo-restricted.
("in your country", "geo_blocked"), ("in your country", "geo_blocked"),
("available in your location", "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"), ("account associated with this video has been terminated", "unavailable"),
("this video is not available", "unavailable"), ("this video is not available", "unavailable"),
("this video has been removed", "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.
("drm protected", "drm"), ("drm protected", "drm"),
("drm-protected", "drm"), ("drm-protected", "drm"),
+5
View File
@@ -83,6 +83,10 @@ def public_meta(
from app.downloads import service from app.downloads import service
source_url = service.reference_url(job, asset) 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 { return {
"title": (job and job.display_name) or asset.title, "title": (job and job.display_name) or asset.title,
"uploader": (job and job.display_uploader) or asset.uploader, "uploader": (job and job.display_uploader) or asset.uploader,
@@ -92,6 +96,7 @@ def public_meta(
"duration_s": asset.duration_s, "duration_s": asset.duration_s,
"width": asset.width, "width": asset.width,
"height": asset.height, "height": asset.height,
"audio": is_audio,
"allow_download": link.allow_download, "allow_download": link.allow_download,
"file_url": file_url, "file_url": file_url,
"poster_url": poster_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: 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: Join this channel to get access to members-only content": "login_required",
"ERROR: [Reddit] 1vi1x56: Account authentication is required.": "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: 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: Requested format is not available": "format_unavailable",
"ERROR: [youtube] abc: Only images are available for download": "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 datetime import datetime, timedelta, timezone
from types import SimpleNamespace 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(): def test_new_token_is_unguessable_and_unique():
@@ -48,6 +55,44 @@ class TestGrant:
assert check_grant("tok", bad, 0) is False 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: class TestIsExpired:
def test_none_expiry_never_expires(self): def test_none_expiry_never_expires(self):
assert is_expired(SimpleNamespace(expires_at=None)) is False 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();
});
});
+24 -8
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 // 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 // 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 — // 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> + // 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. // + 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( function useMediaSession(
videoRef: React.RefObject<HTMLVideoElement | null>, videoRef: React.RefObject<HTMLMediaElement | null>,
meta: Meta | null, meta: Meta | null,
ready: boolean, ready: boolean,
) { ) {
@@ -45,6 +46,7 @@ type Meta = {
source_url?: string | null; source_url?: string | null;
extra_links?: string[]; extra_links?: string[];
duration_s?: number | null; duration_s?: number | null;
audio?: boolean;
allow_download?: boolean; allow_download?: boolean;
file_url?: string; file_url?: string;
poster_url?: string | null; poster_url?: string | null;
@@ -112,8 +114,13 @@ 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); // One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
useMediaSession(videoRef, meta, status === "ready"); // 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(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -206,11 +213,19 @@ export default function WatchPage() {
{status === "ready" && meta && ( {status === "ready" && meta && (
<div> <div>
{/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow {meta.audio ? (
player instead of a wide box with black bars; a landscape clip fills the width. */} /* 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"> <div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video <video
ref={videoRef} ref={setMedia}
controls controls
playsInline playsInline
src={meta.file_url} src={meta.file_url}
@@ -218,6 +233,7 @@ export default function WatchPage() {
className="max-h-[80vh] max-w-full mx-auto block" className="max-h-[80vh] max-w-full mx-auto block"
/> />
</div> </div>
)}
<div className="mt-3 flex items-start gap-3"> <div className="mt-3 flex items-start gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1> <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/…), // 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. // sign-in/unsupported-host/…), where a retry can't help — the queue row still keeps its own retry
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"]; // 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, // 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. // 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", "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." "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": { "geo_blocked": {
"short": "Region-blocked", "short": "Region-blocked",
"body": "The source blocks this video in the server's region, so it can't be downloaded." "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", "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." "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": { "geo_blocked": {
"short": "Régió szerint tiltva", "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." "body": "A forrás a szerver régiójában letiltja ezt a videót, ezért nem tölthető le."