From 4fc55383e12471ce3a36d6d2a19cc870aec48de7 Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:28:05 -0500 Subject: [PATCH 1/3] Name the film when only its artwork was copied Sidecars fold into their parent video's row, but the parent was indexed from the current run only. A video already on cache needs no copy, so runs that touched only its artwork produced several unattributed rows: four "...-poster.jpg" lines with nothing naming the film. sibling_map covers every video with siblings, not just the ones a run touched, so the parent is known either way. Orphaned sidecars now gather under a header carrying that filename, in the completion banner and the Recent Activity feed alike. The header has no size, because the video did not move and a size there would say it did. A lone sidecar is left flat: its own filename already names the film, so a header would add a row without adding anything to read. --- tests/test_orphan_sidecar_grouping.py | 145 ++++++++++++++++++++++ web/services/operation_runner.py | 169 ++++++++++++++++++++------ 2 files changed, 280 insertions(+), 34 deletions(-) create mode 100644 tests/test_orphan_sidecar_grouping.py diff --git a/tests/test_orphan_sidecar_grouping.py b/tests/test_orphan_sidecar_grouping.py new file mode 100644 index 0000000..3837a66 --- /dev/null +++ b/tests/test_orphan_sidecar_grouping.py @@ -0,0 +1,145 @@ +"""Sidecars copied without their video still get grouped under it. + +Artwork and NFOs are frequently copied on their own, because the video is +already on cache and only its metadata changed. The sibling merge indexed +parents from the current run only, so those sidecars had nothing to fold into +and rendered as several unattributed rows — four "…-poster.jpg" lines with +nothing naming the film. + +The parent is known regardless (sibling_map covers every video with siblings, +not just the ones this run touched), so the group header can name it. The +header carries no size: the video did not move, and a size there would say it +did. +""" + +import sys +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +sys.modules.setdefault('fcntl', MagicMock()) +for _mod_name in ['plexapi', 'plexapi.server', 'plexapi.video', 'plexapi.myplex', + 'plexapi.exceptions', 'requests', 'apscheduler', + 'apscheduler.schedulers', 'apscheduler.schedulers.background', + 'apscheduler.triggers', 'apscheduler.triggers.cron', + 'apscheduler.triggers.interval']: + sys.modules.setdefault(_mod_name, MagicMock()) + +MOVIE = "Half Baked (1998) - [BLURAY-1080P][EAC3 5.1][X264][8Bit]-J3RICO.mkv" +STEM = "Half Baked (1998) - [BLURAY-1080P][EAC3 5.1][X264][8Bit]-J3RICO" + +_COMPATIBLE = { + "Restored": ("Restored", "Moved"), + "Moved": ("Restored", "Moved"), + "Cached": ("Cached",), +} + + +def _runner(files): + from web.services.operation_runner import OperationRunner + r = object.__new__(OperationRunner) + r._current_run_files = files + return r + + +def _sidecar(name, size="1.00 MB", action="Cached"): + return {"action": action, "filename": name, "size": size, "size_bytes": 1024} + + +def _parent_map(*sidecar_names, parent=MOVIE): + return {s: parent for s in sidecar_names} + + +class TestSidecarsWithoutTheirVideo: + + def test_grouped_under_a_header_naming_the_film(self): + files = [_sidecar(f"{STEM}{ext}") for ext in + ("-poster.jpg", "-fanart.jpg", "-clearlogo.png", ".nfo")] + runner = _runner(list(files)) + + runner._merge_run_files(_parent_map(*[f["filename"] for f in files]), _COMPATIBLE) + + assert len(runner._current_run_files) == 1, runner._current_run_files + header = runner._current_run_files[0] + assert header["filename"] == MOVIE + assert len(header["associated_files"]) == 4 + assert header["sidecars_only"] is True + + def test_header_carries_no_size(self): + """The video did not move; a size here would claim it did.""" + files = [_sidecar(f"{STEM}-poster.jpg"), _sidecar(f"{STEM}.nfo")] + runner = _runner(list(files)) + + runner._merge_run_files(_parent_map(*[f["filename"] for f in files]), _COMPATIBLE) + + header = runner._current_run_files[0] + assert header["size"] == "" + assert header["size_bytes"] == 0 + + def test_a_lone_sidecar_is_left_alone(self): + """Its own filename already names the film; a header adds a row, not information.""" + files = [_sidecar(f"{STEM}.nfo")] + runner = _runner(list(files)) + + runner._merge_run_files(_parent_map(f"{STEM}.nfo"), _COMPATIBLE) + + assert len(runner._current_run_files) == 1 + assert runner._current_run_files[0]["filename"] == f"{STEM}.nfo" + assert "associated_files" not in runner._current_run_files[0] + + def test_real_parent_still_wins_over_a_synthetic_one(self): + """When the video IS in the run, nothing changes.""" + video = {"action": "Cached", "filename": MOVIE, "size": "4.00 GB", + "size_bytes": 4 * 1024 ** 3} + files = [video] + [_sidecar(f"{STEM}-poster.jpg"), _sidecar(f"{STEM}.nfo")] + runner = _runner(list(files)) + + runner._merge_run_files( + _parent_map(f"{STEM}-poster.jpg", f"{STEM}.nfo"), _COMPATIBLE) + + assert len(runner._current_run_files) == 1 + header = runner._current_run_files[0] + assert header["size_bytes"] == 4 * 1024 ** 3, "the real video row must survive" + assert "sidecars_only" not in header + assert len(header["associated_files"]) == 2 + + def test_two_films_do_not_merge_into_one_group(self): + other = "Saltburn (2023) - [WEBDL-1080P][EAC3 ATMOS 5.1][H264][8Bit]-RE.mkv" + other_stem = other[:-4] + files = [_sidecar(f"{STEM}-poster.jpg"), _sidecar(f"{STEM}.nfo"), + _sidecar(f"{other_stem}-poster.jpg"), _sidecar(f"{other_stem}.nfo")] + mapping = {f"{STEM}-poster.jpg": MOVIE, f"{STEM}.nfo": MOVIE, + f"{other_stem}-poster.jpg": other, f"{other_stem}.nfo": other} + runner = _runner(list(files)) + + runner._merge_run_files(mapping, _COMPATIBLE) + + names = sorted(f["filename"] for f in runner._current_run_files) + assert names == sorted([MOVIE, other]), names + + def test_unrelated_rows_are_untouched(self): + unrelated = {"action": "Cached", "filename": "Troy (2004).mkv", + "size": "14.00 GB", "size_bytes": 14 * 1024 ** 3} + files = [unrelated, _sidecar(f"{STEM}-poster.jpg"), _sidecar(f"{STEM}.nfo")] + runner = _runner(list(files)) + + runner._merge_run_files( + _parent_map(f"{STEM}-poster.jpg", f"{STEM}.nfo"), _COMPATIBLE) + + assert unrelated in runner._current_run_files + assert len(runner._current_run_files) == 2 + + def test_actions_are_not_mixed_into_one_group(self): + """A cached sidecar and a restored one describe different events.""" + files = [_sidecar(f"{STEM}-poster.jpg", action="Cached"), + _sidecar(f"{STEM}.nfo", action="Cached"), + _sidecar(f"{STEM}-fanart.jpg", action="Restored")] + runner = _runner(list(files)) + + runner._merge_run_files( + _parent_map(f"{STEM}-poster.jpg", f"{STEM}.nfo", f"{STEM}-fanart.jpg"), + _COMPATIBLE) + + actions = sorted(f["action"] for f in runner._current_run_files) + assert actions == ["Cached", "Restored"], actions diff --git a/web/services/operation_runner.py b/web/services/operation_runner.py index 1549806..8296341 100644 --- a/web/services/operation_runner.py +++ b/web/services/operation_runner.py @@ -42,6 +42,11 @@ SETTINGS_FILE = CONFIG_SETTINGS_FILE +# Sidecars whose parent video is not in the run are gathered under a header row +# naming that video. Two is the threshold: grouping a lone sidecar would turn one +# row into two without telling the reader anything its own filename does not. +_ORPHAN_GROUP_MIN = 2 + class OperationState(str, Enum): """Operation states""" @@ -1187,25 +1192,67 @@ def _merge_sibling_activities(self, sibling_map: Dict[str, list]) -> None: parent_index[key] = i merged_indices: set = set() + orphans: Dict[tuple, list] = {} + orphan_key_at: Dict[int, tuple] = {} + for i, act in enumerate(activities): - if act.filename in sibling_to_parent: - parent_basename = sibling_to_parent[act.filename] - # Try compatible actions (e.g. Moved sibling → Restored parent) - compatible = _COMPATIBLE_ACTIONS.get(act.action, (act.action,)) - for try_action in compatible: - parent_key = (parent_basename, try_action) - if parent_key in parent_index: - parent_idx = parent_index[parent_key] - parent_act = activities[parent_idx] - parent_act.associated_files.append({ - "filename": act.filename, - "size": format_bytes(act.size_bytes) if act.size_bytes > 0 else "", - }) - merged_indices.add(i) - break + if act.filename not in sibling_to_parent: + continue + parent_basename = sibling_to_parent[act.filename] + # Try compatible actions (e.g. Moved sibling → Restored parent) + compatible = _COMPATIBLE_ACTIONS.get(act.action, (act.action,)) + merged = False + for try_action in compatible: + parent_key = (parent_basename, try_action) + if parent_key in parent_index: + parent_idx = parent_index[parent_key] + parent_act = activities[parent_idx] + parent_act.associated_files.append({ + "filename": act.filename, + "size": format_bytes(act.size_bytes) if act.size_bytes > 0 else "", + }) + merged_indices.add(i) + merged = True + break + if not merged: + key = (parent_basename, act.action) + orphans.setdefault(key, []).append(i) + orphan_key_at.setdefault(key, i) + + # Sidecars copied without their video (already on cache) have no parent + # to fold into. Give them one, so the feed names the film instead of + # listing loose artwork. + orphans = {k: v for k, v in orphans.items() if len(v) >= _ORPHAN_GROUP_MIN} + for idxs in orphans.values(): + merged_indices.update(idxs) if merged_indices: - activities = [a for i, a in enumerate(activities) if i not in merged_indices] + rebuilt: list = [] + emitted: set = set() + for i, act in enumerate(activities): + if i not in merged_indices: + rebuilt.append(act) + continue + for key, first_i in orphan_key_at.items(): + if key in orphans and first_i == i and key not in emitted: + emitted.add(key) + first = activities[i] + rebuilt.append(FileActivity( + timestamp=first.timestamp, + action=key[1], + filename=key[0], + size_bytes=0, # the video did not move + users=list(first.users), + run_id=first.run_id, + run_source=first.run_source, + associated_files=[{ + "filename": activities[j].filename, + "size": (format_bytes(activities[j].size_bytes) + if activities[j].size_bytes > 0 else ""), + } for j in orphans[key]], + )) + break + activities = rebuilt _save_activity_unlocked(activities) # Update in-memory list and merge _current_run_files for banner pill @@ -1229,26 +1276,80 @@ def _merge_run_files(self, sibling_to_parent: Dict[str, str], compatible_actions parent_index[key] = i merged_indices: set = set() + # Sidecars whose parent video is not in this run — see _group_orphan_sidecars. + orphans: Dict[tuple, list] = {} + orphan_key_at: Dict[int, tuple] = {} + for i, f in enumerate(self._current_run_files): - if f["filename"] in sibling_to_parent: - parent_basename = sibling_to_parent[f["filename"]] - compatible = compatible_actions.get(f["action"], (f["action"],)) - for try_action in compatible: - parent_key = (parent_basename, try_action) - if parent_key in parent_index: - parent_idx = parent_index[parent_key] - parent_entry = self._current_run_files[parent_idx] - if "associated_files" not in parent_entry: - parent_entry["associated_files"] = [] - parent_entry["associated_files"].append({ - "filename": f["filename"], - "size": f.get("size", ""), - }) - merged_indices.add(i) - break + if f["filename"] not in sibling_to_parent: + continue + parent_basename = sibling_to_parent[f["filename"]] + compatible = compatible_actions.get(f["action"], (f["action"],)) + merged = False + for try_action in compatible: + parent_key = (parent_basename, try_action) + if parent_key in parent_index: + parent_idx = parent_index[parent_key] + parent_entry = self._current_run_files[parent_idx] + if "associated_files" not in parent_entry: + parent_entry["associated_files"] = [] + parent_entry["associated_files"].append({ + "filename": f["filename"], + "size": f.get("size", ""), + }) + merged_indices.add(i) + merged = True + break + if not merged: + key = (parent_basename, f["action"]) + orphans.setdefault(key, []).append(f) + orphan_key_at.setdefault(key, i) - if merged_indices: - self._current_run_files = [f for i, f in enumerate(self._current_run_files) if i not in merged_indices] + orphans = {k: v for k, v in orphans.items() if len(v) >= _ORPHAN_GROUP_MIN} + for key, entries in orphans.items(): + for f in entries: + merged_indices.add(self._current_run_files.index(f)) + + if not merged_indices: + return + + rebuilt: list = [] + emitted: set = set() + for i, f in enumerate(self._current_run_files): + if i not in merged_indices: + rebuilt.append(f) + continue + for key, first_i in orphan_key_at.items(): + if key in orphans and first_i == i and key not in emitted: + emitted.add(key) + rebuilt.append(self._synthetic_sidecar_parent(key, orphans[key])) + break + self._current_run_files = rebuilt + + @staticmethod + def _synthetic_sidecar_parent(key: tuple, entries: list) -> dict: + """A header row for sidecars whose parent video was not part of this run. + + Artwork and NFOs are often copied on their own, because the video is + already on cache. Without a parent to fold into they rendered as + several unattributed rows — four "…-poster.jpg" lines with nothing + naming the film. This carries the parent's filename so the group reads + as what it is. + + No size on the header: the video did not move, and showing the sidecar + total there would imply it did. + """ + parent_basename, action = key + return { + "action": action, + "filename": parent_basename, + "size": "", + "size_bytes": 0, + "sidecars_only": True, + "associated_files": [ + {"filename": e["filename"], "size": e.get("size", "")} for e in entries + ], + } # Show-episode grouping helper lives in core.activity (shared with the # Recent Activity dashboard grouping service). Imported as From c1a3380fec6537e4c8401216e6003966d91fdd39 Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:44:04 -0500 Subject: [PATCH 2/3] Say when only a title's extras moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidecar group header names the video so its artwork can be attributed, but the video itself did not move. It was still rendering the sidecars' own action badge, so a green CACHED sat beside a .mkv filename and read as though the video had been copied — a clearer claim than the loose rows it replaced, and a wrong one. FileActivity carries sidecars_only through load, save and serialize, defaulting False so existing entries are unaffected. Both surfaces render a neutral tag for those rows: EXTRAS in the completion banner, "Extras only" in Recent Activity, each explaining in its tooltip that the video was already in place. --- core/activity.py | 9 ++++ tests/test_orphan_sidecar_grouping.py | 52 +++++++++++++++++++ web/services/operation_runner.py | 1 + .../components/global_operation_banner.html | 7 +++ web/templates/components/recent_activity.html | 4 +- 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/core/activity.py b/core/activity.py index c7a6c7e..a5165a4 100644 --- a/core/activity.py +++ b/core/activity.py @@ -123,6 +123,10 @@ class FileActivity: # (treated as legacy, bucketed by 15-min time windows by activity_grouping). run_id: Optional[str] = None run_source: str = "legacy" # "scheduled" | "web" | "cli" | "maintenance" | "legacy" + # True when this row is a header gathering a title's associated files and the + # video itself did not move. Without it the action badge sits beside a .mkv + # name and reads as though the video was copied. + sidecars_only: bool = False def to_dict(self, time_format: Optional[str] = None) -> dict: """Serialize for the dashboard/API. @@ -166,6 +170,8 @@ def to_dict(self, time_format: Optional[str] = None) -> dict: } if self.associated_files: result["associated_files"] = self.associated_files + if self.sidecars_only: + result["sidecars_only"] = True return result def _format_size(self, size_bytes: int) -> str: @@ -205,6 +211,7 @@ def _load_activity_unlocked() -> List[FileActivity]: associated_files=item.get('associated_files', []), run_id=item.get('run_id'), run_source=item.get('run_source', 'legacy'), + sidecars_only=item.get('sidecars_only', False), )) except (KeyError, ValueError): continue # Skip malformed entries @@ -243,6 +250,8 @@ def _save_activity_unlocked(activities: List[FileActivity]) -> None: entry['run_id'] = activity.run_id if activity.run_source and activity.run_source != "legacy": entry['run_source'] = activity.run_source + if activity.sidecars_only: + entry['sidecars_only'] = True data.append(entry) save_json_atomically(str(ACTIVITY_FILE), data, label="activity") diff --git a/tests/test_orphan_sidecar_grouping.py b/tests/test_orphan_sidecar_grouping.py index 3837a66..49e36c9 100644 --- a/tests/test_orphan_sidecar_grouping.py +++ b/tests/test_orphan_sidecar_grouping.py @@ -143,3 +143,55 @@ def test_actions_are_not_mixed_into_one_group(self): actions = sorted(f["action"] for f in runner._current_run_files) assert actions == ["Cached", "Restored"], actions + + +class TestTheHeaderDoesNotClaimTheVideoMoved: + """The header names a .mkv that was never copied — the badge must say so.""" + + def test_run_file_header_is_flagged(self): + files = [_sidecar(f"{STEM}-poster.jpg"), _sidecar(f"{STEM}.nfo")] + runner = _runner(list(files)) + + runner._merge_run_files(_parent_map(*[f["filename"] for f in files]), _COMPATIBLE) + + assert runner._current_run_files[0]["sidecars_only"] is True + + def test_activity_entries_round_trip_the_flag(self): + """It is persisted, so a reload must not turn it back into a Cached row.""" + from core.activity import FileActivity + + entry = FileActivity(timestamp=datetime.now(), action="Cached", + filename=MOVIE, size_bytes=0, sidecars_only=True) + + assert entry.to_dict().get("sidecars_only") is True + + def test_normal_rows_carry_no_flag(self): + from core.activity import FileActivity + + entry = FileActivity(timestamp=datetime.now(), action="Cached", + filename=MOVIE, size_bytes=1024) + + assert "sidecars_only" not in entry.to_dict() + + def test_banner_renders_a_neutral_tag(self): + """Not the green CACHED tag, which would claim the video was copied.""" + import pathlib, re + html = (pathlib.Path(__file__).resolve().parent.parent / "web" / "templates" / + "components" / "global_operation_banner.html").read_text(encoding="utf-8") + + assert "f.sidecars_only" in html + # Only the truthy branch — the else branch legitimately styles real rows. + branch = html[html.index("f.sidecars_only"):] + branch = branch[:branch.index("{% else %}")] + assert "EXTRAS" in branch + assert "di-action-tag--cached" not in branch, ( + "the sidecars-only header must not use the cached styling" + ) + + def test_activity_feed_renders_a_neutral_badge(self): + import pathlib + html = (pathlib.Path(__file__).resolve().parent.parent / "web" / "templates" / + "components" / "recent_activity.html").read_text(encoding="utf-8") + + assert "entry.sidecars_only" in html + assert "Extras only" in html diff --git a/web/services/operation_runner.py b/web/services/operation_runner.py index 8296341..ebdf936 100644 --- a/web/services/operation_runner.py +++ b/web/services/operation_runner.py @@ -1245,6 +1245,7 @@ def _merge_sibling_activities(self, sibling_map: Dict[str, list]) -> None: users=list(first.users), run_id=first.run_id, run_source=first.run_source, + sidecars_only=True, associated_files=[{ "filename": activities[j].filename, "size": (format_bytes(activities[j].size_bytes) diff --git a/web/templates/components/global_operation_banner.html b/web/templates/components/global_operation_banner.html index 35b74cf..52cf3c8 100644 --- a/web/templates/components/global_operation_banner.html +++ b/web/templates/components/global_operation_banner.html @@ -860,7 +860,14 @@
{%- for f in status.recent_files %}