Merge: promote dev to prod

This commit is contained in:
2026-07-22 02:53:15 +02:00
18 changed files with 552 additions and 42 deletions
+1 -1
View File
@@ -1 +1 @@
0.50.0
0.51.0
@@ -0,0 +1,71 @@
"""reset builtin download profiles: 720p is now the default (first) preset
Revision ID: 0058_dl_default_720p
Revises: 0057_plex_ppl_enriched
Create Date: 2026-07-22
The download dialog preselects the first builtin (lowest sort_order). Make 720p that default —
it plays everywhere, downloads faster, and is plenty for the shared /watch player — with 1080p and
Best still one click away. Re-seeds the builtin rows so fresh installs and existing DBs converge
(same mechanism as 0040). Existing jobs are unaffected (they snapshot their spec; a profile_id that
pointed at a replaced row just SET NULLs).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0058_dl_default_720p"
down_revision: Union[str, None] = "0057_plex_ppl_enriched"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _spec(mode, height=None, container=None, audio_format=None):
return {
"mode": mode,
"max_height": height,
"container": container,
"audio_format": audio_format,
"vcodec": None,
"embed_subs": False,
"embed_chapters": False,
"embed_thumbnail": False,
"sponsorblock": False,
}
# 720p first (the default preselection), then 1080p, Best, then the rest.
_BUILTINS = [
("720p (MP4)", _spec("av", 720, "mp4"), 10),
("1080p (MP4)", _spec("av", 1080, "mp4"), 20),
("Best (MP4)", _spec("av", None, "mp4"), 30),
("Audio (M4A)", _spec("a", None, None, "m4a"), 40),
("Audio (MP3)", _spec("a", None, None, "mp3"), 50),
("Video only (MP4)", _spec("v", None, "mp4"), 60),
]
_profiles = sa.table(
"download_profiles",
sa.column("user_id", sa.Integer),
sa.column("name", sa.String),
sa.column("spec", sa.JSON),
sa.column("is_builtin", sa.Boolean),
sa.column("sort_order", sa.Integer),
)
def upgrade() -> None:
op.execute("DELETE FROM download_profiles WHERE is_builtin = true")
op.bulk_insert(
_profiles,
[
{"user_id": None, "name": n, "spec": s, "is_builtin": True, "sort_order": o}
for n, s, o in _BUILTINS
],
)
def downgrade() -> None:
# No-op: the previous builtin set (1080p first) was seeded by 0040; not worth reconstructing.
pass
+72
View File
@@ -268,6 +268,78 @@ def run_ffmpeg(cmd, total_s, on_progress, should_cancel) -> None:
raise RuntimeError(f"ffmpeg failed ({ret}): {err[:300]}")
# --- ffmpeg: universal browser playability --------------------------------------------------
# A downloaded file must play in EVERY browser, whatever the source shipped. Universal = H.264
# video + AAC/MP3 audio in an mp4 container: H.264 is the only video codec all browsers decode
# (VP9/AV1 fail on Safari/iOS; HEVC fails on Chrome/Firefox), and mp4/AAC is the safe pairing.
# Anything else is fixed post-download: re-encode the offending stream(s), or just remux when the
# codecs are already fine but the container isn't.
_BROWSER_SAFE_VCODEC = ("h264",)
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
def probe_codecs(path: Path) -> tuple[str | None, str | None]:
"""Return (video_codec, audio_codec) lowercased via one ffprobe call; None for a missing
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "stream=codec_type,codec_name",
"-of", "default=nw=1", str(path)],
capture_output=True, text=True, timeout=30, check=True,
).stdout.lower()
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
return None, None
vcodec = acodec = None
ctype = None
for line in out.splitlines():
if line.startswith("codec_type="):
ctype = line.split("=", 1)[1].strip()
elif line.startswith("codec_name="):
name = line.split("=", 1)[1].strip() or None
if ctype == "video" and vcodec is None:
vcodec = name
elif ctype == "audio" and acodec is None:
acodec = name
return vcodec, acodec
def probe_duration(path: Path) -> int:
"""Media duration in whole seconds via ffprobe, or 0 if unknown — a progress denominator for a
re-encode when the yt-dlp info dict carried no `duration` (some URL/live extractions)."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", str(path)],
capture_output=True, text=True, timeout=30, check=True,
).stdout.strip()
return int(float(out)) if out else 0
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
return 0
def browser_transcode_flags(vcodec: str | None, acodec: str | None, is_mp4: bool) -> tuple[bool, bool, bool]:
"""Decide what (if anything) makes a file universally browser-playable. Returns
(needs_change, reencode_video, reencode_audio). needs_change=False => already H.264 + AAC/MP3
in mp4. A None codec (no such stream, or a failed probe) is never re-encoded."""
rv = bool(vcodec) and vcodec not in _BROWSER_SAFE_VCODEC
ra = bool(acodec) and acodec not in _BROWSER_SAFE_ACODEC
return (rv or ra or not is_mp4), rv, ra
def build_browser_safe_cmd(src: Path, dest: Path, reencode_video: bool, reencode_audio: bool) -> list[str]:
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
pipe:1` streams machine-readable progress for run_ffmpeg."""
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
"-progress", "pipe:1", "-nostats", "-i", str(src),
]
cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20"] if reencode_video else ["-c:v", "copy"]
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
cmd += ["-movflags", "+faststart", str(dest)]
return cmd
# --- filmstrip (editor scrub track) ---------------------------------------------------------
_SB_COLS = 12
+43 -14
View File
@@ -47,7 +47,9 @@ _DEFAULTS = {
# Bump when the spec→output-bytes mapping changes so an identical spec gets a fresh cache
# identity instead of hitting a stale asset produced by the old rule. v2: mp4 downloads now
# prefer H.264+AAC (browser/iOS-compatible) rather than yt-dlp's default VP9/Opus best.
_SIG_VERSION = 2
# v3: mp4 compat now *selects* H.264 (avc1) with a progressive fallback + re-encodes the rare
# AV1/VP9-only result to H.264, so cached AV1 assets must re-derive (fixes iOS broken-player).
_SIG_VERSION = 3
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
@@ -76,11 +78,16 @@ def normalize(spec: dict) -> dict:
return s
def format_sig(spec: dict) -> str:
"""Stable short hash of the output-affecting spec — the MediaAsset cache key component."""
def format_sig(spec: dict, salt: str | None = None) -> str:
"""Stable short hash of the output-affecting spec — the MediaAsset cache key component.
`salt` forks a distinct, non-dedup identity for the same spec (used by force-redownload so it
always re-fetches into its own asset instead of sharing a cached file with other jobs)."""
s = normalize(spec)
data = {k: s[k] for k in _SIG_KEYS}
data["_v"] = _SIG_VERSION
if salt is not None:
data["_salt"] = salt
payload = json.dumps(data, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode()).hexdigest()[:24]
@@ -142,22 +149,44 @@ def build_ydl_opts(
)
else:
hcap = f"[height<=?{h}]" if h else ""
# Compatibility-first for mp4 (the universal container): produce H.264 video + AAC audio
# so shared/downloaded files play in EVERY browser, including iOS/Safari. WebKit (which
# every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there, even though Chromium
# plays it fine. A custom profile that explicitly sets `vcodec` opts out.
compat = s["container"] == "mp4" and s["vcodec"] is None
if s["mode"] == "v":
opts["format"] = f"bestvideo{hcap}/best{hcap}"
if compat:
# Video-only: keep it audio-free. Prefer an H.264 *video-only* stream, then any
# video-only, and only fall to a progressive (audio-bearing) `best` as a last
# resort — same audio-free-when-possible guarantee as the old `bestvideo/best`.
opts["format"] = (
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}"
f"/best[vcodec^=avc1]{hcap}/best{hcap}"
)
else:
opts["format"] = f"bestvideo{hcap}/best{hcap}"
else: # av
opts["format"] = f"bestvideo{hcap}+bestaudio/best{hcap}"
if compat:
# Prefer H.264 at the SELECTION level, not just via format_sort: a sort key is only
# a *preference* and can't conjure an H.264 stream a source doesn't expose as
# video-only. A non-YouTube source (e.g. Facebook) may ship AV1 as the best
# video-only stream and H.264 only as a *progressive* (a+v) format — which
# `bestvideo` skips — so `bestvideo+bestaudio` picked AV1 and iOS broke. This chain
# tries avc1 video-only, then the avc1 progressive, then anything.
opts["format"] = (
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
f"/best[vcodec^=avc1]{hcap}"
f"/bestvideo{hcap}+bestaudio/best{hcap}"
)
else:
opts["format"] = f"bestvideo{hcap}+bestaudio/best{hcap}"
if s["container"]:
opts["merge_output_format"] = s["container"]
sort = []
# Compatibility-first for mp4 (the universal container): prefer H.264 video + AAC
# audio so shared/downloaded files play in EVERY browser, including iOS/Safari.
# WebKit (which every iOS browser, Brave included, is forced to use) decodes only
# H.264/H.265 + AAC inside mp4 — a VP9/Opus-in-mp4 file shows a broken player there,
# even though Chromium plays it fine. These sort keys rank codec ABOVE resolution, so
# a 720p H.264 stream is chosen over a 1080p-VP9-only one; VP9/AV1 remain a graceful
# fallback only when no H.264 stream exists. A custom profile that explicitly sets
# `vcodec` opts out (its codec choice wins over the compatibility default).
if s["container"] == "mp4" and s["vcodec"] is None:
# Keep the codec preference as a tiebreaker (ranks H.264/AAC above resolution among the
# selected candidates); the selector above is the hard guarantee, this is belt-and-braces.
if compat:
sort += ["vcodec:h264", "acodec:aac"]
if h:
sort.append(f"res:{h}")
+3 -3
View File
@@ -106,7 +106,7 @@ def populate_from_catalog(db: Session, asset: MediaAsset) -> bool:
return True
def _next_queue_pos(db: Session) -> int:
def next_queue_pos(db: Session) -> int:
return (db.execute(select(func.coalesce(func.max(DownloadJob.queue_pos), 0))).scalar() or 0) + 1
@@ -138,7 +138,7 @@ def enqueue(
profile_snapshot=spec,
display_name=display_name or (asset.title[:255] if asset.status == "ready" and asset.title else None),
status="queued",
queue_pos=_next_queue_pos(db),
queue_pos=next_queue_pos(db),
)
db.add(job)
asset.ref_count = (asset.ref_count or 0) + 1
@@ -207,7 +207,7 @@ def enqueue_edit(
profile_snapshot={},
display_name=display_name or asset.title,
status="queued",
queue_pos=_next_queue_pos(db),
queue_pos=next_queue_pos(db),
)
db.add(job)
asset.ref_count = (asset.ref_count or 0) + 1
+40 -11
View File
@@ -20,13 +20,35 @@ from xml.sax.saxutils import escape
# Reserve room for the " [id].ext" suffix + parent dirs within the 255-byte component limit.
_MAX_TITLE = 120
# User-facing (Content-Disposition) names can be longer than an on-disk component.
_MAX_DISPLAY = 150
# Filesystem-illegal on Windows/Plex + control chars.
_ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
# Unicode categories we drop entirely: emoji/pictographs (So), modifier symbols/skin-tones
# (Sk), and every control/format/private/surrogate/unassigned char (C*). Letters (incl.
# accented á/ö/ü/ß), digits, and ordinary punctuation are KEPT — Siftlode is trilingual, so
# ASCII-folding would mangle Hungarian/German titles.
# (Sk), and every control/format/private/surrogate/unassigned char (C*).
_DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
# Latin letters NFKD can't decompose to an ASCII base — fold them explicitly (trilingual HU/DE
# plus common European). Everything else with a diacritic (á/é/ö/ő/ü/ű, ç, ñ…) NFKD-decomposes
# into base + a combining mark we then drop, and any remaining non-ASCII is stripped, so every
# generated name is pure ASCII: no spaces, no accents — portable and free of the `?` mojibake
# that accented names show in non-UTF-8 tools.
_FOLD_MAP = str.maketrans(
{
"ß": "ss", "": "SS", "ø": "o", "Ø": "O", "ł": "l", "Ł": "L", "đ": "d", "Đ": "D",
"ð": "d", "Ð": "D", "þ": "th", "Þ": "Th", "æ": "ae", "Æ": "AE", "œ": "oe", "Œ": "OE",
"ı": "i", "İ": "I", "·": " ", "": "-", "": "-",
}
)
def _ascii_fold(text: str) -> str:
"""Transliterate to pure ASCII: apply the explicit map, NFKD-split accented letters and drop
the combining marks (á→a, ő→o), then strip any leftover non-ASCII (non-Latin scripts that have
no ASCII base)."""
text = text.translate(_FOLD_MAP)
text = unicodedata.normalize("NFKD", text)
text = "".join(ch for ch in text if unicodedata.category(ch) != "Mn")
return text.encode("ascii", "ignore").decode("ascii")
@dataclass
@@ -41,16 +63,17 @@ class MediaMeta:
def sanitize(name: str, limit: int = _MAX_TITLE) -> str:
"""Turn an arbitrary (emoji-laden, clickbait) title into a clean, space-free path token.
"""Turn an arbitrary (emoji-laden, clickbait) title into a clean, space-free ASCII path token.
Steps: NFKC-normalize (fold fancy width/compat forms) → drop emoji/symbols/control chars →
strip filesystem-illegal chars → collapse whitespace and runs of the same punctuation →
join words with underscores → trim junk punctuation from the ends → length-cap. Accented
letters and digits survive; the result never contains spaces or path separators."""
ASCII-fold accents (á→a, ő→o, ß→ss) → strip filesystem-illegal chars → collapse whitespace and
runs of the same punctuation → join words with underscores → trim junk punctuation from the ends
→ length-cap. The result is pure ASCII with no spaces or path separators (digits survive)."""
text = unicodedata.normalize("NFKC", name or "")
text = "".join(
ch for ch in text if unicodedata.category(ch) not in _DROP_CATEGORIES
)
text = _ascii_fold(text)
text = _ILLEGAL.sub("", text)
# Collapse repeated punctuation ("!!!", "???", "-----") to a single mark.
text = re.sub(r"([^\w\s])\1{1,}", r"\1", text)
@@ -68,13 +91,19 @@ def sanitize(name: str, limit: int = _MAX_TITLE) -> str:
def display_filename(name: str) -> str:
"""User-facing download filename (Content-Disposition): strip emoji/symbols/control +
filesystem-illegal chars, but KEEP spaces, accents, and ordinary punctuation — unlike the
underscore-joined on-disk name, this is the readable name the user saves to their device."""
"""User-facing download filename (Content-Disposition) base: the same ASCII-slug policy as the
on-disk name — emoji/symbols/control + illegal chars dropped, accents folded, whitespace joined
with underscores — so a saved file is portable (no spaces, no accents). Only the length cap
differs (a served name may be longer than an on-disk path component)."""
text = unicodedata.normalize("NFKC", name or "")
text = "".join(ch for ch in text if unicodedata.category(ch) not in _DROP_CATEGORIES)
text = _ascii_fold(text)
text = _ILLEGAL.sub("", text)
text = re.sub(r"\s+", " ", text).strip(" .")
text = re.sub(r"([^\w\s])\1{1,}", r"\1", text)
text = re.sub(r"\s+", "_", text.strip())
text = re.sub(r"_{2,}", "_", text).strip("_.-·—–|,;:!?ّ ")
if len(text) > _MAX_DISPLAY:
text = text[:_MAX_DISPLAY].rstrip("_.-")
return text or "download"
+47 -1
View File
@@ -20,7 +20,7 @@ from app.config import settings
from app.db import get_db
from app.downloads import edit as editmod
from app.downloads import links as linksmod
from app.downloads import quota, service, storage
from app.downloads import formats, quota, service, storage
from app.models import (
DownloadJob,
DownloadLink,
@@ -448,6 +448,52 @@ def resume_download(
return _serialize_job(db, job)
@router.post("/{job_id}/redownload")
def redownload_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""Force a fresh re-download of a finished (or failed) job, rebuilding the file + Plex tree.
The SAME job row is kept, so any public share link (DownloadLink → job_id) keeps working and
resolves to the newly-downloaded file — a re-download never breaks a link you already sent out
(only a *failed* re-download leaves the job in `error`, which is the intended broken-link case).
The rebuild always re-fetches into a FRESH, private asset (the cache sig is salted with the job
id + current asset id) rather than the dedup pool. That means it (a) always genuinely rebuilds
under the current format/naming rules — never silently reuses a shared cached file — and (b)
never deletes a file another job/user still shares: the old asset is only removed if THIS job
was its last holder."""
job = _own_job(db, user, job_id)
if job.job_kind == "edit":
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
if job.status in ("queued", "running", "paused"):
raise HTTPException(status_code=409, detail="This download is still in progress.")
# Resolve the fresh private asset FIRST (before touching any file). Salting with the current
# asset id makes it distinct from both the dedup pool and this job's current asset, so the
# insert never collides — no IntegrityError/rollback can strand us after a file was deleted.
spec = formats.normalize(job.profile_snapshot)
sig = formats.format_sig(spec, salt=f"{job.id}:{job.asset_id}")
asset = service.get_or_create_asset(db, job.source_kind, job.source_ref, sig)
service.populate_from_catalog(db, asset)
# Now drop the old hold — `_release_asset` deletes the old file + row only if this job was its
# last holder (a co-held asset is left intact for its other holders).
_release_asset(db, job)
asset.ref_count = (asset.ref_count or 0) + 1
job.asset_id = asset.id
job.status = "queued"
job.progress = 0
job.phase = None
job.error = None
job.speed_bps = None
job.eta_s = None
job.queue_pos = service.next_queue_pos(db) # tail of the queue, not its stale original slot
db.commit()
return _serialize_job(db, job)
@router.post("/{job_id}/cancel")
def cancel_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
+21 -2
View File
@@ -5,6 +5,8 @@ yt-dlp, so the feed, search, channel pages, and the download center all show tid
raw title is preserved in `Video.original_title` so this is reversible / re-derivable.
Rules (user-approved "option b"):
0. Strip a leading social engagement prefix ("1.6M views · 66K reactions | …") some sources
(Facebook) prepend, up to and including the pipe.
1. Drop emoji / pictographs / symbols / control chars (keep accents — HU/DE/…).
2. Strip trailing SEO hashtag clusters (word hashtags at the end); a numeric "#3" episode
marker survives.
@@ -21,6 +23,16 @@ import re
import unicodedata
_DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
# Social engagement prefix some sources (Facebook) prepend, e.g. "1.6M views · 66K reactions | ".
# Match one-or-more "<count> <label>" tokens (· / , separated) terminated by a pipe, then drop it.
# Deliberately does NOT consume the space after `|`: leaving it lets a hashtag-only remainder
# ("… | #foo #bar") keep the leading whitespace `_TRAILING_HASHTAGS` needs to strip it; the normal
# case's leading space is removed later by the whitespace collapse.
_ENGAGEMENT_PREFIX = re.compile(
r"^\s*(?:\d[\d.,]*\s*[KMB]?\s*"
r"(?:views?|reactions?|likes?|comments?|shares?|plays?|followers?)\s*[·,;]?\s*)+\|",
re.IGNORECASE,
)
_LETTER_RUN = re.compile(r"[^\W\d_]+", re.UNICODE)
_TRAILING_HASHTAGS = re.compile(r"(?:\s+#[^\s#]*[^\W\d\s_][^\s#]*)+\s*$", re.UNICODE)
_REPEAT_PUNCT = re.compile(r"([!?.,\-])\1{1,}")
@@ -49,7 +61,10 @@ def normalize_title(raw: str | None) -> str | None:
return raw
t = unicodedata.normalize("NFKC", raw)
t = "".join(ch for ch in t if unicodedata.category(ch) not in _DROP_CATEGORIES)
t = _TRAILING_HASHTAGS.sub("", t)
stripped = _ENGAGEMENT_PREFIX.sub("", t)
prefix_removed = stripped != t
t = _TRAILING_HASHTAGS.sub("", stripped)
base = t # after prefix + trailing-hashtag removal — the fallback if de-shouting empties it
multi = [r for r in _LETTER_RUN.findall(t) if len(r) >= 2]
caps = [r for r in multi if r.isupper()]
@@ -77,4 +92,8 @@ def normalize_title(raw: str | None) -> str | None:
if ch.isalpha():
t = t[:i] + ch.upper() + t[i + 1:]
break
return t or raw
# Nothing survived cleaning: fall back to the prefix+hashtag-stripped text (so neither the
# stats prefix nor the SEO hashtags are resurrected). Only use the untouched raw when no prefix
# was there to strip — e.g. an all-emoji title whose glyphs were dropped, where returning the
# original is the lesser evil.
return t or base.strip() or ("" if prefix_removed else raw)
+45 -4
View File
@@ -249,7 +249,10 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
asset = db.get(MediaAsset, asset_id)
if asset is None or asset.title:
return
asset.title = normalize_title(info.get("title"))
# `or info.get("id")` avoids a blank card while downloading when the cleaned title is empty
# (e.g. a title that was only a social engagement prefix); the completion path later sets
# the final title with its own source_ref fallback.
asset.title = normalize_title(info.get("title")) or info.get("id")
asset.uploader = info.get("uploader") or info.get("channel")
asset.uploader_url = _uploader_url(info)
asset.thumbnail_url = info.get("thumbnail")
@@ -398,6 +401,43 @@ def _download_settings() -> tuple[str, int]:
)
def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict) -> Path:
"""Make an mp4-compat-profile download play in every browser: H.264 + AAC/MP3 in mp4. If the
download (whatever the source shipped) isn't already that, re-encode the offending stream(s) —
or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat
profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out
of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the
file to store (converted, or the original) and corrects `info`'s codec fields to match disk."""
if spec.get("container") != "mp4" or spec.get("vcodec"):
return produced
vcodec, acodec = editmod.probe_codecs(produced)
is_mp4 = produced.suffix.lower() == ".mp4"
needs, reenc_v, reenc_a = editmod.browser_transcode_flags(vcodec, acodec, is_mp4)
if not needs:
return produced
dest = produced.with_name(produced.stem + ".browser.mp4")
action = "re-encode" if (reenc_v or reenc_a) else "remux"
log.info("job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4)
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
# Prefer the info duration; probe the file when the extractor didn't provide one, so a long
# re-encode still shows real progress instead of a frozen 0%.
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
editmod.run_ffmpeg(
editmod.build_browser_safe_cmd(produced, dest, reenc_v, reenc_a),
total_s=total_s,
on_progress=lambda pct: _set_job(job_id, progress=int(pct), phase="processing"),
should_cancel=lambda: _job_status(job_id) not in ("running", None),
)
produced.unlink(missing_ok=True)
# Keep the stored asset metadata truthful about what's now on disk.
if reenc_v:
info["vcodec"] = "h264"
if reenc_a:
info["acodec"] = "aac"
return dest
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
staging = _staging_dir(asset_id)
try:
@@ -418,6 +458,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
raise RuntimeError("yt-dlp produced no output file")
produced = cands[0]
produced = _ensure_browser_playable(job_id, produced, spec, info)
ext = produced.suffix.lstrip(".").lower()
meta = storage.MediaMeta(
video_id=info.get("id") or source_ref,
@@ -479,9 +520,9 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
db.commit()
log.info("job %s done: %s", job_id, rel)
except _Aborted:
# Paused/canceled mid-download: free the asset for a later retry; keep the job's status
# (the route set it to paused/canceled). ref_count is adjusted by the route.
except (_Aborted, editmod.EditAborted):
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
_reset_asset_pending(asset_id)
log.info("job %s aborted (pause/cancel)", job_id)
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
+79
View File
@@ -0,0 +1,79 @@
"""build_ydl_opts: the mp4 compat profile must SELECT H.264 (not merely prefer it), so shared
downloads play on iOS/Safari, while custom vcodec / non-mp4 profiles opt out. See the iPad AV1
broken-player fix."""
from app.downloads import edit as editmod
from app.downloads import formats
def _opts(spec):
return formats.build_ydl_opts(spec, "/tmp/%(id)s.%(ext)s", lambda d: None)
def test_mp4_default_selects_avc1_with_progressive_fallback():
fmt = _opts({"mode": "av", "container": "mp4"})["format"]
# avc1 video-only first, then the avc1 progressive, then an unrestricted fallback.
assert fmt.startswith("bestvideo[vcodec^=avc1]+bestaudio[acodec^=mp4a]")
assert "/best[vcodec^=avc1]" in fmt
assert fmt.endswith("bestvideo+bestaudio/best")
def test_format_sig_salt_forks_a_distinct_identity():
spec = {"mode": "av", "container": "mp4", "max_height": 720}
base = formats.format_sig(spec)
# A salt yields a distinct cache identity (force-redownload's private-asset fork); different
# salts differ from each other and from the unsalted sig, same salt is stable.
assert formats.format_sig(spec, salt="7:12") != base
assert formats.format_sig(spec, salt="7:12") != formats.format_sig(spec, salt="7:13")
assert formats.format_sig(spec, salt="7:12") == formats.format_sig(spec, salt="7:12")
def test_mp4_default_keeps_h264_sort_tiebreaker():
sort = _opts({"mode": "av", "container": "mp4"})["format_sort"]
assert "vcodec:h264" in sort and "acodec:aac" in sort
def test_video_only_mp4_selects_avc1():
fmt = _opts({"mode": "v", "container": "mp4"})["format"]
assert fmt.startswith("bestvideo[vcodec^=avc1]")
assert "+bestaudio" not in fmt # video-only never merges audio
# A video-only download stays audio-free: every `bestvideo` (video-only) option is preferred
# over any `best` (progressive, audio-bearing) fallback.
assert fmt.index("bestvideo") < fmt.index("best[vcodec^=avc1]") < fmt.rindex("/best")
def test_max_height_caps_every_branch():
fmt = _opts({"mode": "av", "container": "mp4", "max_height": 720})["format"]
assert fmt.count("[height<=?720]") >= 3 # applied to each avc1/fallback branch
def test_explicit_vcodec_opts_out_of_avc1_selector():
opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"})
assert "vcodec^=avc1" not in opts["format"]
assert opts["format"] == "bestvideo+bestaudio/best"
assert "vcodec:vp9" in opts["format_sort"]
def test_non_mp4_container_opts_out():
fmt = _opts({"mode": "av", "container": "webm"})["format"]
assert "vcodec^=avc1" not in fmt
def test_audio_only_unaffected():
assert _opts({"mode": "a", "audio_format": "m4a"})["format"] == "bestaudio/best"
def test_browser_transcode_flags_universal_playability():
F = editmod.browser_transcode_flags
# Already universal: H.264 + AAC in mp4 → no change.
assert F("h264", "aac", True) == (False, False, False)
assert F("h264", "mp3", True) == (False, False, False)
# Non-H.264 video → full video re-encode (AV1/VP9 fail iOS; HEVC fails Chrome/FF).
assert F("av1", "aac", True) == (True, True, False)
assert F("vp9", "opus", True) == (True, True, True)
assert F("hevc", "aac", True) == (True, True, False)
# H.264 but bad audio → audio-only transcode (video copied).
assert F("h264", "opus", True) == (True, False, True)
# Codecs fine but wrong container → remux only (no re-encode).
assert F("h264", "aac", False) == (True, False, False)
# A failed probe / missing stream is never re-encoded.
assert F(None, None, True) == (False, False, False)
+78
View File
@@ -0,0 +1,78 @@
"""ASCII-slug naming (no spaces, no accents) for on-disk paths and served filenames, and the
social engagement-prefix strip in title normalization."""
from app.downloads import storage
from app.titles import normalize_title
def _is_ascii_slug(s: str) -> bool:
return s.isascii() and " " not in s
def test_sanitize_folds_accents_to_ascii():
assert storage.sanitize("Barnabás Skriván") == "Barnabas_Skrivan"
assert storage.sanitize("Comedy Central Magyarország") == "Comedy_Central_Magyarorszag"
assert storage.sanitize("Marco Mobili Bútoráruház Veszprém") == "Marco_Mobili_Butoraruhaz_Veszprem"
# Hungarian double-acute (ő/ű) and German ß.
assert storage.sanitize("Előző Műsor") == "Elozo_Musor"
assert storage.sanitize("Straße") == "Strasse"
def test_sanitize_output_is_pure_ascii_without_spaces():
for name in ("Poénbomba", "Mágáné Zsuzsanna", "café déjà vu", "naïve œuvre"):
out = storage.sanitize(name)
assert _is_ascii_slug(out), out
def test_sanitize_non_latin_falls_back_not_crashes():
# A title with no ASCII base collapses to the untitled fallback rather than an empty name.
assert storage.sanitize("日本語") == "untitled"
def test_display_filename_uses_underscores_and_ascii():
out = storage.display_filename("Amikor a férfi éhesen ér haza")
assert out == "Amikor_a_ferfi_ehesen_er_haza"
assert _is_ascii_slug(out)
def test_download_filename_appends_single_extension():
from pathlib import Path
assert storage.download_filename("Szép Videó", "mp4", Path("x.mp4")) == "Szep_Video.mp4"
def test_rel_path_is_ascii_everywhere():
meta = storage.MediaMeta(
video_id="abc123", title="Áristom Ödön", uploader="Hajdú Balázs", upload_date=None
)
rel = storage.rel_path(meta, "mp4", "plex", asset_id=7)
assert _is_ascii_slug(rel.replace("/", ""))
assert "Hajdu_Balazs" in rel and "Aristom_Odon" in rel
def test_engagement_prefix_stripped():
assert normalize_title(
"1.6M views · 66K reactions | Amikor a férfi éhesen ér haza"
) == "Amikor a férfi éhesen ér haza"
assert normalize_title("13M views · 162K reactions |Valami cím") == "Valami cím"
assert normalize_title("500 views | Rövid") == "Rövid"
def test_engagement_prefix_does_not_touch_normal_titles():
# A pipe that isn't an engagement prefix must survive.
assert normalize_title("Vlog 12 | A nagy nap") == "Vlog 12 | A nagy nap"
assert normalize_title("How I built a boat") == "How I built a boat"
def test_engagement_only_title_does_not_resurrect_the_stats():
# A title that is ONLY the stats prefix must not fall back to the raw (stats-bearing) string.
out = normalize_title("1.6M views · 66K reactions |")
assert out == ""
assert "views" not in (out or "") and "reactions" not in (out or "")
def test_prefix_plus_only_hashtags_resurrects_neither_stats_nor_hashtags():
# stats prefix + trailing SEO hashtags → both are removed; the fallback must not bring back the
# hashtags that rule 2 strips (nor the stats prefix).
out = normalize_title("5 views | #foo #bar")
assert out == ""
assert "#foo" not in (out or "") and "views" not in (out or "")
+2 -1
View File
@@ -88,7 +88,8 @@ class TestRelPath:
class TestDownloadFilename:
def test_appends_the_container_extension(self):
assert download_filename("My Video", "mp4", Path("/x/f.webm")) == "My Video.mp4"
# Served names follow the same ASCII-slug policy as on-disk names: spaces → underscores.
assert download_filename("My Video", "mp4", Path("/x/f.webm")) == "My_Video.mp4"
def test_does_not_double_an_extension_already_present(self):
assert download_filename("clip.mp4", "mp4", Path("/x/f.mp4")) == "clip.mp4"
+23 -2
View File
@@ -9,6 +9,7 @@ import {
Pause,
Play,
Plus,
RotateCcw,
Scissors,
Settings2,
Share2,
@@ -672,14 +673,22 @@ export default function DownloadCenter() {
const invalidate = () => invalidateDownloads(qc);
const act = useMutation({
mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) =>
mutationFn: ({
id,
op,
}: {
id: number;
op: "pause" | "resume" | "cancel" | "delete" | "redownload";
}) =>
op === "pause"
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: op === "cancel"
? api.cancelDownload(id)
: api.deleteDownload(id),
: op === "redownload"
? api.redownloadDownload(id)
: api.deleteDownload(id),
onSuccess: invalidate,
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global
// error modal (api.ts req) with the backend's specific message — a caller toast would double it.
@@ -824,6 +833,18 @@ export default function DownloadCenter() {
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
() => act.mutate({ id: job.id, op: "redownload" }),
)
}
title={t("downloads.actions.redownload")}
>
<RotateCcw className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() =>
confirmThen(
+1 -1
View File
@@ -11,7 +11,7 @@ const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
const BLANK: DownloadSpec = {
mode: "av",
max_height: 1080,
max_height: 720,
container: "mp4",
audio_format: "m4a",
vcodec: null,
+4 -1
View File
@@ -59,6 +59,7 @@
"editDetails": "Edit details",
"share": "Share",
"edit": "Edit / trim",
"redownload": "Re-download (rebuild file)",
"removeShared": "Remove from my list"
},
"empty": {
@@ -125,7 +126,9 @@
"deleteTitle": "Remove download?",
"deleteBody": "This removes it from your list. The file may stay cached for other users until it expires.",
"removeSharedTitle": "Remove from your list?",
"removeSharedBody": "This removes it from your shared list only — it won't delete the owner's file."
"removeSharedBody": "This removes it from your shared list only — it won't delete the owner's file.",
"redownloadTitle": "Re-download this file?",
"redownloadBody": "The current file is deleted and downloaded again under the latest format and naming rules. Any share links you've sent keep working and will point to the new file (a failed re-download leaves the link broken)."
},
"profiles": {
"manage": "Manage formats",
+4 -1
View File
@@ -59,6 +59,7 @@
"editDetails": "Adatok szerkesztése",
"share": "Megosztás",
"edit": "Szerkesztés / vágás",
"redownload": "Újraletöltés (fájl újraépítése)",
"removeShared": "Eltávolítás a listámból"
},
"empty": {
@@ -125,7 +126,9 @@
"deleteTitle": "Eltávolítod a letöltést?",
"deleteBody": "Ez eltávolítja a listádról. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára.",
"removeSharedTitle": "Eltávolítod a listádból?",
"removeSharedBody": "Csak a te megosztott listádból tűnik el — a tulajdonos fájlját nem törli."
"removeSharedBody": "Csak a te megosztott listádból tűnik el — a tulajdonos fájlját nem törli.",
"redownloadTitle": "Újratöltöd ezt a fájlt?",
"redownloadBody": "A jelenlegi fájl törlődik és újra letöltődik a legfrissebb formátum- és elnevezési szabályokkal. A már elküldött megosztási linkek működnek tovább, és az új fájlra fognak mutatni (sikertelen újratöltés esetén a link törötten marad)."
},
"profiles": {
"manage": "Formátumok kezelése",
+4
View File
@@ -1673,6 +1673,10 @@ export const api = {
req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }),
cancelDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }),
// Force a fresh re-download of a finished/failed job — keeps the job (and its share links),
// deletes the old file, and rebuilds it under the current format/naming rules.
redownloadDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/redownload`, { method: "POST" }, { idempotent: true }),
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
// Update a download's display metadata (title / channel name+link / extra reference URLs).
updateDownloadMeta: (
+14
View File
@@ -14,6 +14,20 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.51.0",
date: "2026-07-22",
summary: "Downloads that play on every device, cleaner file names, and one-click re-download.",
features: [
"Downloads now default to 720p — quicker and plays everywhere. 1080p and Best are still one click away in the format list.",
"New “Re-download” button on each item in your Library rebuilds the file under the latest rules; any share links you've already sent keep working and point to the new file.",
],
fixes: [
"Shared videos now play in every browser, including on iPhone and iPad (Safari and Brave): a file that arrived in a format Apple can't decode (such as AV1) is now automatically converted to a universally-playable one, so the “broken play button” is gone.",
"Downloaded folder and file names are now plain, accent-free ASCII with underscores instead of spaces — portable, and no longer shown as “?” in some file tools.",
"Titles that start with social clutter like “1.6M views · 66K reactions |” are cleaned up automatically.",
],
},
{
version: "0.50.0",
date: "2026-07-22",