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