fix(downloads): keep sidecar basenames intact when the title contains a dot

write_sidecars / delete_asset_files computed `base = media.with_suffix("")` and then
`base.with_suffix(".nfo")`. Stripping the real `.mp4` first leaves a stem whose own last dot may
be inside the title (e.g. "Sonka Házilag."), which `with_suffix` then treats as the extension and
clobbers — writing the .nfo/.jpg under a TRUNCATED basename that no longer matches the media, so
Plex/Kodi never associate them (and delete_asset_files targets the wrong name, leaking orphans).
Swap the extension on the FULL media path via a shared `_sidecar` helper instead.

Pre-existing bug (predates the source-adapters work); found during its E2E and folded in here.
Verified end-to-end: a dotted-title Facebook reel now writes a single shared basename.
This commit is contained in:
2026-08-13 00:47:51 +02:00
parent 8e4dee1bb6
commit d4dca46d9f
2 changed files with 34 additions and 5 deletions
+11 -5
View File
@@ -181,16 +181,23 @@ def _nfo_xml(meta: MediaMeta) -> str:
return "\n".join(parts)
def _sidecar(media: Path, ext: str) -> Path:
"""The sibling sidecar path (`.nfo` / `.jpg`) for a media file. Swaps the extension on the FULL
media path — NOT via `media.with_suffix("")` first: stripping the real extension leaves a stem
whose OWN last dot may be inside the title (e.g. "Sonka Házilag."), which `with_suffix` would
then read as the extension and clobber, orphaning the sidecar under a truncated name."""
return media.with_suffix(ext)
def write_sidecars(
download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None
) -> bool:
"""Write `<basename>.nfo` + `<basename>.jpg` next to the media file. Returns True if written."""
media = abs_path(download_root, rel)
base = media.with_suffix("")
try:
base.with_suffix(".nfo").write_text(_nfo_xml(meta), encoding="utf-8")
_sidecar(media, ".nfo").write_text(_nfo_xml(meta), encoding="utf-8")
if thumb_src and thumb_src.exists():
shutil.copyfile(str(thumb_src), str(base.with_suffix(".jpg")))
shutil.copyfile(str(thumb_src), str(_sidecar(media, ".jpg")))
return True
except OSError:
return False
@@ -199,8 +206,7 @@ def write_sidecars(
def delete_asset_files(download_root: str, rel: str) -> None:
"""Remove the media file and its sidecars; prune now-empty parent dirs (best effort)."""
media = abs_path(download_root, rel)
base = media.with_suffix("")
for p in (media, base.with_suffix(".nfo"), base.with_suffix(".jpg")):
for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")):
try:
p.unlink()
except OSError:
+23
View File
@@ -147,3 +147,26 @@ def test_nfo_uniqueid_type_reflects_the_source():
base = dict(video_id="v1", title="t", uploader="c", upload_date=None)
assert '<uniqueid type="youtube"' in _nfo_xml(MediaMeta(**base)) # default preserved
assert '<uniqueid type="facebook"' in _nfo_xml(MediaMeta(**base, uniqueid_type="facebook"))
def test_sidecars_keep_the_full_basename_when_the_title_has_a_dot(tmp_path: Path):
# A title ending in a period ("Sonka Házilag.") puts a dot in the media slug; the .nfo/.jpg
# sidecars must sit on the SAME (full) basename, not be truncated at that dot (which orphaned
# them from the media so Plex/Kodi never matched them).
from app.downloads.storage import write_sidecars
stem = "Chan_-_2026-08-12_-_Sonka_Hazilag._Villam_konyha_abc_a5"
rel = f"Chan/Season_2026/{stem}.mp4"
media = tmp_path / rel
media.parent.mkdir(parents=True)
media.write_text("x")
thumb = tmp_path / "t.jpg"
thumb.write_bytes(b"\xff\xd8\xff")
meta = MediaMeta(video_id="abc", title="Sonka Házilag. | Villám konyha", uploader="Chan",
upload_date=None)
assert write_sidecars(str(tmp_path), rel, meta, thumb) is True
names = sorted(p.name for p in media.parent.iterdir())
assert f"{stem}.nfo" in names, names
assert f"{stem}.jpg" in names, names
assert "Chan_-_2026-08-12_-_Sonka_Hazilag.nfo" not in names # the old truncated orphan