Skip to content

Commit 1d45109

Browse files
authored
feat(loader): type plex ingestion batches (#116)
1 parent 3b29182 commit 1d45109

File tree

6 files changed

+77
-54
lines changed

6 files changed

+77
-54
lines changed

docker/pyproject.deps.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-plex"
3-
version = "1.0.2"
3+
version = "1.0.3"
44
requires-python = ">=3.11,<3.13"
55
dependencies = [
66
"fastmcp>=2.11.2",

mcp_plex/loader/pipeline/channels.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717

1818
try: # Only import plexapi when available; the sample data mode does not require it.
1919
from plexapi.base import PlexPartialObject
20+
from plexapi.video import Episode, Movie, Show
2021
except Exception:
2122
PlexPartialObject = object # type: ignore[assignment]
23+
Episode = Movie = Show = PlexPartialObject # type: ignore[assignment]
2224

2325
T = TypeVar("T")
2426

@@ -46,15 +48,15 @@
4648
class MovieBatch:
4749
"""Batch of Plex movie items pending metadata enrichment."""
4850

49-
movies: list["PlexPartialObject"]
51+
movies: list["Movie"]
5052

5153

5254
@dataclass(slots=True)
5355
class EpisodeBatch:
5456
"""Batch of Plex episodes along with their parent show."""
5557

56-
show: "PlexPartialObject"
57-
episodes: list["PlexPartialObject"]
58+
show: "Show"
59+
episodes: list["Episode"]
5860

5961

6062
@dataclass(slots=True)

mcp_plex/loader/pipeline/ingestion.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import asyncio
1111
import logging
12-
from typing import Sequence
12+
from typing import TYPE_CHECKING, Sequence
1313

1414
from ...common.types import AggregatedItem
1515
from .channels import (
@@ -20,14 +20,24 @@
2020
chunk_sequence,
2121
)
2222

23+
if TYPE_CHECKING: # pragma: no cover - imported for typing
24+
from plexapi.server import PlexServer
25+
from plexapi.video import Episode, Movie, Season, Show
26+
else: # pragma: no cover - runtime import with graceful fallback
27+
try:
28+
from plexapi.server import PlexServer
29+
from plexapi.video import Episode, Movie, Season, Show
30+
except Exception: # pragma: no cover - plexapi optional at runtime
31+
PlexServer = Movie = Show = Season = Episode = object # type: ignore[assignment]
32+
2333

2434
class IngestionStage:
2535
"""Coordinate ingesting items from Plex or bundled sample data."""
2636

2737
def __init__(
2838
self,
2939
*,
30-
plex_server: object | None,
40+
plex_server: PlexServer | None,
3141
sample_items: Sequence[AggregatedItem] | None,
3242
movie_batch_size: int,
3343
episode_batch_size: int,
@@ -145,17 +155,18 @@ async def _run_plex_ingestion(self) -> None:
145155
async def _ingest_plex(
146156
self,
147157
*,
148-
plex_server: object,
158+
plex_server: PlexServer,
149159
movie_batch_size: int,
150160
episode_batch_size: int,
151161
output_queue: IngestQueue,
152162
logger: logging.Logger,
153163
) -> None:
154164
"""Retrieve Plex media and place batches onto *output_queue*."""
155165

156-
movies_attr = getattr(plex_server, "movies", [])
157-
movies_source = movies_attr() if callable(movies_attr) else movies_attr
158-
movies = list(movies_source)
166+
library = plex_server.library
167+
168+
movies_section = library.section("Movies")
169+
movies: list[Movie] = list(movies_section.all())
159170
logger.info(
160171
"Discovered %d Plex movie(s) for ingestion.",
161172
len(movies),
@@ -180,9 +191,8 @@ async def _ingest_plex(
180191
self._items_ingested,
181192
)
182193

183-
shows_attr = getattr(plex_server, "shows", [])
184-
shows_source = shows_attr() if callable(shows_attr) else shows_attr
185-
shows = list(shows_source)
194+
shows_section = library.section("TV Shows")
195+
shows: list[Show] = list(shows_section.all())
186196
logger.info(
187197
"Discovered %d Plex show(s) for ingestion.",
188198
len(shows),
@@ -191,11 +201,10 @@ async def _ingest_plex(
191201
episode_total = 0
192202
for show in shows:
193203
show_title = getattr(show, "title", str(show))
194-
episodes_attr = getattr(show, "episodes", [])
195-
episodes_source = (
196-
episodes_attr() if callable(episodes_attr) else episodes_attr
197-
)
198-
episodes = list(episodes_source)
204+
seasons: list[Season] = list(show.seasons())
205+
episodes: list[Episode] = []
206+
for season in seasons:
207+
episodes.extend(season.episodes())
199208
if not episodes:
200209
logger.debug("Show %s yielded no episodes for ingestion.", show_title)
201210
for batch_index, chunk in enumerate(

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "mcp-plex"
7-
version = "1.0.2"
7+
version = "1.0.3"
88

99
description = "Plex-Oriented Model Context Protocol Server"
1010
requires-python = ">=3.11,<3.13"

tests/test_ingestion_stage.py

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import asyncio
22
import logging
3+
from typing import cast
4+
from unittest.mock import Mock, create_autospec
35

46
import pytest
7+
from plexapi.server import PlexServer
8+
from plexapi.video import Episode, Movie, Season, Show
59

610
from mcp_plex.common.types import AggregatedItem, PlexItem
711
from mcp_plex.loader.pipeline.channels import (
@@ -28,7 +32,7 @@ def test_ingestion_stage_logger_name() -> None:
2832
async def scenario() -> str:
2933
queue: asyncio.Queue = asyncio.Queue()
3034
stage = IngestionStage(
31-
plex_server=object(),
35+
plex_server=cast(PlexServer, object()),
3236
sample_items=None,
3337
movie_batch_size=50,
3438
episode_batch_size=25,
@@ -159,45 +163,49 @@ async def scenario() -> tuple[list[SampleBatch], object | None, object | None, i
159163
def test_ingestion_stage_ingest_plex_batches_movies_and_episodes(caplog) -> None:
160164
caplog.set_level(logging.INFO)
161165

162-
class FakeMovie:
163-
def __init__(self, title: str) -> None:
164-
self.title = title
166+
sentinel = object()
165167

166-
class FakeEpisode:
167-
def __init__(self, title: str) -> None:
168-
self.title = title
168+
async def scenario() -> tuple[list[object], int, int, Mock]:
169+
queue: asyncio.Queue = asyncio.Queue()
169170

170-
class FakeShow:
171-
def __init__(self, title: str, episode_titles: list[str]) -> None:
172-
self.title = title
173-
self._episodes = [FakeEpisode(ep_title) for ep_title in episode_titles]
171+
movie_section = Mock()
172+
movies = [
173+
create_autospec(Movie, instance=True, title="Movie 1"),
174+
create_autospec(Movie, instance=True, title="Movie 2"),
175+
create_autospec(Movie, instance=True, title="Movie 3"),
176+
]
177+
movie_section.all.return_value = movies
174178

175-
def episodes(self) -> list[FakeEpisode]:
176-
return list(self._episodes)
179+
def _episodes(titles: list[str]) -> list[Episode]:
180+
return [create_autospec(Episode, instance=True, title=title) for title in titles]
177181

178-
class FakePlex:
179-
def __init__(self) -> None:
180-
self._movies = [
181-
FakeMovie("Movie 1"),
182-
FakeMovie("Movie 2"),
183-
FakeMovie("Movie 3"),
184-
]
185-
self._shows = [
186-
FakeShow("Show A", ["S01E01", "S01E02", "S01E03"]),
187-
FakeShow("Show B", ["S01E01", "S01E02"]),
188-
]
182+
show_a_season_1 = create_autospec(Season, instance=True)
183+
show_a_season_1.episodes.return_value = _episodes(["S01E01", "S01E02"])
184+
show_a_season_2 = create_autospec(Season, instance=True)
185+
show_a_season_2.episodes.return_value = _episodes(["S01E03"])
189186

190-
def movies(self) -> list[FakeMovie]:
191-
return list(self._movies)
187+
show_a = create_autospec(Show, instance=True, title="Show A")
188+
show_a.seasons.return_value = [show_a_season_1, show_a_season_2]
192189

193-
def shows(self) -> list[FakeShow]:
194-
return list(self._shows)
190+
show_b_season_1 = create_autospec(Season, instance=True)
191+
show_b_season_1.episodes.return_value = _episodes(["S01E01", "S01E02"])
195192

196-
sentinel = object()
193+
show_b = create_autospec(Show, instance=True, title="Show B")
194+
show_b.seasons.return_value = [show_b_season_1]
195+
196+
shows = [show_a, show_b]
197+
show_section = Mock()
198+
show_section.all.return_value = shows
199+
200+
library = Mock()
201+
library.section.side_effect = lambda name: {
202+
"Movies": movie_section,
203+
"TV Shows": show_section,
204+
}[name]
205+
206+
plex = create_autospec(PlexServer, instance=True)
207+
plex.library = library
197208

198-
async def scenario() -> tuple[list[object], int, int]:
199-
queue: asyncio.Queue = asyncio.Queue()
200-
plex = FakePlex()
201209
stage = IngestionStage(
202210
plex_server=plex,
203211
sample_items=None,
@@ -220,10 +228,14 @@ async def scenario() -> tuple[list[object], int, int]:
220228
while not queue.empty():
221229
batches.append(await queue.get())
222230

223-
return batches, stage.items_ingested, stage.batches_ingested
231+
return batches, stage.items_ingested, stage.batches_ingested, library
224232

225-
batches, items_ingested, batches_ingested = asyncio.run(scenario())
233+
batches, items_ingested, batches_ingested, library = asyncio.run(scenario())
226234

235+
assert library.section.call_args_list == [
236+
(("Movies",),),
237+
(("TV Shows",),),
238+
]
227239
assert items_ingested == 8
228240
assert batches_ingested == 5
229241

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)