Merge: promote dev to prod
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 "")
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user