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
79 changes: 79 additions & 0 deletions tests/test_sync_to_array_newer_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,82 @@ def test_array_copy_same_size_deletes_cache_without_copy(tmp_path, max_workers):
mock_copy.assert_not_called()
assert array_file.read_bytes() == NEW
assert not cache_file.exists()


# ---------------------------------------------------------------------------
# The other direction: the array copy was changed after the cache copy.
#
# Caching keeps the original modification time on both sides (copystat on the
# cache copy, rename for the backup), so an untouched pair has matching mtimes.
# When a tool rewrites the array side directly, the cache copy is the stale one
# and must not be copied over it.
# ---------------------------------------------------------------------------

STALE_CACHE = b"stale copy left on cache" * 10
PROCESSED_ARRAY = b"processed on the array by another tool" * 10
BASE_TIME = 1_700_000_000


def _set_mtime(path, seconds):
os.utime(path, (seconds, seconds))


@pytest.mark.parametrize("max_workers", WORKERS)
@pytest.mark.parametrize("array_side", ["same-name", "backup"])
def test_newer_array_copy_is_left_alone(tmp_path, max_workers, array_side):
svc, cache_root, array_root = _make_service(tmp_path)
cache_file = cache_root / "Film (2020)" / "Film.mkv"
name = "Film.mkv" if array_side == "same-name" else "Film.mkv.plexcached"
array_side_file = array_root / "Film (2020)" / name
_write(cache_file, STALE_CACHE)
_write(array_side_file, PROCESSED_ARRAY)
_set_mtime(cache_file, BASE_TIME)
_set_mtime(array_side_file, BASE_TIME + 3600)

result = svc.sync_to_array([str(cache_file)], dry_run=False, max_workers=max_workers)

assert result.affected_count == 0
assert any("newer than the cache copy" in e for e in result.errors), result.errors
assert array_side_file.read_bytes() == PROCESSED_ARRAY
assert cache_file.read_bytes() == STALE_CACHE
assert not (array_root / "Film (2020)" / "Film.mkv.pc-part").exists()
if array_side == "backup":
assert not (array_root / "Film (2020)" / "Film.mkv").exists()


@pytest.mark.parametrize("max_workers", WORKERS)
def test_newer_cache_copy_still_replaces_array(tmp_path, max_workers):
"""Explicit mtimes for the #222 direction: the cache copy was rewritten later."""
svc, cache_root, array_root = _make_service(tmp_path)
cache_file = cache_root / "Film.mkv"
array_file = array_root / "Film.mkv"
_write(cache_file, NEW)
_write(array_file, OLD)
_set_mtime(array_file, BASE_TIME)
_set_mtime(cache_file, BASE_TIME + 3600)

result = svc.sync_to_array([str(cache_file)], dry_run=False, max_workers=max_workers)

assert result.affected_count == 1, result.errors
assert array_file.read_bytes() == NEW
assert not cache_file.exists()


@pytest.mark.parametrize("max_workers", WORKERS)
@pytest.mark.parametrize("array_offset", [0, 1], ids=["same-mtime", "within-tolerance"])
def test_matching_mtimes_keep_the_cache_version(tmp_path, max_workers, array_offset):
"""A tool that keeps the original mtime gives no signal; the cache copy wins as in #222."""
svc, cache_root, array_root = _make_service(tmp_path)
cache_file = cache_root / "Film.mkv"
backup = array_root / "Film.mkv.plexcached"
_write(cache_file, NEW)
_write(backup, OLD)
_set_mtime(cache_file, BASE_TIME)
_set_mtime(backup, BASE_TIME + array_offset)

result = svc.sync_to_array([str(cache_file)], dry_run=False, max_workers=max_workers)

assert result.affected_count == 1, result.errors
assert (array_root / "Film.mkv").read_bytes() == NEW
assert not backup.exists()
assert not cache_file.exists()
30 changes: 29 additions & 1 deletion web/services/maintenance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,26 @@ def _array_copy_matches(self, cache_path: str, array_path: str) -> Optional[str]
return candidate
return None

# Filesystems that store modification times coarsely (FAT keeps 2 seconds)
# can make an untouched copy look slightly newer; ignore differences this small.
_NEWER_MTIME_TOLERANCE_SECONDS = 2

