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/episode_ref.py b/plex_auto_languages/episode_ref.py new file mode 100644 index 0000000..fb72e36 --- /dev/null +++ b/plex_auto_languages/episode_ref.py @@ -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, + ) diff --git a/plex_auto_languages/plex_server.py b/plex_auto_languages/plex_server.py index cae4e46..c788b66 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 @@ -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 @@ -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]: """ @@ -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. @@ -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: diff --git a/plex_auto_languages/plex_server_cache.py b/plex_auto_languages/plex_server_cache.py index dd2e3d2..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: @@ -418,10 +419,15 @@ 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 - - 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: @@ -434,16 +440,49 @@ 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) + # 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") 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" 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)]