From 9afb34fe6d37b42ca7ae5306f5b7eaee613849e6 Mon Sep 17 00:00:00 2001 From: nickwolf Date: Wed, 15 Jul 2026 16:25:29 -0600 Subject: [PATCH 1/7] fix(cache): stream library refresh instead of loading every episode at once refresh_library_cache() iterated over PlexServer.episodes(), which materializes every episode of every non-ignored show library into a single list before the loop body runs. Peak memory therefore scales with library size. On a 65,906-episode library this exceeds 1GB and the process is OOM-killed part way through the refresh, before it can complete. Because refresh_library_on_scan defaults to true, every Plex library scan retriggers it, so the container ends up in a restart loop (observed: 40 OOM kills in one day, 228 restarts). Raising the memory limit only moves the ceiling, as the allocation grows with the library. Add UnprivilegedPlexServer.iter_episodes(), which pages through each show section and yields each page before requesting the next, so pages are released as they are consumed. episodes() is kept as a thin list(iter_episodes()) wrapper for compatibility. Notes on the implementation: - maxresults is required, not redundant. Without it fetchItems() paginates internally up to the section's total size and returns it in one list, which would silently reinstate the behaviour being fixed. - The cursor advances by len(page) and stops only on an empty page. Advancing by a fixed page_size would leave a permanent gap if a short page were ever returned mid-section, and a skipped episode is not benign: it is evicted from episode_parts, so the next refresh treats it as newly added and re-applies the show's track selection over every user's manual choice. - Results are sorted by addedAt so episodes imported while the scan is running sort after the cursor, rather than shifting the offsets of unread pages. The refresh runs precisely because content was just added, so this window is real. - The unprivileged branch previously issued a library-wide query; episodes only exist in show sections, so iterating those covers the same items without asking the server for everything at once. refresh_library_cache() additionally skips collecting added/updated when the cache is cold. There is nothing to diff against, so every episode would be reported as "added" and retained to build a list that the only cold-cache caller discards. Measured against a 65,906-episode library (plexapi 4.18.1): before: OOM-killed, >1024MB, refresh never completed after: 92MB peak RSS, 65,906/65,906 episodes, no duplicates, completes in 129s The remaining growth is the episode_parts dict of part-key strings (~0.3KB per episode), not the Episode objects. --- plex_auto_languages/plex_server.py | 69 +++++++++++++++++++----- plex_auto_languages/plex_server_cache.py | 18 ++++++- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/plex_auto_languages/plex_server.py b/plex_auto_languages/plex_server.py index cae4e46..3fa2981 100644 --- a/plex_auto_languages/plex_server.py +++ b/plex_auto_languages/plex_server.py @@ -5,7 +5,7 @@ import re import concurrent.futures from urllib.parse import urlparse -from typing import Union, Callable, List, Tuple, Optional +from typing import Union, Callable, Iterator, List, Tuple, Optional from datetime import datetime, timedelta from requests import ConnectionError as RequestsConnectionError from urllib3.exceptions import InsecureRequestWarning @@ -162,24 +162,69 @@ def fetch_item(self, item_id: Union[str, int]) -> Optional[object]: except NotFound: return None + def iter_episodes(self, page_size: int = 1000) -> Iterator[Episode]: + """ + Iterate over all episodes from non-ignored libraries, one page at a time. + + Each page is released once consumed, so peak memory stays flat regardless + of library size. Prefer this over :meth:`episodes` when episodes are + consumed one at a time, as that method holds the whole library at once. + + Args: + page_size (int): Number of episodes to request per page. + + Yields: + Episode: Episodes from non-ignored Plex libraries. + """ + if hasattr(self, 'should_ignore_library'): + # For PlexServer instances that have the should_ignore_library method + sections = self.get_show_sections() + else: + # For UnprivilegedPlexServer instances or fallback. Episodes only ever + # live in show sections, so this covers the same items as the previous + # library-wide query without requesting the whole library at once. + sections = [s for s in self._plex.library.sections() if isinstance(s, ShowSection)] + + for section in sections: + container_start = 0 + while True: + # `maxresults` is required, not redundant: without it fetchItems() + # paginates internally up to the section's total size and returns + # the whole thing in one list, which is the behaviour this method + # exists to avoid. Capping it to one page keeps this loop in charge. + # Sorting by addedAt keeps episodes imported mid-scan after the + # cursor, so they are picked up at the tail rather than shifting + # the offsets of pages that have not been read yet. + page = section.search( + libtype="episode", + sort="addedAt:asc", + container_start=container_start, + container_size=page_size, + maxresults=page_size + ) + if not page: + break + yield from page + # Advance by what was actually returned rather than by page_size: + # a short page mid-section would otherwise leave a permanent gap, + # and a skipped episode is not harmless here. It gets evicted from + # episode_parts, so the next refresh sees it as newly added and + # re-applies the show's track selection over every user's manual + # choice. + container_start += len(page) + def episodes(self) -> List[Episode]: """ Get all episodes from non-ignored libraries in the Plex server. + Note: + This holds every episode in memory at once. Prefer :meth:`iter_episodes` + when the episodes can be consumed one at a time. + Returns: List[Episode]: A list of all episodes in non-ignored Plex libraries. """ - if hasattr(self, 'should_ignore_library'): - # For PlexServer instances that have the should_ignore_library method - non_ignored_sections = self.get_show_sections() - episodes = [] - for section in non_ignored_sections: - section_episodes = section.all(libtype="episode", container_size=1000) - episodes.extend(section_episodes) - return episodes - else: - # For UnprivilegedPlexServer instances or fallback - return self._plex.library.all(libtype="episode", container_size=1000) + return list(self.iter_episodes()) def get_recently_added_episodes(self, minutes: int) -> List[Episode]: """ diff --git a/plex_auto_languages/plex_server_cache.py b/plex_auto_languages/plex_server_cache.py index dd2e3d2..b309445 100644 --- a/plex_auto_languages/plex_server_cache.py +++ b/plex_auto_languages/plex_server_cache.py @@ -418,6 +418,11 @@ def refresh_library_cache(self) -> tuple[list, list]: Updates the episode_parts dictionary with current data from the Plex server. Identifies episodes that have been added or updated since the last refresh. + On a cold cache there is nothing to diff against, so no changes are collected + and both returned lists are empty. The only caller in that situation discards + the return value anyway, and collecting would mean retaining every Episode in + the library to report the whole thing as "added". + Returns: tuple[list, list]: A tuple containing two lists: - List of newly added episodes @@ -434,12 +439,21 @@ def refresh_library_cache(self) -> tuple[list, list]: added = [] updated = [] new_episode_parts = {} - - for episode in self._plex.episodes(): + # A cold cache diffs against nothing, so every episode would land in + # `added` and be retained for a result the caller throws away. + collect_changes = bool(self.episode_parts) + + # Iterate lazily: holding the whole library at once costs >1GB on + # large libraries, which is enough to get the process OOM-killed + # before the refresh completes. + for episode in self._plex.iter_episodes(): part_list = new_episode_parts.setdefault(episode.key, []) for part in episode.iterParts(): part_list.append(part.key) + if not collect_changes: + continue + if episode.key in self.episode_parts and set(self.episode_parts[episode.key]) != set(part_list): updated.append(episode) elif episode.key not in self.episode_parts: From 14b58ae7dd4f2d0b726fece1456a2bbe0c896350 Mon Sep 17 00:00:00 2001 From: nickwolf Date: Thu, 16 Jul 2026 06:09:59 -0600 Subject: [PATCH 2/7] feat(cache): add EpisodeRef record for post-refresh consumers --- plex_auto_languages/episode_ref.py | 61 ++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/fakes.py | 55 +++++++++++++++++++++++++++ tests/requirements.txt | 1 + tests/test_episode_ref.py | 55 +++++++++++++++++++++++++++ 5 files changed, 172 insertions(+) create mode 100644 plex_auto_languages/episode_ref.py create mode 100644 tests/__init__.py create mode 100644 tests/fakes.py create mode 100644 tests/requirements.txt create mode 100644 tests/test_episode_ref.py diff --git a/plex_auto_languages/episode_ref.py b/plex_auto_languages/episode_ref.py new file mode 100644 index 0000000..ea5b503 --- /dev/null +++ b/plex_auto_languages/episode_ref.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass(frozen=True, slots=True) +class EpisodeRef: + """The subset of an Episode that the post-refresh consumers actually read. + + refresh_library_cache() used to hand back live plexapi Episode objects at + roughly 12KB each, so a bulk part-key churn retained the whole library + (~790MB on a 65,906-episode server) and got the process OOM-killed. These + measure ~356 bytes. Every field is already loaded on the Episode during the + refresh, so building one costs no extra requests. + + Note show_title comes from grandparentTitle, which is NOT always the same as + show().title: a show with an empty title reports grandparentTitle = None. + """ + + key: str + added_at: datetime | None + library_section_title: str | None + season_number: int | None + episode_number: int | None + show_title: str | None + show_key: int | None + part_files: tuple[str, ...] = field(default=()) + + @classmethod + def from_episode(cls, episode, collect_part_files: bool = False) -> "EpisodeRef": + """Build a ref from an already-loaded Episode. + + Args: + episode: A plexapi Episode that has already been loaded. + collect_part_files: Whether to record media file paths. Only needed + when ignore_filepatterns is configured; the shipped default is + [""], which disables the check entirely, so the paths would be + retained for nothing. + """ + part_files: tuple[str, ...] = () + if collect_part_files: + part_files = tuple( + part.file for part in episode.iterParts() if getattr(part, "file", None) + ) + return cls( + key=episode.key, + added_at=getattr(episode, "addedAt", None), + library_section_title=getattr(episode, "librarySectionTitle", None), + # parentIndex, not seasonNumber: the latter is a cached_data_property + # that falls back to fetching the season over the network when + # parentIndex is neither int nor None (utils.cast yields NaN on a + # malformed attribute). getattr's default only swallows AttributeError, + # so a NotFound from that fetch would abort the whole refresh. This + # runs 65,906 times; it must not touch the network. + season_number=getattr(episode, "parentIndex", None), + episode_number=getattr(episode, "episodeNumber", None), + show_title=getattr(episode, "grandparentTitle", None), + show_key=getattr(episode, "grandparentRatingKey", None), + part_files=part_files, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..595cbac --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,55 @@ +"""Duck-typed stand-ins for the plexapi objects the helpers under test touch. + +Deliberately not plexapi types: these tests must run without a Plex server, +which is exactly what the previous suite (removed in 709cc76) could not do. +""" + + +class FakePart: + def __init__(self, key="/part/1", file="/media/show/s01e01.mkv"): + self.key = key + self.file = file + + +class FakeMedia: + def __init__(self, parts=None): + self.parts = parts if parts is not None else [FakePart()] + + +class FakeShow: + def __init__(self, title="Some Show", labels=None): + self.title = title + self.labels = labels or [] + + +class _Raises: + """Sentinel: makes episode.show() raise, as plexapi does via NotFound.""" + + +class FakeEpisode: + def __init__(self, key="/library/metadata/1", added_at=None, + library_section_title="TV Shows", season_number=1, + episode_number=1, show_title="Some Show", show_key=9, + files=("/media/show/s01e01.mkv",), show=None): + self.key = key + self.addedAt = added_at + self.librarySectionTitle = library_section_title + self.seasonNumber = season_number + self.episodeNumber = episode_number + self.grandparentTitle = show_title + self.grandparentRatingKey = show_key + # Real Episodes carry both: parentIndex is the raw attribute, seasonNumber + # is a cached_data_property derived from it that can hit the network. + self.parentIndex = season_number + self.media = [FakeMedia([FakePart(key=f"/part/{i}", file=f) for i, f in enumerate(files)])] + self._show = show if show is not None else FakeShow(title=show_title or "") + + def iterParts(self): + for media in self.media: + for part in media.parts: + yield part + + def show(self): + if isinstance(self._show, _Raises): + raise RuntimeError("simulated plexapi NotFound") + return self._show diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +pytest diff --git a/tests/test_episode_ref.py b/tests/test_episode_ref.py new file mode 100644 index 0000000..afd431c --- /dev/null +++ b/tests/test_episode_ref.py @@ -0,0 +1,55 @@ +from datetime import datetime + +import pytest + +from plex_auto_languages.episode_ref import EpisodeRef +from tests.fakes import FakeEpisode + + +def test_from_episode_maps_every_field(): + when = datetime(2026, 7, 15, 12, 0, 0) + ep = FakeEpisode(key="/library/metadata/5", added_at=when, + library_section_title="TV Shows", season_number=2, episode_number=7, + show_title="Deli Boys", show_key=9) + ref = EpisodeRef.from_episode(ep) + assert ref.key == "/library/metadata/5" + assert ref.added_at == when + assert ref.library_section_title == "TV Shows" + assert ref.season_number == 2 + assert ref.episode_number == 7 + assert ref.show_title == "Deli Boys" + assert ref.show_key == 9 + + +def test_part_files_omitted_by_default(): + ep = FakeEpisode(files=("/media/a.mkv", "/media/b.mkv")) + assert EpisodeRef.from_episode(ep).part_files == () + + +def test_part_files_collected_when_requested(): + ep = FakeEpisode(files=("/media/a.mkv", "/media/b.mkv")) + ref = EpisodeRef.from_episode(ep, collect_part_files=True) + assert ref.part_files == ("/media/a.mkv", "/media/b.mkv") + + +def test_none_show_title_is_preserved_not_coerced(): + # KidTube episodes (show 258471) report grandparentTitle = None. The ref must + # carry the None through rather than inventing a placeholder; formatting is + # format_ref_name's job. + ep = FakeEpisode(show_title=None) + assert EpisodeRef.from_episode(ep).show_title is None + + +def test_from_episode_does_not_call_show(): + # Building a ref must not trigger a Plex fetch: this runs 65,906 times. + class Exploding(FakeEpisode): + def show(self): + raise AssertionError("from_episode must not call show()") + + assert EpisodeRef.from_episode(Exploding()).show_title == "Some Show" + + +def test_ref_is_frozen(): + ref = EpisodeRef.from_episode(FakeEpisode()) + with pytest.raises(Exception): + ref.key = "/library/metadata/999" From 609fee0dfee48dcf3d94c31abf1a5ed2cc168952 Mon Sep 17 00:00:00 2001 From: nickwolf Date: Thu, 16 Jul 2026 10:03:04 -0600 Subject: [PATCH 3/7] feat: add ref-path helpers for naming, filepath and show-label checks --- plex_auto_languages/plex_server.py | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/plex_auto_languages/plex_server.py b/plex_auto_languages/plex_server.py index 3fa2981..1bc32d2 100644 --- a/plex_auto_languages/plex_server.py +++ b/plex_auto_languages/plex_server.py @@ -608,6 +608,73 @@ def should_ignore_library(self, section_title: str) -> bool: """ return section_title in self.config.get("ignore_libraries") + @staticmethod + def format_ref_name(show_title: str | None, season_number: int | None, + episode_number: int | None) -> str: + """Format a log name from an EpisodeRef's primitives. + + Separate from get_episode_short_name on purpose. That one reads + show().title via a live fetch; this reads grandparentTitle off the ref. + They disagree on real data - a show with an empty title reports + grandparentTitle = None while show().title == "" - so they are different + functions rather than one shared formatter pretending otherwise. + """ + season_num = season_number if season_number is not None else 0 + episode_num = episode_number if episode_number is not None else 0 + if show_title is None: + return f"Unknown Show (S{season_num:02}E{episode_num:02})" + return f"'{show_title}' (S{season_num:02}E{episode_num:02})" + + def _matches_ignore_filepattern(self, files) -> bool: + """Whether any of these recorded file paths matches an ignore pattern. + + The ref-path counterpart of should_ignore_filepath, which keeps its own + copy of this loop so its early return stays ahead of episode.media. + """ + patterns = self.config.get("ignore_filepatterns") + patterns = [p for p in patterns if p] + if not patterns: + return False + for filepath in files: + if not filepath: + continue + filepath_lower = filepath.lower() + for pattern in patterns: + try: + if re.search(pattern, filepath_lower, re.IGNORECASE): + return True + except re.error as e: + logger.warning(f"Invalid regex pattern '{pattern}': {e}") + return False + + def should_ignore_show_by_key(self, show_key, memo: dict | None = None) -> bool: + """Label-based ignore check for a show identified by its rating key. + + Callers holding an EpisodeRef have the key but not the Show. + + `memo` is a caller-owned dict that must not outlive the loop using it. + Roughly 2,000 shows back 65,906 episodes, so memoising turns one fetch + per episode into one per show. It is scoped to a single loop precisely + so it cannot serve stale labels across runs. + + A show that cannot be fetched is not ignored: plexapi raises NotFound + rather than returning None, and today an escaping NotFound aborts the + entire deep-analysis loop. + """ + if show_key is None: + return False + if memo is not None and show_key in memo: + return memo[show_key] + try: + show = self._plex.fetchItem(show_key) + ignored = show is not None and self.should_ignore_show(show) + except Exception as e: + logger.warning(f"Unable to fetch show {show_key}: {e}") + ignored = False + if memo is not None: + memo[show_key] = ignored + return ignored + def should_ignore_show(self, show: Show) -> bool: """ Check if a show should be ignored based on its labels or its library. From c531ceb99e267e8f5951103f2f75529b18b22ee4 Mon Sep 17 00:00:00 2001 From: nickwolf Date: Thu, 16 Jul 2026 10:11:04 -0600 Subject: [PATCH 4/7] feat: add ref-returning recently-added lookup --- plex_auto_languages/plex_server.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plex_auto_languages/plex_server.py b/plex_auto_languages/plex_server.py index 1bc32d2..3c0d05b 100644 --- a/plex_auto_languages/plex_server.py +++ b/plex_auto_languages/plex_server.py @@ -21,6 +21,7 @@ from plex_auto_languages.plex_alert_listener import PlexAlertListener from plex_auto_languages.track_changes import TrackChanges, NewOrUpdatedTrackChanges from plex_auto_languages.utils.notifier import Notifier +from plex_auto_languages.episode_ref import EpisodeRef from plex_auto_languages.plex_server_cache import PlexServerCache from plex_auto_languages.constants import EventType from plex_auto_languages.exceptions import UserNotFound @@ -608,6 +609,17 @@ def should_ignore_library(self, section_title: str) -> bool: """ return section_title in self.config.get("ignore_libraries") + def get_recently_added_episode_refs(self, minutes: int) -> List[EpisodeRef]: + """ + Get refs for episodes added in the last `minutes` minutes. + + Returns refs rather than Episodes so this shares one shape with + refresh_library_cache(). Its only consumer (the library-scan handler) + does not check filepaths, so part files are not collected. + """ + return [EpisodeRef.from_episode(episode) + for episode in self.get_recently_added_episodes(minutes)] + @staticmethod def format_ref_name(show_title: str | None, season_number: int | None, episode_number: int | None) -> str: From 307cbe60fa90c56d18d69a74db7d52b54e581852 Mon Sep 17 00:00:00 2001 From: nickwolf Date: Thu, 16 Jul 2026 10:12:07 -0600 Subject: [PATCH 5/7] fix(cache): return lightweight refs instead of retaining Episodes --- plex_auto_languages/plex_server_cache.py | 35 ++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/plex_auto_languages/plex_server_cache.py b/plex_auto_languages/plex_server_cache.py index b309445..12a0b3e 100644 --- a/plex_auto_languages/plex_server_cache.py +++ b/plex_auto_languages/plex_server_cache.py @@ -10,6 +10,7 @@ from dateutil.parser import isoparse +from plex_auto_languages.episode_ref import EpisodeRef from plex_auto_languages.utils.logger import get_logger if TYPE_CHECKING: @@ -424,9 +425,9 @@ def refresh_library_cache(self) -> tuple[list, list]: the library to report the whole thing as "added". Returns: - tuple[list, list]: A tuple containing two lists: - - List of newly added episodes - - List of updated episodes + tuple[list[EpisodeRef], list[EpisodeRef]]: A tuple containing two lists: + - Refs for newly added episodes + - Refs for updated episodes """ with self._lock: if self._is_refreshing: @@ -442,22 +443,46 @@ def refresh_library_cache(self) -> tuple[list, list]: # A cold cache diffs against nothing, so every episode would land in # `added` and be retained for a result the caller throws away. collect_changes = bool(self.episode_parts) + # Only worth recording media paths when a pattern is configured; + # the shipped default ([""]) disables the check entirely. + collect_files = bool([p for p in self._plex.config.get("ignore_filepatterns") if p]) # Iterate lazily: holding the whole library at once costs >1GB on # large libraries, which is enough to get the process OOM-killed # before the refresh completes. for episode in self._plex.iter_episodes(): part_list = new_episode_parts.setdefault(episode.key, []) + files = [] for part in episode.iterParts(): part_list.append(part.key) + if collect_files and getattr(part, "file", None): + files.append(part.file) if not collect_changes: continue if episode.key in self.episode_parts and set(self.episode_parts[episode.key]) != set(part_list): - updated.append(episode) + target = updated elif episode.key not in self.episode_parts: - added.append(episode) + target = added + else: + continue + + # Record only what the consumers read. Retaining the Episode + # costs ~12KB each, which is enough to OOM the process when a + # bulk change marks the whole library as updated. + target.append(EpisodeRef( + key=episode.key, + added_at=getattr(episode, "addedAt", None), + library_section_title=getattr(episode, "librarySectionTitle", None), + # parentIndex, not seasonNumber - see EpisodeRef.from_episode. + # seasonNumber can fetch over the network from inside this loop. + season_number=getattr(episode, "parentIndex", None), + episode_number=getattr(episode, "episodeNumber", None), + show_title=getattr(episode, "grandparentTitle", None), + show_key=getattr(episode, "grandparentRatingKey", None), + part_files=tuple(files), + )) self.episode_parts = new_episode_parts logger.debug("[Cache] Done refreshing library cache") From f0e1ff9d6f63953c293a59d6c65e68d7244fae01 Mon Sep 17 00:00:00 2001 From: nickwolf Date: Thu, 16 Jul 2026 10:14:33 -0600 Subject: [PATCH 6/7] refactor: consume EpisodeRef in scheduler and status handlers --- plex_auto_languages/alerts/status.py | 35 ++++++++++-------- plex_auto_languages/plex_server.py | 55 ++++++++++++---------------- 2 files changed, 43 insertions(+), 47 deletions(-) diff --git a/plex_auto_languages/alerts/status.py b/plex_auto_languages/alerts/status.py index 9b7736d..d53e621 100644 --- a/plex_auto_languages/alerts/status.py +++ b/plex_auto_languages/alerts/status.py @@ -60,47 +60,52 @@ def process(self, plex: 'PlexServer') -> None: if plex.config.get("refresh_library_on_scan"): added, updated = plex.cache.refresh_library_cache() else: - added = plex.get_recently_added_episodes(minutes=5) + added = plex.get_recently_added_episode_refs(minutes=5) updated = [] + # Scoped to this handler call so it cannot serve stale labels later. + show_memo: dict = {} + # Process recently added episodes if len(added) > 0: logger.debug(f"[Status] Found {len(added)} newly added episode(s)") - for item in added: + for ref in added: + name = plex.format_ref_name(ref.show_title, ref.season_number, ref.episode_number) # Check if the library should be ignored - if plex.should_ignore_library(item.librarySectionTitle): - logger.debug(f"[Status] Ignoring episode {plex.get_episode_short_name(item)} due to ignored library: '{item.librarySectionTitle}'") + if plex.should_ignore_library(ref.library_section_title): + logger.debug(f"[Status] Ignoring episode {name} due to ignored library: '{ref.library_section_title}'") continue # Check if the item should be ignored - if plex.should_ignore_show(item.show()): + if plex.should_ignore_show_by_key(ref.show_key, show_memo): continue # Check if the item has already been processed - if not plex.cache.should_process_recently_added(item.key, item.addedAt): + if not plex.cache.should_process_recently_added(ref.key, ref.added_at): continue # Change tracks for all users - logger.info(f"[Status] Processing newly added episode {plex.get_episode_short_name(item)}") - plex.process_new_or_updated_episode(item.key, EventType.NEW_EPISODE, True) + logger.info(f"[Status] Processing newly added episode {name}") + plex.process_new_or_updated_episode(ref.key, EventType.NEW_EPISODE, True) # Process updated episodes if len(updated) > 0: logger.debug(f"[Status] Found {len(updated)} updated episode(s)") - for item in updated: + for ref in updated: + name = plex.format_ref_name(ref.show_title, ref.season_number, ref.episode_number) # Check if the library should be ignored - if plex.should_ignore_library(item.librarySectionTitle): - logger.debug(f"[Status] Ignoring episode {plex.get_episode_short_name(item)} due to ignored library: '{item.librarySectionTitle}'") + if plex.should_ignore_library(ref.library_section_title): + logger.debug(f"[Status] Ignoring episode {name} due to ignored library: '{ref.library_section_title}'") continue # Check if the item should be ignored - if plex.should_ignore_show(item.show()): + if plex.should_ignore_show_by_key(ref.show_key, show_memo): continue # Check if the item has already been processed - if not plex.cache.should_process_recently_updated(item.key): + if not plex.cache.should_process_recently_updated(ref.key): continue # Change tracks for all users - logger.info(f"[Status] Processing updated episode {plex.get_episode_short_name(item)}") - plex.process_new_or_updated_episode(item.key, EventType.UPDATED_EPISODE, False) + logger.info(f"[Status] Processing updated episode {name}") + plex.process_new_or_updated_episode(ref.key, EventType.UPDATED_EPISODE, False) diff --git a/plex_auto_languages/plex_server.py b/plex_auto_languages/plex_server.py index 3c0d05b..df469ea 100644 --- a/plex_auto_languages/plex_server.py +++ b/plex_auto_languages/plex_server.py @@ -227,22 +227,6 @@ def episodes(self) -> List[Episode]: """ return list(self.iter_episodes()) - def get_recently_added_episodes(self, minutes: int) -> List[Episode]: - """ - Get episodes that were recently added to non-ignored Plex libraries. - - Args: - minutes (int): Number of minutes to look back for recently added episodes. - - Returns: - List[Episode]: A list of episodes added within the specified time frame. - """ - episodes = [] - for section in self.get_show_sections(): - recent = section.searchEpisodes(sort="addedAt:desc", filters={"addedAt>>": f"{minutes}m"}) - episodes.extend(recent) - return episodes - def get_show_sections(self) -> List[ShowSection]: """ Get all TV show sections from the Plex library that are not ignored. @@ -617,8 +601,11 @@ def get_recently_added_episode_refs(self, minutes: int) -> List[EpisodeRef]: refresh_library_cache(). Its only consumer (the library-scan handler) does not check filepaths, so part files are not collected. """ - return [EpisodeRef.from_episode(episode) - for episode in self.get_recently_added_episodes(minutes)] + refs = [] + for section in self.get_show_sections(): + recent = section.searchEpisodes(sort="addedAt:desc", filters={"addedAt>>": f"{minutes}m"}) + refs.extend(EpisodeRef.from_episode(episode) for episode in recent) + return refs @staticmethod def format_ref_name(show_title: str | None, season_number: int | None, @@ -860,28 +847,32 @@ def start_deep_analysis(self) -> None: # Scan library added, updated = self.cache.refresh_library_cache() - for item in added: - if self.should_ignore_library(item.librarySectionTitle): + # Scoped to this loop so it cannot serve stale labels on a later run. + show_memo: dict = {} + for ref in added: + if self.should_ignore_library(ref.library_section_title): continue - if self.should_ignore_show(item.show()): + if self.should_ignore_show_by_key(ref.show_key, show_memo): continue - if self.should_ignore_filepath(item): + if self._matches_ignore_filepattern(ref.part_files): continue - if not self.cache.should_process_recently_added(item.key, item.addedAt): + if not self.cache.should_process_recently_added(ref.key, ref.added_at): continue - logger.info(f"[Scheduler] Processing newly added episode {self.get_episode_short_name(item)}") - self.process_new_or_updated_episode(item.key, EventType.SCHEDULER, True) - for item in updated: - if self.should_ignore_library(item.librarySectionTitle): + name = self.format_ref_name(ref.show_title, ref.season_number, ref.episode_number) + logger.info(f"[Scheduler] Processing newly added episode {name}") + self.process_new_or_updated_episode(ref.key, EventType.SCHEDULER, True) + for ref in updated: + if self.should_ignore_library(ref.library_section_title): continue - if self.should_ignore_show(item.show()): + if self.should_ignore_show_by_key(ref.show_key, show_memo): continue - if self.should_ignore_filepath(item): + if self._matches_ignore_filepattern(ref.part_files): continue - if not self.cache.should_process_recently_updated(item.key): + if not self.cache.should_process_recently_updated(ref.key): continue - logger.info(f"[Scheduler] Processing updated episode {self.get_episode_short_name(item)}") - self.process_new_or_updated_episode(item.key, EventType.SCHEDULER, False) + name = self.format_ref_name(ref.show_title, ref.season_number, ref.episode_number) + logger.info(f"[Scheduler] Processing updated episode {name}") + self.process_new_or_updated_episode(ref.key, EventType.SCHEDULER, False) logger.info("[Scheduler] Deep analysis completed") def stop(self) -> None: From 24d1ccf8eeba473d6a09b2962d8946398d399aaf Mon Sep 17 00:00:00 2001 From: nickwolf Date: Thu, 16 Jul 2026 17:39:59 -0600 Subject: [PATCH 7/7] fix: stamp librarySectionTitle from the owning section to avoid a reload per episode Episodes returned by a section search carry librarySectionTitle only on the MediaContainer, so the attribute is None on every item. Reading it made plexapi reload the full item over the network once per episode: 65,925 requests and ~19 minutes on a full-library refresh, all while holding the cache lock, and any one of them timing out aborted the refresh. --- plex_auto_languages/episode_ref.py | 12 +++++-- plex_auto_languages/plex_server.py | 15 ++++++++- tests/test_iter_episodes.py | 53 ++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 tests/test_iter_episodes.py diff --git a/plex_auto_languages/episode_ref.py b/plex_auto_languages/episode_ref.py index ea5b503..fb72e36 100644 --- a/plex_auto_languages/episode_ref.py +++ b/plex_auto_languages/episode_ref.py @@ -10,9 +10,15 @@ class EpisodeRef: refresh_library_cache() used to hand back live plexapi Episode objects at roughly 12KB each, so a bulk part-key churn retained the whole library - (~790MB on a 65,906-episode server) and got the process OOM-killed. These - measure ~356 bytes. Every field is already loaded on the Episode during the - refresh, so building one costs no extra requests. + (~790MB on a 65,925-episode server) and got the process OOM-killed. These + measure ~242 bytes. + + Building one issues no requests, but only for an Episode whose fields are + populated. Reading a field that is None on a partial object makes plexapi + reload the whole item over the network (base.py:657), and librarySectionTitle + is None on every episode a section search returns. iter_episodes stamps it + from the owning section for exactly this reason; without that, building refs + for a full library costs one metadata request per episode. Note show_title comes from grandparentTitle, which is NOT always the same as show().title: a show with an empty title reports grandparentTitle = None. diff --git a/plex_auto_languages/plex_server.py b/plex_auto_languages/plex_server.py index df469ea..c788b66 100644 --- a/plex_auto_languages/plex_server.py +++ b/plex_auto_languages/plex_server.py @@ -205,6 +205,15 @@ def iter_episodes(self, page_size: int = 1000) -> Iterator[Episode]: ) if not page: break + for episode in page: + # A section search returns librarySectionTitle on the + # MediaContainer, not on each Video, so every episode here has + # it set to None. Reading it would send plexapi looking for a + # value it does not have and reload the whole item over the + # network (base.py:657), once per episode. Stamp it from the + # section that produced them instead - the same propagation + # plexapi does onto a show's children (video.py:1318). + episode.librarySectionTitle = section.title yield from page # Advance by what was actually returned rather than by page_size: # a short page mid-section would otherwise leave a permanent gap, @@ -604,7 +613,11 @@ def get_recently_added_episode_refs(self, minutes: int) -> List[EpisodeRef]: refs = [] for section in self.get_show_sections(): recent = section.searchEpisodes(sort="addedAt:desc", filters={"addedAt>>": f"{minutes}m"}) - refs.extend(EpisodeRef.from_episode(episode) for episode in recent) + for episode in recent: + # Same reason as iter_episodes: unstamped, reading this field + # costs a full metadata reload per episode. + episode.librarySectionTitle = section.title + refs.append(EpisodeRef.from_episode(episode)) return refs @staticmethod diff --git a/tests/test_iter_episodes.py b/tests/test_iter_episodes.py new file mode 100644 index 0000000..abcb173 --- /dev/null +++ b/tests/test_iter_episodes.py @@ -0,0 +1,53 @@ +from plex_auto_languages.plex_server import PlexServer +from tests.fakes import FakeEpisode + + +class FakeSection: + def __init__(self, title, episodes): + self.title = title + self._episodes = episodes + + def search(self, libtype=None, sort=None, container_start=0, + container_size=None, maxresults=None): + return self._episodes[container_start:container_start + container_size] + + +def _server(sections): + server = object.__new__(PlexServer) + server.get_show_sections = lambda: sections + return server + + +def test_iter_episodes_stamps_the_owning_section_title(): + # Episodes from a section search carry no librarySectionTitle of their own: + # it lives on the MediaContainer. Reading it unstamped makes plexapi issue a + # full metadata reload per episode (base.py:657), which on a 65,925-episode + # library is 65,925 requests inside the refresh lock. + episodes = [FakeEpisode(key=f"/library/metadata/{i}", library_section_title=None) + for i in range(3)] + server = _server([FakeSection("TV Shows", episodes)]) + + out = list(server.iter_episodes(page_size=2)) + + assert len(out) == 3 + assert [e.librarySectionTitle for e in out] == ["TV Shows"] * 3 + + +def test_iter_episodes_stamps_each_section_separately(): + tv = [FakeEpisode(key="/library/metadata/1", library_section_title=None)] + sports = [FakeEpisode(key="/library/metadata/2", library_section_title=None)] + server = _server([FakeSection("TV Shows", tv), FakeSection("Sports", sports)]) + + out = list(server.iter_episodes(page_size=10)) + + assert [e.librarySectionTitle for e in out] == ["TV Shows", "Sports"] + + +def test_iter_episodes_paginates_across_pages(): + episodes = [FakeEpisode(key=f"/library/metadata/{i}", library_section_title=None) + for i in range(5)] + server = _server([FakeSection("TV Shows", episodes)]) + + out = list(server.iter_episodes(page_size=2)) + + assert [e.key for e in out] == [f"/library/metadata/{i}" for i in range(5)]