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
13 changes: 13 additions & 0 deletions core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3500,6 +3500,19 @@ def _check_files_to_move_back_to_array(self):
current_pinned_cache_paths=self.pinned_paths_cache,
)

# Anything this run is keeping on cache (OnDeck, Watchlist, pinned and
# their sidecars) stays put. filter_files() would skip it later anyway,
# but by then it had been counted as a restore and reported as a
# failed move every run (#232). The two lists are index-aligned.
active = set(self.all_active_media)
if active and files_to_move_back:
keep = [f in active for f in files_to_move_back]
if any(keep):
for f in (f for f, k in zip(files_to_move_back, keep) if k):
logging.debug(f"Still needed this run, not moving back: {os.path.basename(f)}")
move_back_exclude_paths = [p for p, k in zip(move_back_exclude_paths, keep) if not k]
files_to_move_back = [f for f, k in zip(files_to_move_back, keep) if not k]

if files_to_move_back:
logging.debug(f"Found {len(files_to_move_back)} files to move back to array")
self.media_to_array.extend(files_to_move_back)
Expand Down
51 changes: 36 additions & 15 deletions core/file_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -3729,7 +3729,7 @@ def _build_needed_media_sets(self, current_ondeck_items: Set[str],
tv_show_needed_episodes[show_name][season_num].add(episode_num)
continue
if media_type == "movie":
media_name = self._extract_media_name(item)
media_name = self._extract_media_name(item, known_movie=True)
if media_name:
needed_movies.add(media_name)
continue
Expand Down Expand Up @@ -3784,6 +3784,13 @@ def _is_tv_episode_still_needed(self, show_name: str, season_num: int, episode_n
logging.debug(f"TV episode not needed (S{season_num:02d}E{episode_num:02d}): {show_name}")
return False

def _cache_to_real_path(self, cache_path: str) -> Optional[str]:
"""Real (array) path for a container cache path, or None if unmapped."""
if self.path_modifier:
real_path, _ = self.path_modifier.convert_cache_to_real(cache_path)
return real_path
return cache_path.replace(self.cache_dir, self.real_source, 1)

def get_files_to_move_back_to_array(self, current_ondeck_items: Set[str],
current_watchlist_items: Set[str],
files_to_skip: Optional[Set[str]] = None,
Expand Down Expand Up @@ -3857,9 +3864,22 @@ def get_files_to_move_back_to_array(self, current_ondeck_items: Set[str],
logging.debug(f"Keeping pinned file in cache: {os.path.basename(cache_file)}")
continue

# Try stored metadata first for classification (check_path matches timestamp tracker keys)
# Real path for this cache file. The OnDeck/Watchlist sets and the
# OnDeck tracker are keyed by real paths, so a file still on
# someone's list is kept by exact path before any name matching
# (#232: movies in year folders, e.g. Film/2012/, were read as
# "Film, season 2012" by the folder-name fallback).
real_path = self._cache_to_real_path(check_path)
if real_path and (real_path in current_ondeck_items or real_path in current_watchlist_items):
logging.debug(f"Still on OnDeck/Watchlist, keeping in cache: {os.path.basename(cache_file)}")
continue

# Try stored metadata first for classification: real path first
# (OnDeck tracker, media_info_map), then the cache path
# (timestamp tracker keys)
tv_info = None
lookup = self._lookup_media_info(check_path)
media_type = None
lookup = (self._lookup_media_info(real_path) if real_path else None) or self._lookup_media_info(check_path)
if lookup:
media_type, ep_info = lookup
if media_type == "episode" and ep_info:
Expand All @@ -3870,7 +3890,7 @@ def get_files_to_move_back_to_array(self, current_ondeck_items: Set[str],
tv_info = (show, season, episode)

# Fallback to regex if no stored metadata
if tv_info is None:
if tv_info is None and media_type != "movie":
tv_info = self._extract_tv_info(cache_file)

# Determine if file should be kept
Expand All @@ -3880,7 +3900,7 @@ def get_files_to_move_back_to_array(self, current_ondeck_items: Set[str],
continue
media_name = show_name
else:
media_name = self._extract_media_name(cache_file)
media_name = self._extract_media_name(cache_file, known_movie=(media_type == "movie"))
if media_name is None:
logging.warning(f"Could not extract media name from path: {cache_file}")
continue
Expand Down Expand Up @@ -3918,14 +3938,11 @@ def get_files_to_move_back_to_array(self, current_ondeck_items: Set[str],
except OSError as e:
logging.debug(f"Could not check hard link count for {cache_file}: {e}")

# Move file back to array (use container path for path conversion)
if self.path_modifier:
array_file, _ = self.path_modifier.convert_cache_to_real(check_path)
if array_file is None:
logging.warning(f"Could not convert cache path to array path: {cache_file}")
continue
else:
array_file = check_path.replace(self.cache_dir, self.real_source, 1)
# Move file back to array (real path computed from the container path above)
array_file = real_path
if array_file is None:
logging.warning(f"Could not convert cache path to array path: {cache_file}")
continue

display_name = self._extract_display_name(cache_file)
logging.debug(f"Media no longer needed, will move back to array: {display_name} - {cache_file}")
Expand Down Expand Up @@ -4064,7 +4081,7 @@ def _extract_tv_info(self, file_path: str) -> Optional[Tuple[str, int, int]]:
except Exception:
return None

def _extract_media_name(self, file_path: str) -> Optional[str]:
def _extract_media_name(self, file_path: str, known_movie: bool = False) -> Optional[str]:
"""
Extract a comparable media identifier from a file path.
- For TV shows: returns show name (episode comparison handled separately).
Expand All @@ -4079,13 +4096,17 @@ def _extract_media_name(self, file_path: str) -> Optional[str]:
artwork/NFO/subtitle siblings in lockstep so they share an identifier
and the 'still needed in OnDeck' lookup matches for all of them, not
just the primary video.

known_movie: Plex metadata says this is a movie, so skip the TV folder
check. Otherwise a numbers-only folder (Film/2012/...) reads as a
season and every movie in those folders shares the name "Film".
"""
try:
normalized_path = os.path.normpath(file_path)
path_parts = normalized_path.split(os.sep)

# Check if this is a TV show
for i, part in enumerate(path_parts):
for i, part in enumerate([] if known_movie else path_parts):
if (
re.match(r'^(Season|Series)\s*\d+', part, re.IGNORECASE)
or re.match(r'^\d+$', part)
Expand Down
141 changes: 141 additions & 0 deletions tests/test_year_folder_moveback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Move-back decisions for movies stored in year folders (#232).

Layout: media/Film/2012/<movie>.mkv. The folder-name fallback reads a
numbers-only folder as a TV season, so a movie still on a user's OnDeck
became "Film, season 2012, episode 4" (the "e 4" in "glaciale 4"), was queued
for move-back, then kept by the later filter and reported as a failed move on
every run. In the other direction every movie in those folders shared the
name "Film", so one OnDeck movie kept every watched one on cache.
"""

import os
import sys
import types

import pytest

for _mod in ("fcntl", "pwd", "grp"):
sys.modules.setdefault(_mod, types.ModuleType(_mod))

from core.file_operations import ( # noqa: E402
CacheTimestampTracker,
FileFilter,
OnDeckTracker,
WatchlistTracker,
)

ONDECK_NAME = "L'era glaciale 4 - Continenti alla deriva (2012) [IT+EN] [Bluray-1080p x265] [DTS 5.1].mkv"
WATCHED_NAME = "Inception (2010) [Bluray-1080p].mkv"


@pytest.fixture
def env(tmp_path):
cache_dir = tmp_path / "cache"
array_dir = tmp_path / "array"
ondeck_cache = cache_dir / "Film" / "2012" / ONDECK_NAME
watched_cache = cache_dir / "Film" / "2010" / WATCHED_NAME
for f in (ondeck_cache, watched_cache):
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(b"\0" * 1024)

exclude_file = tmp_path / "exclude.txt"
exclude_file.write_text(f"{ondeck_cache}\n{watched_cache}\n", encoding="utf-8")

# "pre-existing" entries carry no media_type, which is what sends the
# classification to the folder-name fallback.
ts = CacheTimestampTracker(str(tmp_path / "timestamps.json"))
ts.record_cache_time(str(ondeck_cache), source="pre-existing")
ts.record_cache_time(str(watched_cache), source="pre-existing")

ondeck_real = str(array_dir / "Film" / "2012" / ONDECK_NAME)
od = OnDeckTracker(str(tmp_path / "ondeck.json"))
od.update_entry(ondeck_real, "Unicorno")

ff = FileFilter(
real_source=str(array_dir),
cache_dir=str(cache_dir),
is_unraid=False,
mover_cache_exclude_file=str(exclude_file),
timestamp_tracker=ts,
cache_retention_hours=0,
ondeck_tracker=od,
watchlist_tracker=WatchlistTracker(str(tmp_path / "watchlist.json")),
)
ff.set_media_info_map({ondeck_real: {"media_type": "movie", "episode_info": None}})
return {
"ff": ff,
"ondeck_real": ondeck_real,
"ondeck_cache": str(ondeck_cache),
"watched_cache": str(watched_cache),
"watched_real": str(array_dir / "Film" / "2010" / WATCHED_NAME),
}


def _move_back(env):
return env["ff"].get_files_to_move_back_to_array({env["ondeck_real"]}, set(), files_to_skip=set())


def test_ondeck_movie_in_year_folder_is_kept(env):
to_move, _, exclude_paths = _move_back(env)
assert env["ondeck_real"] not in to_move
assert env["ondeck_cache"] not in exclude_paths


def test_watched_movie_in_year_folder_is_moved_back(env):
"""Another movie on OnDeck must not keep this one on cache."""
to_move, _, exclude_paths = _move_back(env)
assert env["watched_real"] in to_move
assert env["watched_cache"] in exclude_paths


def test_ondeck_movie_kept_by_metadata_without_exact_path(env):
"""Same file reached through a different real path spelling still matches by name."""
ff = env["ff"]
other_real = env["ondeck_real"].replace(os.sep + "array" + os.sep, os.sep + "array2" + os.sep)
ff.set_media_info_map({other_real: {"media_type": "movie", "episode_info": None},
env["ondeck_real"]: {"media_type": "movie", "episode_info": None}})
to_move, _, _ = ff.get_files_to_move_back_to_array({other_real}, set(), files_to_skip=set())
assert env["ondeck_real"] not in to_move


def test_known_movie_name_ignores_year_folder():
ff = FileFilter.__new__(FileFilter)
path = os.path.join(os.sep, "media", "Film", "2012", ONDECK_NAME)
assert ff._extract_media_name(path) == "Film" # unchanged fallback
assert ff._extract_media_name(path, known_movie=True) != "Film"


def test_tv_numeric_season_folders_still_work():
"""Date-based shows use numbers-only season folders; keep reading them as TV."""
ff = FileFilter.__new__(FileFilter)
path = os.path.join(os.sep, "tv", "Daily Show", "2012", "Daily Show - S2012E05.mkv")
assert ff._extract_tv_info(path) == ("Daily Show", 2012, 5)


# -- app-level guard -------------------------------------------------------------

def test_files_kept_this_run_are_never_queued_for_move_back(monkeypatch):
"""Anything in all_active_media is dropped from the move-back lists, so it is
neither reported as a restore nor counted as a failed array move."""
from unittest.mock import MagicMock
from core.app import PlexCacheApp

app = PlexCacheApp.__new__(PlexCacheApp)
app.ondeck_items = set()
app.watchlist_items = set()
app.files_to_skip = []
app.pinned_paths_cache = set()
app.media_to_array = []
app.dry_run = True
app.all_active_media = ["/mnt/user/media/Film/2012/kept.mkv"]
app.file_filter = MagicMock()
app.file_filter.get_files_to_move_back_to_array.return_value = (
["/mnt/user/media/Film/2012/kept.mkv", "/mnt/user/media/Film/2010/watched.mkv"],
[],
["/mnt/cache/media/Film/2012/kept.mkv", "/mnt/cache/media/Film/2010/watched.mkv"],
)

app._check_files_to_move_back_to_array()

assert app.media_to_array == ["/mnt/user/media/Film/2010/watched.mkv"]
assert app._move_back_exclude_paths == ["/mnt/cache/media/Film/2010/watched.mkv"]
Loading