From 8fd0049dcea6587412993b7eb82f71c42244a709 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 22 Jul 2026 01:19:31 +0200 Subject: [PATCH 1/5] fix(downloads): make shared files iOS-playable + default to 720p MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared /watch links showed a "broken play button" on iPad (any iOS browser — all forced onto WebKit) while playing fine on desktop Chrome. Root cause: the stored mp4's video codec was AV1 (confirmed via ffprobe), which WebKit can't decode. The existing H.264 guard was only a format_sort *preference*, so a non-YouTube source (Facebook) that ships AV1 as its best video-only stream slipped through. - B1: for the mp4 compat profile, prefer H.264 at the SELECTION level with a progressive fallback (bestvideo[vcodec^=avc1]+bestaudio / best[vcodec^=avc1] / anything) so an avc1 progressive is chosen over an AV1-only video-only stream. - B2: re-encode the rare AV1/VP9-only result to H.264+AAC (+faststart) in the worker before storing, gated on the compat profile; corrects the asset codec metadata to match. - Bump _SIG_VERSION 2->3 so cached AV1 assets re-derive. - Default download preset is now 720p (migration 0058 re-seeds builtins 720p-first; ProfileEditor BLANK 1080->720). - Tests for the selector, compat opt-outs, and the transcode predicate. --- .../alembic/versions/0058_dl_default_720p.py | 71 +++++++++++++++++++ backend/app/downloads/edit.py | 40 +++++++++++ backend/app/downloads/formats.py | 45 ++++++++---- backend/app/worker.py | 35 ++++++++- backend/tests/test_formats.py | 59 +++++++++++++++ frontend/src/components/ProfileEditor.tsx | 2 +- 6 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 backend/alembic/versions/0058_dl_default_720p.py create mode 100644 backend/tests/test_formats.py diff --git a/backend/alembic/versions/0058_dl_default_720p.py b/backend/alembic/versions/0058_dl_default_720p.py new file mode 100644 index 00000000..6d8e1a91 --- /dev/null +++ b/backend/alembic/versions/0058_dl_default_720p.py @@ -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 diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py index cff449a5..2a7e9c89 100644 --- a/backend/app/downloads/edit.py +++ b/backend/app/downloads/edit.py @@ -268,6 +268,46 @@ def run_ffmpeg(cmd, total_s, on_progress, should_cancel) -> None: raise RuntimeError(f"ffmpeg failed ({ret}): {err[:300]}") +# --- ffmpeg: iOS/Safari codec compatibility ------------------------------------------------- +# Apple WebKit (every iOS browser, Brave included) decodes only H.264/H.265 in mp4 — an AV1/VP9 +# file plays on desktop Chrome but shows a "broken player" on iPad. The mp4 compat profile now +# *selects* H.264, but a source may expose no H.264 stream at all, so this is the last-ditch net. +_IOS_SAFE_VCODECS = ("h264", "hevc") + + +def probe_vcodec(path: Path) -> str | None: + """Return the file's primary video codec name (lowercased) via ffprobe, or None if unknown.""" + try: + out = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", + "stream=codec_name", "-of", "default=nw=1:nk=1", str(path)], + capture_output=True, text=True, timeout=30, check=True, + ).stdout.strip().lower() + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): + return None + return out or None + + +def needs_ios_transcode(vcodec: str | None) -> bool: + """True when an mp4's video codec won't play in Apple WebKit and should be re-encoded to H.264. + None (unknown/ffprobe failed) is treated as safe so we never re-encode on a bad probe.""" + return bool(vcodec) and vcodec not in _IOS_SAFE_VCODECS and vcodec != "none" + + +def build_h264_transcode_cmd(src: Path, dest: Path) -> list[str]: + """Re-encode `src` to an iOS-safe H.264 + AAC mp4 (faststart), preserving resolution. + `-progress pipe:1` streams machine-readable progress for run_ffmpeg.""" + return [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", + "-progress", "pipe:1", "-nostats", + "-i", str(src), + "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", + str(dest), + ] + + # --- filmstrip (editor scrub track) --------------------------------------------------------- _SB_COLS = 12 diff --git a/backend/app/downloads/formats.py b/backend/app/downloads/formats.py index 28b7f453..ac122429 100644 --- a/backend/app/downloads/formats.py +++ b/backend/app/downloads/formats.py @@ -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"} @@ -142,22 +144,41 @@ 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: + opts["format"] = ( + f"bestvideo[vcodec^=avc1]{hcap}/best[vcodec^=avc1]{hcap}" + f"/bestvideo{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}") diff --git a/backend/app/worker.py b/backend/app/worker.py index d8080468..8e06d4fb 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -398,6 +398,34 @@ def _download_settings() -> tuple[str, int]: ) +def _ensure_ios_compatible(job_id: int, produced: Path, spec: dict, info: dict) -> Path: + """Last-ditch iOS net: if the mp4 compat profile still yielded an Apple-undecodable codec + (AV1/VP9 — happens when a source exposes no H.264 stream at all), re-encode to H.264 + AAC so + shared /watch files play on iPad. Gated on the compat profile so an explicit `vcodec` or a + non-mp4 container is respected. Returns the file to store (re-encoded, or the original), and + corrects `info`'s codec fields so the stored asset metadata matches the actual file.""" + if spec.get("container") != "mp4" or spec.get("vcodec"): + return produced + if produced.suffix.lower() != ".mp4": + return produced + vcodec = editmod.probe_vcodec(produced) + if not editmod.needs_ios_transcode(vcodec): + return produced + + dest = produced.with_name(produced.stem + ".ios.mp4") + log.info("job %s: re-encoding %s→h264 for iOS compatibility", job_id, vcodec) + _set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None) + editmod.run_ffmpeg( + editmod.build_h264_transcode_cmd(produced, dest), + total_s=int(info["duration"]) if info.get("duration") else 0, + 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) + info["vcodec"], info["acodec"] = "h264", "aac" # asset metadata must match the stored file + 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 +446,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_ios_compatible(job_id, produced, spec, info) ext = produced.suffix.lstrip(".").lower() meta = storage.MediaMeta( video_id=info.get("id") or source_ref, @@ -479,9 +508,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 diff --git a/backend/tests/test_formats.py b/backend/tests/test_formats.py new file mode 100644 index 00000000..8f603d0f --- /dev/null +++ b/backend/tests/test_formats.py @@ -0,0 +1,59 @@ +"""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_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 + + +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_needs_ios_transcode_flags_only_undecodable_codecs(): + assert editmod.needs_ios_transcode("av1") is True + assert editmod.needs_ios_transcode("vp9") is True + assert editmod.needs_ios_transcode("h264") is False + assert editmod.needs_ios_transcode("hevc") is False + # An unknown/failed probe must NOT trigger a needless re-encode. + assert editmod.needs_ios_transcode(None) is False + assert editmod.needs_ios_transcode("none") is False diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index 8db048a7..9648294c 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -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, From e46f215adab57dbeb9a73cb1d973a1449933a8c6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 22 Jul 2026 02:04:27 +0200 Subject: [PATCH 2/5] feat(downloads): ASCII-slug names, universal browser playability, force re-download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mini-epic "Downloads correctness & UX" (S1–S3), on top of the iOS-AV1 + 720p fixes. S1 — naming & titles: - ASCII-fold on-disk dir/file names AND the served (Content-Disposition) filename: no spaces (underscore-joined), no accents (á→a, ő→o, ß→ss), pure ASCII — portable and free of the `?` mojibake accented names show in non-UTF-8 tools. (Forward-only; existing files clean up via re-download.) - Strip a leading social engagement prefix ("1.6M views · 66K reactions | …") from titles. S2 — universal browser playability (generalizes the iOS re-encode): - ensure_browser_playable guarantees the stored file is H.264 + AAC/MP3 in mp4 — the only combo every browser decodes. Re-encode just the offending stream(s) (video for AV1/VP9/HEVC, audio for Opus/etc), or remux when only the container is wrong. Gated on the mp4 compat profile (an explicit vcodec / non-mp4 custom profile opts out). S3 — force re-download (preserves share links): - POST /downloads/{job_id}/redownload keeps the job row so its public DownloadLink survives and resolves to the freshly-downloaded file; deletes the old file and re-fetches under the current rules. A failed re-download leaves the job in error (the intended broken-link case). - Library "Re-download" action (icon + confirm), api method, hu/en strings. Tests: naming/title-strip (test_naming), browser_transcode_flags matrix; existing download_filename expectation updated for the underscore policy. --- backend/app/downloads/edit.py | 70 +++++++++++++-------- backend/app/downloads/storage.py | 51 +++++++++++---- backend/app/routes/downloads.py | 44 ++++++++++++- backend/app/titles.py | 10 +++ backend/app/worker.py | 35 ++++++----- backend/tests/test_formats.py | 23 ++++--- backend/tests/test_naming.py | 63 +++++++++++++++++++ backend/tests/test_storage.py | 3 +- frontend/src/components/DownloadCenter.tsx | 25 +++++++- frontend/src/i18n/locales/en/downloads.json | 5 +- frontend/src/i18n/locales/hu/downloads.json | 5 +- frontend/src/lib/api.ts | 4 ++ 12 files changed, 272 insertions(+), 66 deletions(-) create mode 100644 backend/tests/test_naming.py diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py index 2a7e9c89..9eab60f3 100644 --- a/backend/app/downloads/edit.py +++ b/backend/app/downloads/edit.py @@ -268,44 +268,62 @@ def run_ffmpeg(cmd, total_s, on_progress, should_cancel) -> None: raise RuntimeError(f"ffmpeg failed ({ret}): {err[:300]}") -# --- ffmpeg: iOS/Safari codec compatibility ------------------------------------------------- -# Apple WebKit (every iOS browser, Brave included) decodes only H.264/H.265 in mp4 — an AV1/VP9 -# file plays on desktop Chrome but shows a "broken player" on iPad. The mp4 compat profile now -# *selects* H.264, but a source may expose no H.264 stream at all, so this is the last-ditch net. -_IOS_SAFE_VCODECS = ("h264", "hevc") +# --- 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_vcodec(path: Path) -> str | None: - """Return the file's primary video codec name (lowercased) via ffprobe, or None if unknown.""" +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", "-select_streams", "v:0", "-show_entries", - "stream=codec_name", "-of", "default=nw=1:nk=1", str(path)], + ["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.strip().lower() + ).stdout.lower() except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): - return None - return out or None + 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 needs_ios_transcode(vcodec: str | None) -> bool: - """True when an mp4's video codec won't play in Apple WebKit and should be re-encoded to H.264. - None (unknown/ffprobe failed) is treated as safe so we never re-encode on a bad probe.""" - return bool(vcodec) and vcodec not in _IOS_SAFE_VCODECS and vcodec != "none" +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_h264_transcode_cmd(src: Path, dest: Path) -> list[str]: - """Re-encode `src` to an iOS-safe H.264 + AAC mp4 (faststart), preserving resolution. - `-progress pipe:1` streams machine-readable progress for run_ffmpeg.""" - return [ +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), - "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", - "-c:a", "aac", "-b:a", "192k", - "-movflags", "+faststart", - str(dest), + "-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) --------------------------------------------------------- diff --git a/backend/app/downloads/storage.py b/backend/app/downloads/storage.py index 665e107c..b98eab64 100644 --- a/backend/app/downloads/storage.py +++ b/backend/app/downloads/storage.py @@ -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" diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 33171302..566461b2 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -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,48 @@ 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). + Useful after a format/naming fix: the old file is deleted and re-fetched under the current rules + (the format-sig bump alone re-points this to a fresh asset).""" + 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.") + + # Drop this job's hold on its current asset (deletes the file if it was the last holder), then + # resolve the asset for the current spec and force a genuinely fresh fetch even on a cache hit. + _release_asset(db, job) + spec = formats.normalize(job.profile_snapshot) + sig = formats.format_sig(spec) + asset = service.get_or_create_asset(db, job.source_kind, job.source_ref, sig) + if asset.status == "ready": + if asset.rel_path: + storage.delete_asset_files(settings.download_root, asset.rel_path) + asset.rel_path = None + asset.status = "pending" + service.populate_from_catalog(db, asset) + + 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 + 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) diff --git a/backend/app/titles.py b/backend/app/titles.py index e609f58f..4e621cf8 100644 --- a/backend/app/titles.py +++ b/backend/app/titles.py @@ -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,13 @@ 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 "