def _newer_array_copy(self, cache_path: str, array_path: str) -> Optional[str]:
"""Return the array-side file that was modified after the cache copy, if any.

Caching keeps the original modification time (copystat on the cache
copy, rename for the .plexcached backup), so an untouched pair has
matching mtimes and whichever side a tool rewrote is the newer one.
A newer array copy means the cache copy is stale and must not be
copied over it.
"""
cache_mtime = os.path.getmtime(cache_path)
for candidate in (array_path, array_path + PLEXCACHED_EXTENSION):
if (os.path.exists(candidate)
and os.path.getmtime(candidate) > cache_mtime + self._NEWER_MTIME_TOLERANCE_SECONDS):
return candidate
return None

def _sync_one_to_array(self, cache_path: str, array_path: str,
bytes_progress_callback: Optional[Callable] = None) -> Optional[str]:
"""Move one cache file to the array. Returns an error message, or None on success.
Expand Down Expand Up @@ -1588,6 +1608,11 @@ def _sync_one_to_array(self, cache_path: str, array_path: str,
os.remove(cache_path)
return None

if self._newer_array_copy(cache_path, array_path) is not None:
logging.warning(f"Array copy is newer than the cache copy, leaving both in place: "
f"{os.path.basename(cache_path)}")
return "Array copy is newer than the cache copy, left both in place for review"

if os.path.exists(array_path) or os.path.exists(backup_path):
logging.info(f"Cache copy differs from the array copy, keeping the cache version: "
f"{os.path.basename(cache_path)}")
Expand Down Expand Up @@ -1622,6 +1647,8 @@ def sync_to_array(self, paths: List[str], dry_run: bool = True,
For each file (see ``_sync_one_to_array``):
- If the array already holds an identical copy (.plexcached backup or
same-name file of the same size): restore it, delete cache copy
- If the array copy was modified after the cache copy, the cache copy
is stale: leave both in place and report it for review
- Otherwise the cache copy is the version to keep (a Sonarr/Radarr
upgrade, a Tdarr pass, or no array copy at all): copy it to the
array, verify, then remove the old backup and the cache copy
Expand All @@ -1637,7 +1664,8 @@ def sync_to_array(self, paths: List[str], dry_run: bool = True,
array_path = self._cache_to_array_path(cache_path)
if array_path:
try:
if self._array_copy_matches(cache_path, array_path) is None:
if (self._array_copy_matches(cache_path, array_path) is None
and self._newer_array_copy(cache_path, array_path) is None):
total_bytes += os.path.getsize(cache_path)
except OSError:
pass
Expand Down
3 changes: 2 additions & 1 deletion web/templates/maintenance/partials/audit_results.html
Original file line number Diff line number Diff line change
Expand Up @@ -919,7 +919,7 @@ <h4 style="margin: 0 0 0.5rem 0; color: var(--plex-orange);">
whatWillHappen = `
<strong style="color: var(--warning);">What will happen:</strong>
<ul style="margin: 0.5rem 0 0 1.5rem; padding: 0;">
<li><strong>${hasBackupCount}</strong> file(s) with existing backup/copy: Restore the array copy if it matches, otherwise copy the newer cache version over it, then delete cache copy</li>
<li><strong>${hasBackupCount}</strong> file(s) with existing backup/copy: Restore the array copy if it matches, otherwise copy the cache version over it, then delete cache copy. If the array copy is the newer one, both are left in place for review</li>
<li><strong>${noCopyCount}</strong> file(s) without backup: Copy to array first, then delete cache copy</li>
</ul>
`;
Expand All @@ -929,6 +929,7 @@ <h4 style="margin: 0 0 0.5rem 0; color: var(--plex-orange);">
<ol style="margin: 0.5rem 0 0 1.5rem; padding: 0;">
<li>If the array copy matches the cache copy, restore it (rename <code>.plexcached</code> back to original)</li>
<li>If the cache copy differs, it is the newer version: copy it to the array and verify</li>
<li>If the array copy was changed after the cache copy, leave both in place and report it for review</li>
<li>Delete the cache copy</li>
</ol>
`;
Expand Down
Loading