fix(downloads): shell-safe names, refresh display_name on re-download, GC orphan sweep

Follow-up to user prod testing of 0.51.0:
- re-download now clears job.display_name so the card title + Content-Disposition filename are
  re-derived from the freshly-cleaned title (previously an auto-name from an older un-cleaned
  title — still carrying a "… views · … reactions |" prefix — survived the rebuild).
- on-disk AND served names are now a strict shell-safe slug: only [A-Za-z0-9._-] survive; every
  other char (the "!" the user saw, plus ( ) [ ] & $ ' " ; | ? * …) collapses to "_". The id no
  longer uses [brackets] (glob metacharacters). Shared _slug helper for sanitize + display_filename.
- GC gains a 5th pass: sweep on-disk files/dirs no ready asset owns (old accented channel dirs, a
  poster from a superseded naming scheme left after a re-download), skipping the dot system trees
  and anything newer than an hour so an in-flight download is never touched.
- Tests: slug strips !/$/()[]; rel_path is bracket-free; orphan-sweep keeps owned/system/fresh files.
This commit is contained in:
2026-07-22 03:18:14 +02:00
parent e21c6f6b92
commit fa35925cc6
6 changed files with 196 additions and 60 deletions
+13 -13
View File
@@ -23,19 +23,19 @@ class TestSanitize:
for bad in '/\\:*?"<>|🔥':
assert bad not in out
def test_repeated_punctuation_collapses(self):
assert sanitize("wow!!!___---end") == "wow!_-end"
def test_unsafe_punctuation_is_stripped_to_a_single_separator(self):
# 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):
assert len(sanitize("x" * 500, limit=10)) <= 10
def test_cap_is_by_bytes_not_characters(self):
# Accented (2-byte UTF-8) letters must not blow past the byte limit — the FS component cap
# is 255 BYTES, so a char-only cap would overflow for multi-byte titles.
def test_cap_stays_within_the_byte_limit(self):
# Names are folded to ASCII, so a char cap is also the byte cap — accented input can't
# overflow the 255-BYTE FS component limit.
out = sanitize("á" * 500, limit=10)
assert 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")
assert out.isascii() and len(out.encode("utf-8")) <= 10
def test_empty_or_all_junk_falls_back_to_untitled(self):
assert sanitize("") == "untitled"
@@ -51,11 +51,11 @@ class TestRelPath:
)
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):
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):
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)
b = rel_path(self.meta, "mp4", asset_id=6)
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).
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") == "Cool_Clip_abc123.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):
# A long accented channel AND title, plus the asset-id suffix, must not overflow any path