Skip to content
Draft
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
35 changes: 20 additions & 15 deletions plex_auto_languages/alerts/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
67 changes: 67 additions & 0 deletions plex_auto_languages/episode_ref.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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,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.
"""

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,
)
202 changes: 165 additions & 37 deletions plex_auto_languages/plex_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -162,40 +163,78 @@ def fetch_item(self, item_id: Union[str, int]) -> Optional[object]:
except NotFound:
return None

def episodes(self) -> List[Episode]:
def iter_episodes(self, page_size: int = 1000) -> Iterator[Episode]:
"""
Get all episodes from non-ignored libraries in the Plex server.
Iterate over all episodes from non-ignored libraries, one page at a time.

Returns:
List[Episode]: A list of all episodes in non-ignored Plex libraries.
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
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
sections = self.get_show_sections()
else:
# For UnprivilegedPlexServer instances or fallback
return self._plex.library.all(libtype="episode", container_size=1000)
# 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
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,
# 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 get_recently_added_episodes(self, minutes: int) -> List[Episode]:
def episodes(self) -> List[Episode]:
"""
Get episodes that were recently added to non-ignored Plex libraries.
Get all episodes from non-ignored libraries in the Plex server.

Args:
minutes (int): Number of minutes to look back for recently added episodes.
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 episodes added within the specified time frame.
List[Episode]: A list of all episodes in non-ignored Plex libraries.
"""
episodes = []
for section in self.get_show_sections():
recent = section.searchEpisodes(sort="addedAt:desc", filters={"addedAt>>": f"{minutes}m"})
episodes.extend(recent)
return episodes
return list(self.iter_episodes())

def get_show_sections(self) -> List[ShowSection]:
"""
Expand Down Expand Up @@ -563,6 +602,91 @@ 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.
"""
refs = []
for section in self.get_show_sections():
recent = section.searchEpisodes(sort="addedAt:desc", filters={"addedAt>>": f"{minutes}m"})
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
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.
Expand Down Expand Up @@ -736,28 +860,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:
Expand Down
Loading