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:
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user