From 0317f53b1e73fb4b2067e28698d3cc249d37b72f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 21:47:45 +0200 Subject: [PATCH] fix(downloads): disambiguate the media path by asset id (C-3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A MediaAsset is unique on (source_kind, source_ref, format_sig), so the same video in two formats is two assets — but rel_path keyed only on video_id, so they computed the same path: the second download silently overwrote the first, and deleting either removed the shared file. rel_path now takes an optional asset_id and appends "_a{id}" (which Plex ignores — the .nfo sidecar carries the metadata); the worker passes the asset id. Only affects NEW downloads (existing assets keep their stored rel_path). Covered by a new test (two ids → two paths; omitted → the plain name, unchanged). --- backend/app/downloads/storage.py | 15 +++++++++++---- backend/tests/test_storage.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/app/downloads/storage.py b/backend/app/downloads/storage.py index 1aee7b8..d8e1279 100644 --- a/backend/app/downloads/storage.py +++ b/backend/app/downloads/storage.py @@ -74,16 +74,23 @@ def display_filename(name: str) -> str: return text or "download" -def rel_path(meta: MediaMeta, ext: str, layout: str = "plex") -> str: - """Path of the media file relative to DOWNLOAD_ROOT (forward slashes).""" +def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | None = None) -> str: + """Path of the media file relative to DOWNLOAD_ROOT (forward slashes). + + `asset_id` disambiguates the file. A MediaAsset is unique on (source_kind, source_ref, + format_sig), so the SAME video downloaded in two formats is two distinct assets — but without + the id they compute the identical path, so the second silently overwrites the first and deleting + 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.""" title = sanitize(meta.title) vid = meta.video_id + disc = f"_a{asset_id}" if asset_id is not None else "" if layout == "flat": - return f"{title}_[{vid}].{ext}" + return f"{title}_[{vid}]{disc}.{ext}" channel = sanitize(meta.uploader or "Unknown_Channel", 80) year = meta.upload_date.year if meta.upload_date else 0 d = meta.upload_date.isoformat() if meta.upload_date else "0000-00-00" - epname = f"{channel}_-_{d}_-_{title}_[{vid}].{ext}" + epname = f"{channel}_-_{d}_-_{title}_[{vid}]{disc}.{ext}" return f"{channel}/Season_{year}/{epname}" diff --git a/backend/tests/test_storage.py b/backend/tests/test_storage.py index ab2c8e7..f1ce671 100644 --- a/backend/tests/test_storage.py +++ b/backend/tests/test_storage.py @@ -54,6 +54,16 @@ class TestRelPath: out = rel_path(meta, "mp4") assert "Season_0/" in out and "0000-00-00" in out + def test_asset_id_disambiguates_two_formats_of_the_same_video(self): + # Same video, two assets (e.g. 1080p vs 720p) must NOT collide on one path. + 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") + # 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" + class TestDownloadFilename: def test_appends_the_container_extension(self):