Merge: downloads naming/cleanup fixes (0.51.1)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"""Download retention garbage collector (a scheduler job).
|
"""Download retention garbage collector (a scheduler job).
|
||||||
|
|
||||||
Four passes each run:
|
Five passes each run:
|
||||||
1. Pre-expiry warning — for ready assets expiring within the grace window, notify each holder
|
1. Pre-expiry warning — for ready assets expiring within the grace window, notify each holder
|
||||||
once (gc_notified flag) so a download isn't silently deleted.
|
once (gc_notified flag) so a download isn't silently deleted.
|
||||||
2. Expiry deletion — delete assets past their TTL (files + row). The FK SET NULL nulls the
|
2. Expiry deletion — delete assets past their TTL (files + row). The FK SET NULL nulls the
|
||||||
@@ -9,11 +9,16 @@ Four passes each run:
|
|||||||
ready assets until back under it.
|
ready assets until back under it.
|
||||||
4. Orphan reap — delete errored, fileless, unreferenced asset rows (they carry no TTL, so
|
4. Orphan reap — delete errored, fileless, unreferenced asset rows (they carry no TTL, so
|
||||||
passes 1-3 skip them; benign row-count hygiene).
|
passes 1-3 skip them; benign row-count hygiene).
|
||||||
|
5. Orphan FILE sweep — delete on-disk files/dirs no surviving asset owns (cruft left when a
|
||||||
|
re-download renames an asset, e.g. old accented channel dirs), skipping system trees and
|
||||||
|
anything newer than an hour so an in-flight download is never touched.
|
||||||
|
|
||||||
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -26,6 +31,11 @@ from app.notifications import create_notification
|
|||||||
|
|
||||||
log = logging.getLogger("siftlode.downloads.gc")
|
log = logging.getLogger("siftlode.downloads.gc")
|
||||||
|
|
||||||
|
# Dot-prefixed top-level trees that are NOT part of the Plex/flat asset layout — the orphan sweep
|
||||||
|
# never touches them (user clips, Plex HLS/img caches, in-progress staging, editor storyboards).
|
||||||
|
_SYSTEM_TREE = {".edits", ".plex-hls", ".plex-img-cache", ".staging", ".storyboards"}
|
||||||
|
_ORPHAN_MIN_AGE_S = 3600 # only sweep files older than this, so an in-flight download is never hit
|
||||||
|
|
||||||
|
|
||||||
def _holders(db: Session, asset_id: int) -> list[int]:
|
def _holders(db: Session, asset_id: int) -> list[int]:
|
||||||
return list(
|
return list(
|
||||||
@@ -57,6 +67,53 @@ def _delete_asset(db: Session, asset: MediaAsset, ntype: str) -> None:
|
|||||||
db.delete(asset) # jobs.asset_id -> NULL via FK ON DELETE SET NULL
|
db.delete(asset) # jobs.asset_id -> NULL via FK ON DELETE SET NULL
|
||||||
|
|
||||||
|
|
||||||
|
def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
||||||
|
"""Delete media/sidecar files (and now-empty dirs) under DOWNLOAD_ROOT that no `ready` asset
|
||||||
|
owns — cruft left when a re-download changes an asset's on-disk name (e.g. an old accented
|
||||||
|
channel dir, or a poster from a superseded naming scheme). Skips the dot-prefixed system trees
|
||||||
|
and only removes files older than `_ORPHAN_MIN_AGE_S`, so a just-completed or in-flight download
|
||||||
|
is never deleted, and only files NOT owned by a surviving asset."""
|
||||||
|
root = Path(download_root)
|
||||||
|
if not root.is_dir():
|
||||||
|
return 0
|
||||||
|
keep: set[Path] = set()
|
||||||
|
for rel in db.execute(
|
||||||
|
select(MediaAsset.rel_path).where(
|
||||||
|
MediaAsset.status == "ready", MediaAsset.rel_path.is_not(None)
|
||||||
|
)
|
||||||
|
).scalars():
|
||||||
|
media = (root / rel).resolve()
|
||||||
|
keep |= {media, media.with_suffix(".nfo"), media.with_suffix(".jpg")}
|
||||||
|
|
||||||
|
cutoff = time.time() - _ORPHAN_MIN_AGE_S
|
||||||
|
removed = 0
|
||||||
|
for entry in root.iterdir():
|
||||||
|
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
|
||||||
|
continue
|
||||||
|
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
if f.resolve() in keep or f.stat().st_mtime > cutoff:
|
||||||
|
continue
|
||||||
|
f.unlink()
|
||||||
|
removed += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself
|
||||||
|
for d in sorted(
|
||||||
|
(p for p in entry.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
d.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
entry.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def run_download_gc(db: Session) -> dict:
|
def run_download_gc(db: Session) -> dict:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
grace_days = sysconfig.get_int(db, "download_gc_grace_days")
|
grace_days = sysconfig.get_int(db, "download_gc_grace_days")
|
||||||
@@ -132,6 +189,12 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
if orphans:
|
if orphans:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
result = {"warned": warned, "expired": expired, "evicted": evicted, "reaped": reaped}
|
# 5. Orphan FILE sweep — remove on-disk cruft no surviving asset owns (old accented dirs, stale
|
||||||
|
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
|
||||||
|
swept = _sweep_orphan_files(db, settings.download_root)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"warned": warned, "expired": expired, "evicted": evicted, "reaped": reaped, "swept": swept
|
||||||
|
}
|
||||||
log.info("download_gc %s", result)
|
log.info("download_gc %s", result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ from datetime import date
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.sax.saxutils import escape
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
# Reserve room for the " [id].ext" suffix + parent dirs within the 255-byte component limit.
|
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
|
||||||
_MAX_TITLE = 120
|
_MAX_TITLE = 120
|
||||||
# User-facing (Content-Disposition) names can be longer than an on-disk component.
|
# User-facing (Content-Disposition) names can be longer than an on-disk component.
|
||||||
_MAX_DISPLAY = 150
|
_MAX_DISPLAY = 150
|
||||||
# Filesystem-illegal on Windows/Plex + control chars.
|
# The ONLY characters allowed in a generated name — letters, digits, and `._-`. Everything else
|
||||||
_ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
# (spaces, and every shell/filesystem-unpleasant metacharacter: ! ? * & $ ( ) [ ] { } ' " ; | < >
|
||||||
|
# etc.) collapses to a single underscore, so names are safe to handle in any shell or tool.
|
||||||
|
_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||||
# Unicode categories we drop entirely: emoji/pictographs (So), modifier symbols/skin-tones
|
# Unicode categories we drop entirely: emoji/pictographs (So), modifier symbols/skin-tones
|
||||||
# (Sk), and every control/format/private/surrogate/unassigned char (C*).
|
# (Sk), and every control/format/private/surrogate/unassigned char (C*).
|
||||||
_DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
|
_DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
|
||||||
@@ -62,49 +64,33 @@ class MediaMeta:
|
|||||||
thumbnail_url: str | None = None
|
thumbnail_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def sanitize(name: str, limit: int = _MAX_TITLE) -> str:
|
def _slug(name: str, limit: int, fallback: str) -> str:
|
||||||
"""Turn an arbitrary (emoji-laden, clickbait) title into a clean, space-free ASCII path token.
|
"""Reduce an arbitrary (emoji-laden, clickbait, accented) title to a portable, shell-safe token.
|
||||||
|
|
||||||
Steps: NFKC-normalize (fold fancy width/compat forms) → drop emoji/symbols/control chars →
|
NFKC-normalize → drop emoji/symbols/control chars → ASCII-fold accents (á→a, ő→o, ß→ss) →
|
||||||
ASCII-fold accents (á→a, ő→o, ß→ss) → strip filesystem-illegal chars → collapse whitespace and
|
replace every run of unsafe characters (spaces + shell/filesystem metacharacters) with a single
|
||||||
runs of the same punctuation → join words with underscores → trim junk punctuation from the ends
|
underscore → collapse separator pile-ups → trim junk from the ends → length-cap. The result is
|
||||||
→ length-cap. The result is pure ASCII with no spaces or path separators (digits survive)."""
|
pure ASCII containing only `[A-Za-z0-9._-]` — no spaces, accents, or characters that need
|
||||||
text = unicodedata.normalize("NFKC", name or "")
|
quoting in a shell."""
|
||||||
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)
|
|
||||||
# Whitespace → single underscore (no spaces in generated names).
|
|
||||||
text = re.sub(r"\s+", "_", text.strip())
|
|
||||||
# Tidy separator pile-ups and trim junk from the ends.
|
|
||||||
text = re.sub(r"_{2,}", "_", text).strip("_.-·—–|,;:!?ّ ")
|
|
||||||
# Cap by BYTES, not characters: a path component's hard limit is 255 BYTES, and rel_path packs
|
|
||||||
# the (byte-capped) channel + date + id + ext into that same component — so an accented/multi-byte
|
|
||||||
# title capped only by char count could still overflow to ENAMETOOLONG. Truncate on a UTF-8
|
|
||||||
# boundary (decode(..., "ignore") drops a trailing partial char) so we never split a character.
|
|
||||||
if len(text.encode("utf-8")) > limit:
|
|
||||||
text = text.encode("utf-8")[:limit].decode("utf-8", "ignore").rstrip("_.-")
|
|
||||||
return text or "untitled"
|
|
||||||
|
|
||||||
|
|
||||||
def display_filename(name: str) -> str:
|
|
||||||
"""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 = unicodedata.normalize("NFKC", name or "")
|
||||||
text = "".join(ch for ch in text if unicodedata.category(ch) not in _DROP_CATEGORIES)
|
text = "".join(ch for ch in text if unicodedata.category(ch) not in _DROP_CATEGORIES)
|
||||||
text = _ascii_fold(text)
|
text = _ascii_fold(text)
|
||||||
text = _ILLEGAL.sub("", text)
|
text = _UNSAFE.sub("_", text)
|
||||||
text = re.sub(r"([^\w\s])\1{1,}", r"\1", text)
|
text = re.sub(r"[-_]{2,}", "_", text).strip("_.-")
|
||||||
text = re.sub(r"\s+", "_", text.strip())
|
if len(text) > limit: # pure ASCII → 1 char == 1 byte, so a char cap is also the byte cap
|
||||||
text = re.sub(r"_{2,}", "_", text).strip("_.-·—–|,;:!?ّ ")
|
text = text[:limit].rstrip("_.-")
|
||||||
if len(text) > _MAX_DISPLAY:
|
return text or fallback
|
||||||
text = text[:_MAX_DISPLAY].rstrip("_.-")
|
|
||||||
return text or "download"
|
|
||||||
|
def sanitize(name: str, limit: int = _MAX_TITLE) -> str:
|
||||||
|
"""On-disk path token: portable, shell-safe, pure ASCII (see `_slug`)."""
|
||||||
|
return _slug(name, limit, "untitled")
|
||||||
|
|
||||||
|
|
||||||
|
def display_filename(name: str) -> str:
|
||||||
|
"""Content-Disposition filename base — the same shell-safe slug policy as the on-disk name, just
|
||||||
|
with a larger length cap (a served name may be longer than a path component)."""
|
||||||
|
return _slug(name, _MAX_DISPLAY, "download")
|
||||||
|
|
||||||
|
|
||||||
def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | None = None) -> str:
|
def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | None = None) -> str:
|
||||||
@@ -116,14 +102,16 @@ def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | No
|
|||||||
either removes the shared file. The `_a{id}` suffix (which Plex ignores — the .nfo sidecar carries
|
either removes the shared file. The `_a{id}` suffix (which Plex ignores — the .nfo sidecar carries
|
||||||
the metadata) keeps them apart. Optional so callers/tests that don't need it keep the plain name."""
|
the metadata) keeps them apart. Optional so callers/tests that don't need it keep the plain name."""
|
||||||
title = sanitize(meta.title)
|
title = sanitize(meta.title)
|
||||||
vid = meta.video_id
|
# The id goes into the name bare (no brackets — `[`/`]` are shell glob metacharacters); keep it
|
||||||
|
# shell-safe and bounded in case a URL source has no clean extractor id.
|
||||||
|
vid = _UNSAFE.sub("_", str(meta.video_id))[:40]
|
||||||
disc = f"_a{asset_id}" if asset_id is not None else ""
|
disc = f"_a{asset_id}" if asset_id is not None else ""
|
||||||
if layout == "flat":
|
if layout == "flat":
|
||||||
return f"{title}_[{vid}]{disc}.{ext}"
|
return f"{title}_{vid}{disc}.{ext}"
|
||||||
channel = sanitize(meta.uploader or "Unknown_Channel", 80)
|
channel = sanitize(meta.uploader or "Unknown_Channel", 80)
|
||||||
year = meta.upload_date.year if meta.upload_date else 0
|
year = meta.upload_date.year if meta.upload_date else 0
|
||||||
d = meta.upload_date.isoformat() if meta.upload_date else "0000-00-00"
|
d = meta.upload_date.isoformat() if meta.upload_date else "0000-00-00"
|
||||||
epname = f"{channel}_-_{d}_-_{title}_[{vid}]{disc}.{ext}"
|
epname = f"{channel}_-_{d}_-_{title}_{vid}{disc}.{ext}"
|
||||||
return f"{channel}/Season_{year}/{epname}"
|
return f"{channel}/Season_{year}/{epname}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -489,6 +489,10 @@ def redownload_download(
|
|||||||
job.error = None
|
job.error = None
|
||||||
job.speed_bps = None
|
job.speed_bps = None
|
||||||
job.eta_s = None
|
job.eta_s = None
|
||||||
|
# Re-derive the display name (card title + Content-Disposition filename) from the freshly
|
||||||
|
# cleaned title — otherwise a name auto-set from an older, un-cleaned title (e.g. still carrying
|
||||||
|
# a "1.6M views · … |" social prefix) would survive the rebuild. The worker fills it on completion.
|
||||||
|
job.display_name = None
|
||||||
job.queue_pos = service.next_queue_pos(db) # tail of the queue, not its stale original slot
|
job.queue_pos = service.next_queue_pos(db) # tail of the queue, not its stale original slot
|
||||||
db.commit()
|
db.commit()
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Orphan-file sweep: remove on-disk cruft no ready asset owns (old accented dirs, stale posters),
|
||||||
|
while never touching system trees, owned files, or freshly-written downloads."""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.downloads.gc import _sweep_orphan_files
|
||||||
|
|
||||||
|
|
||||||
|
class _Scalars:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def scalars(self):
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubDB:
|
||||||
|
"""Minimal stand-in: `_sweep_orphan_files` only issues one select for ready rel_paths."""
|
||||||
|
|
||||||
|
def __init__(self, rel_paths):
|
||||||
|
self._rel_paths = rel_paths
|
||||||
|
|
||||||
|
def execute(self, _stmt):
|
||||||
|
return _Scalars(self._rel_paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _aged(path: Path, hours: float) -> None:
|
||||||
|
old = time.time() - hours * 3600
|
||||||
|
os.utime(path, (old, old))
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_removes_orphans_but_keeps_owned_system_and_fresh(tmp_path: Path):
|
||||||
|
root = tmp_path
|
||||||
|
# An owned asset (in the keep set) + its sidecars.
|
||||||
|
kept_dir = root / "Chan" / "Season_2026"
|
||||||
|
kept_dir.mkdir(parents=True)
|
||||||
|
kept_media = kept_dir / "Chan_-_2026-01-01_-_Title_vid_a1.mp4"
|
||||||
|
for p in (kept_media, kept_media.with_suffix(".nfo"), kept_media.with_suffix(".jpg")):
|
||||||
|
p.write_bytes(b"x")
|
||||||
|
_aged(p, 5) # old, but owned → must survive
|
||||||
|
|
||||||
|
# An orphan poster in a stale (e.g. old accented) channel dir — old, unowned → swept.
|
||||||
|
orphan_dir = root / "OldChan" / "Season_2026"
|
||||||
|
orphan_dir.mkdir(parents=True)
|
||||||
|
orphan = orphan_dir / "OldChan_-_old_title_vid.jpg"
|
||||||
|
orphan.write_bytes(b"x")
|
||||||
|
_aged(orphan, 5)
|
||||||
|
|
||||||
|
# A system tree (storyboards) — never touched, even when old.
|
||||||
|
sysfile = root / ".storyboards" / "asset-1.jpg"
|
||||||
|
sysfile.parent.mkdir()
|
||||||
|
sysfile.write_bytes(b"x")
|
||||||
|
_aged(sysfile, 5)
|
||||||
|
|
||||||
|
# A brand-new unowned file (an in-flight download's just-placed output) — too young to sweep.
|
||||||
|
fresh_dir = root / "FreshChan" / "Season_2026"
|
||||||
|
fresh_dir.mkdir(parents=True)
|
||||||
|
fresh = fresh_dir / "FreshChan_-_now_vid_a2.mp4"
|
||||||
|
fresh.write_bytes(b"x") # mtime = now
|
||||||
|
|
||||||
|
db = _StubDB(["Chan/Season_2026/Chan_-_2026-01-01_-_Title_vid_a1.mp4"])
|
||||||
|
removed = _sweep_orphan_files(db, str(root))
|
||||||
|
|
||||||
|
assert removed == 1 # only the aged orphan
|
||||||
|
assert kept_media.exists() and kept_media.with_suffix(".jpg").exists()
|
||||||
|
assert not orphan.exists() and not orphan_dir.exists() # orphan + its emptied dirs pruned
|
||||||
|
assert not (root / "OldChan").exists()
|
||||||
|
assert sysfile.exists() # system tree untouched
|
||||||
|
assert fresh.exists() # too new to sweep
|
||||||
@@ -47,6 +47,17 @@ def test_rel_path_is_ascii_everywhere():
|
|||||||
rel = storage.rel_path(meta, "mp4", "plex", asset_id=7)
|
rel = storage.rel_path(meta, "mp4", "plex", asset_id=7)
|
||||||
assert _is_ascii_slug(rel.replace("/", ""))
|
assert _is_ascii_slug(rel.replace("/", ""))
|
||||||
assert "Hajdu_Balazs" in rel and "Aristom_Odon" in rel
|
assert "Hajdu_Balazs" in rel and "Aristom_Odon" in rel
|
||||||
|
# Shell-unpleasant chars (incl. the id brackets) never appear in a generated path.
|
||||||
|
for bad in "[]!?*&$()'\"; |":
|
||||||
|
assert bad not in rel
|
||||||
|
|
||||||
|
|
||||||
|
def test_rel_path_strips_exclamation_and_other_shell_chars():
|
||||||
|
meta = storage.MediaMeta(
|
||||||
|
video_id="abc123", title="Rovinjból! A vihar (2026) $$$", uploader="Chan", upload_date=None
|
||||||
|
)
|
||||||
|
rel = storage.rel_path(meta, "mp4", "plex", asset_id=1)
|
||||||
|
assert "!" not in rel and "$" not in rel and "(" not in rel
|
||||||
|
|
||||||
|
|
||||||
def test_engagement_prefix_stripped():
|
def test_engagement_prefix_stripped():
|
||||||
|
|||||||
@@ -23,19 +23,19 @@ class TestSanitize:
|
|||||||
for bad in '/\\:*?"<>|🔥':
|
for bad in '/\\:*?"<>|🔥':
|
||||||
assert bad not in out
|
assert bad not in out
|
||||||
|
|
||||||
def test_repeated_punctuation_collapses(self):
|
def test_unsafe_punctuation_is_stripped_to_a_single_separator(self):
|
||||||
assert sanitize("wow!!!___---end") == "wow!_-end"
|
# Shell-unpleasant punctuation (! etc.) is removed; runs of separators collapse to one "_".
|
||||||
|
assert sanitize("wow!!!___---end") == "wow_end"
|
||||||
|
assert "!" not in sanitize("Rovinjból! A vihar")
|
||||||
|
|
||||||
def test_length_is_capped(self):
|
def test_length_is_capped(self):
|
||||||
assert len(sanitize("x" * 500, limit=10)) <= 10
|
assert len(sanitize("x" * 500, limit=10)) <= 10
|
||||||
|
|
||||||
def test_cap_is_by_bytes_not_characters(self):
|
def test_cap_stays_within_the_byte_limit(self):
|
||||||
# Accented (2-byte UTF-8) letters must not blow past the byte limit — the FS component cap
|
# Names are folded to ASCII, so a char cap is also the byte cap — accented input can't
|
||||||
# is 255 BYTES, so a char-only cap would overflow for multi-byte titles.
|
# overflow the 255-BYTE FS component limit.
|
||||||
out = sanitize("á" * 500, limit=10)
|
out = sanitize("á" * 500, limit=10)
|
||||||
assert len(out.encode("utf-8")) <= 10
|
assert out.isascii() and len(out.encode("utf-8")) <= 10
|
||||||
# And it never splits a character (would raise otherwise / leave a replacement char).
|
|
||||||
out.encode("utf-8").decode("utf-8")
|
|
||||||
|
|
||||||
def test_empty_or_all_junk_falls_back_to_untitled(self):
|
def test_empty_or_all_junk_falls_back_to_untitled(self):
|
||||||
assert sanitize("") == "untitled"
|
assert sanitize("") == "untitled"
|
||||||
@@ -51,11 +51,11 @@ class TestRelPath:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_flat_layout_is_a_single_file(self):
|
def test_flat_layout_is_a_single_file(self):
|
||||||
assert rel_path(self.meta, "mp4", layout="flat") == "Cool_Clip_[abc123].mp4"
|
assert rel_path(self.meta, "mp4", layout="flat") == "Cool_Clip_abc123.mp4"
|
||||||
|
|
||||||
def test_plex_layout_nests_channel_and_season(self):
|
def test_plex_layout_nests_channel_and_season(self):
|
||||||
out = rel_path(self.meta, "mp4", layout="plex")
|
out = rel_path(self.meta, "mp4", layout="plex")
|
||||||
assert out == "Some_Channel/Season_2024/Some_Channel_-_2024-05-02_-_Cool_Clip_[abc123].mp4"
|
assert out == "Some_Channel/Season_2024/Some_Channel_-_2024-05-02_-_Cool_Clip_abc123.mp4"
|
||||||
|
|
||||||
def test_missing_date_uses_year_zero_and_placeholder_date(self):
|
def test_missing_date_uses_year_zero_and_placeholder_date(self):
|
||||||
meta = MediaMeta(video_id="x", title="t", uploader="c", upload_date=None)
|
meta = MediaMeta(video_id="x", title="t", uploader="c", upload_date=None)
|
||||||
@@ -67,10 +67,10 @@ class TestRelPath:
|
|||||||
a = rel_path(self.meta, "mp4", asset_id=5)
|
a = rel_path(self.meta, "mp4", asset_id=5)
|
||||||
b = rel_path(self.meta, "mp4", asset_id=6)
|
b = rel_path(self.meta, "mp4", asset_id=6)
|
||||||
assert a != b
|
assert a != b
|
||||||
assert a.endswith("_[abc123]_a5.mp4") and b.endswith("_[abc123]_a6.mp4")
|
assert a.endswith("_abc123_a5.mp4") and b.endswith("_abc123_a6.mp4")
|
||||||
# Omitting it keeps the plain name (unchanged for callers/tests that don't pass one).
|
# Omitting it keeps the plain name (unchanged for callers/tests that don't pass one).
|
||||||
assert rel_path(self.meta, "mp4", layout="flat") == "Cool_Clip_[abc123].mp4"
|
assert rel_path(self.meta, "mp4", layout="flat") == "Cool_Clip_abc123.mp4"
|
||||||
assert rel_path(self.meta, "mp4", layout="flat", asset_id=7) == "Cool_Clip_[abc123]_a7.mp4"
|
assert rel_path(self.meta, "mp4", layout="flat", asset_id=7) == "Cool_Clip_abc123_a7.mp4"
|
||||||
|
|
||||||
def test_every_component_stays_within_the_255_byte_fs_limit(self):
|
def test_every_component_stays_within_the_255_byte_fs_limit(self):
|
||||||
# A long accented channel AND title, plus the asset-id suffix, must not overflow any path
|
# A long accented channel AND title, plus the asset-id suffix, must not overflow any path
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.51.1",
|
||||||
|
date: "2026-07-22",
|
||||||
|
summary: "Follow-ups to the downloads update: cleaner titles and file names on re-download.",
|
||||||
|
fixes: [
|
||||||
|
"Re-downloading an item now also refreshes its title and saved file name — the “1.6M views · … |” social clutter that stuck to older downloads is cleared on re-download (both on the card and in the saved file name).",
|
||||||
|
"Downloaded file and folder names now contain only safe characters — no “!”, brackets, or other shell-unpleasant punctuation.",
|
||||||
|
"The app now cleans up after itself: stale leftover folders and images from a previous naming scheme (e.g. an old accented folder left behind by a re-download) are removed automatically.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.51.0",
|
version: "0.51.0",
|
||||||
date: "2026-07-22",
|
date: "2026-07-22",
|
||||||
|
|||||||
Reference in New Issue
Block a user