Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions core/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
138 changes: 138 additions & 0 deletions tests/test_operation_eta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""The remaining-time estimate prices files as well as bytes.

Measured against a real run (2026-08-12, 199 files / 287.53 GB): at 14m 11s of
copying, 171.16 GB was done across 43 files. The old pure-byte model reported
9m 38s left; the run actually needed 11m 27s. It was optimistic because the 43
completed files averaged ~4 GB while the 156 remaining averaged ~0.75 GB, many
of them artwork and NFOs whose cost is per-file rather than per-byte.
"""

import sys
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())

GB = 1024 ** 3


def _estimate(**kw):
from web.services.operation_runner import OperationRunner
return OperationRunner._estimate_remaining_seconds(**kw)


class TestTheRealRun:
"""Numbers taken from the 08:30 run on 2026-08-12."""

ELAPSED = 14 * 60 + 11 # copying began 08:50:09, sampled 09:04:20
DONE = int(171.16 * GB)
REMAINING = int((287.53 - 171.16) * GB)
FILES_DONE = 43
FILES_LEFT = 156
ACTUAL_LEFT = 11 * 60 + 27 # finished 09:15:47

def test_pure_byte_rate_is_the_optimistic_baseline(self):
"""What the old model produced, kept as the thing to beat."""
rate = self.DONE / self.ELAPSED
naive = self.REMAINING / rate

assert 560 < naive < 600, naive # ~9m 38s
assert naive < self.ACTUAL_LEFT * 0.9 # meaningfully under

def test_per_file_term_closes_most_of_the_gap(self):
# A sustained rate slightly above the batch average, as a peak sample
# taken during large-file transfer would be.
peak = (self.DONE / self.ELAPSED) * 1.08

eta = _estimate(elapsed=self.ELAPSED, bytes_done=self.DONE,
bytes_remaining=self.REMAINING, files_done=self.FILES_DONE,
files_remaining=self.FILES_LEFT, peak_rate=peak)

naive = self.REMAINING / (self.DONE / self.ELAPSED)
assert eta > naive, "must be less optimistic than the pure byte model"
assert abs(eta - self.ACTUAL_LEFT) < abs(naive - self.ACTUAL_LEFT), (
f"estimate {eta:.0f}s should beat naive {naive:.0f}s "
f"against actual {self.ACTUAL_LEFT}s"
)


class TestBehaviour:

def test_a_tail_of_tiny_files_is_not_free(self):
"""500 sidecars totalling almost nothing still take time."""
eta = _estimate(elapsed=600, bytes_done=100 * GB, bytes_remaining=1024,
files_done=20, files_remaining=500, peak_rate=200 * 1024 ** 2)

assert eta > 30, f"a 500-file tail priced at {eta:.1f}s is not credible"

def test_matches_the_byte_model_when_no_per_file_cost_shows(self):
"""A perfectly byte-bound run should not be inflated."""
rate = 100 * 1024 ** 2
eta = _estimate(elapsed=100, bytes_done=100 * rate,
bytes_remaining=50 * rate, files_done=1,
files_remaining=1, peak_rate=rate)

assert eta == pytest.approx(50, rel=0.02)

def test_peak_rate_is_neutral_when_the_tail_mirrors_the_head(self):
"""Splitting one measured elapsed into two terms must not invent time.

With the remaining work shaped like the completed work, any split of
cost between bytes and files reproduces the same total. That the peak
rate changes nothing here is the model behaving.
"""
kw = dict(elapsed=100, bytes_done=10 * GB, bytes_remaining=10 * GB,
files_done=5, files_remaining=5)

assert _estimate(peak_rate=0, **kw) == pytest.approx(
_estimate(peak_rate=(10 * GB / 100) * 2, **kw))

def test_peak_rate_is_what_prices_a_small_file_tail(self):
"""Without it the per-file cost is invisible and the tail reads as free.

This is the case that motivated the change: bytes nearly exhausted,
hundreds of sidecars left. On the batch average alone the residual is
zero, so the estimate collapses to nothing.
"""
kw = dict(elapsed=100, bytes_done=10 * GB, bytes_remaining=1024,
files_done=5, files_remaining=500)

assert _estimate(peak_rate=0, **kw) == pytest.approx(0, abs=1)
assert _estimate(peak_rate=(10 * GB / 100) * 2, **kw) > 60

@pytest.mark.parametrize("kw", [
{"elapsed": 0, "bytes_done": 1024},
{"elapsed": 10, "bytes_done": 0},
])
def test_returns_none_with_nothing_to_measure(self, kw):
assert _estimate(bytes_remaining=1024, files_done=0, files_remaining=1,
peak_rate=0, **kw) is None

def test_zero_remaining_is_zero(self):
eta = _estimate(elapsed=100, bytes_done=10 * GB, bytes_remaining=0,
files_done=10, files_remaining=0, peak_rate=0)

assert eta == 0


class TestDisplayDenominatorsAgree:

def test_banner_leads_with_bytes_when_available(self):
"""The bar and ETA are byte-based; the headline number must match."""
import pathlib
html = (pathlib.Path(__file__).resolve().parent.parent / "web" / "templates" /
"components" / "global_operation_banner.html").read_text(encoding="utf-8")

# There are two progress-meta blocks; pick the operation one.
block = html[html.index("status.has_byte_progress"):]
block = block[:block.index("</div>")]
assert block.index("bytes_display") < block.index("completed_files"), (
"file count still leads, which is what made a correct ETA look wrong"
)
197 changes: 197 additions & 0 deletions tests/test_orphan_sidecar_grouping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""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


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
Loading
Loading