diff --git a/core/app.py b/core/app.py index dcadb59..4fc838a 100644 --- a/core/app.py +++ b/core/app.py @@ -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) diff --git a/core/file_operations.py b/core/file_operations.py index 0eb1c00..797c0d2 100644 --- a/core/file_operations.py +++ b/core/file_operations.py @@ -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 @@ -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, @@ -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: @@ -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 @@ -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 @@ -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}") @@ -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). @@ -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) diff --git a/tests/test_year_folder_moveback.py b/tests/test_year_folder_moveback.py new file mode 100644 index 0000000..fbf0c8c --- /dev/null +++ b/tests/test_year_folder_moveback.py @@ -0,0 +1,141 @@ +"""Move-back decisions for movies stored in year folders (#232). + +Layout: media/Film/2012/.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"]