From 5809b40a7587aafc7556e88a4d581f1beb1b07c0 Mon Sep 17 00:00:00 2001 From: Roger Sikorski Date: Thu, 29 Oct 2026 14:46:45 +0100 Subject: [PATCH] feat: Jellyfin as additional OnDeck source Continue Watching + Next Up (plus next N episodes) of every Jellyfin user are cached alongside Plex OnDeck; files playing on Jellyfin are skipped. Jellyfin paths are translated into Plex paths via jellyfin_path_mappings. Move-back is skipped when Jellyfin data is incomplete. Configured via settings keys or JELLYFIN_* env vars. Refs: https://nextcloud.sikorski.cloud/index.php/apps/deck/board/78/card/258 Co-Authored-By: Claude Opus 5.5 --- README.md | 15 +++ core/app.py | 39 +++++- core/config.py | 39 +++++- core/jellyfin_api.py | 191 ++++++++++++++++++++++++++++ core/plex_api.py | 3 +- tests/test_jellyfin_api.py | 252 +++++++++++++++++++++++++++++++++++++ 6 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 core/jellyfin_api.py create mode 100644 tests/test_jellyfin_api.py diff --git a/README.md b/README.md index 908e3c1..af68bf8 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,21 @@ python3 plexcache.py For additional help, check the [Wiki](https://github.com/StudioNirin/PlexCache-D/wiki) for detailed guides. If something doesn't make sense or doesn't work, please open a new issue. But don't be upset if the answer is in the Wiki and we mock you for not reading it thoroughly first. +## Jellyfin (optional) + +Jellyfin can be added as a second OnDeck source next to Plex, for setups where both servers share the same media. Every enabled Jellyfin user's *Continue Watching* and *Next Up* (plus the next `number_episodes` episodes) are cached like Plex OnDeck items, and files playing on Jellyfin are skipped. There is no Jellyfin watchlist. Plex stays required. + +Enabled when URL and API key are set (Jellyfin Dashboard → API Keys). Env vars override the settings file: + +| Env var | Settings key | Example | +|---|---|---| +| `JELLYFIN_URL` | `jellyfin_url` | `http://jellyfin:8096` | +| `JELLYFIN_API_KEY` | `jellyfin_api_key` | | +| `JELLYFIN_PATH_MAPPINGS` (JSON) | `jellyfin_path_mappings` | `{"/media/": "/mnt/user/plex-media/"}` | +| `JELLYFIN_SKIP_USERS` (comma-separated) | `jellyfin_skip_users` | `guest,kid` | + +`jellyfin_path_mappings` translates the paths Jellyfin reports into the paths Plex reports, so the regular path mappings apply. Items without a matching prefix are ignored. If Jellyfin can't be read, moving files back to the array is skipped for that run. + ## Notes This script might be compatible with other systems, especially Linux-based ones, although I have primarily tested it on Unraid with plex as docker container. While I cannot support every case, it's worth checking the GitHub issues to see if your specific case has already been discussed. Particularly worth checking the original Bexem repo issues page. diff --git a/core/app.py b/core/app.py index 4fc838a..66021fc 100644 --- a/core/app.py +++ b/core/app.py @@ -29,6 +29,7 @@ from core.logging_config import LoggingManager, reset_warning_error_flag from core.system_utils import SystemDetector, FileUtils, SingleInstanceLock, get_disk_usage, get_array_direct_path, detect_zfs, set_zfs_prefixes, format_bytes, create_dir_with_ownership, check_cache_share_alignment, cleanup_empty_parent_folders, resolve_cache_boundary, sweep_empty_folders from core.plex_api import PlexManager, OnDeckItem +from core.jellyfin_api import JellyfinManager from core.file_operations import MultiPathModifier, SiblingFileFinder, FileFilter, FileMover, PlexcachedRestorer, CacheTimestampTracker, WatchlistTracker, OnDeckTracker, CachePriorityManager, PlexcachedMigration, get_media_identity, find_matching_plexcached, is_directory_level_file, is_video_file, save_json_atomically from core.pinned_media import PinnedMediaTracker, resolve_pins_to_paths @@ -63,6 +64,7 @@ def __init__(self, config_file: str, dry_run: bool = False, # Will be initialized after config loading self.logging_manager = None self.plex_manager = None + self.jellyfin_manager = None self.file_path_modifier = None self.sibling_finder = None self.file_filter = None @@ -574,6 +576,21 @@ def _init_plex_manager(self) -> None: watchlist_enabled=self.config_manager.cache.watchlist_toggle ) + def _init_jellyfin_manager(self) -> None: + """Initialize the optional Jellyfin OnDeck source.""" + jellyfin = self.config_manager.jellyfin + if not jellyfin.enabled: + return + if not jellyfin.path_mappings: + logging.warning("[JELLYFIN] jellyfin_path_mappings is empty — no Jellyfin item will match a library") + logging.info(f"[JELLYFIN] Additional OnDeck source enabled: {jellyfin.url}") + self.jellyfin_manager = JellyfinManager( + url=jellyfin.url, + api_key=jellyfin.api_key, + path_mappings=jellyfin.path_mappings, + skip_users=jellyfin.skip_users, + ) + def _init_path_modifier(self) -> None: """Initialize path modifier and subtitle finder.""" logging.debug("Initializing file operation components...") @@ -840,6 +857,7 @@ def _initialize_components(self) -> None: # Initialize Plex manager self._init_plex_manager() + self._init_jellyfin_manager() # Initialize path modifier and subtitle finder self._init_path_modifier() @@ -1060,8 +1078,16 @@ def _add_new_users_to_settings(self, new_users: List[dict]) -> None: logging.error(f"Failed to save new users to settings: {e}") def _check_active_sessions(self) -> None: - """Check for active Plex sessions.""" + """Check for active Plex and Jellyfin sessions.""" sessions = self.plex_manager.get_active_sessions() + jellyfin_paths = self.jellyfin_manager.get_active_session_paths() if self.jellyfin_manager else [] + if jellyfin_paths: + if self.config_manager.exit_if_active_session: + logging.warning('There is an active Jellyfin session. Exiting...') + sys.exit('There is an active Jellyfin session. Exiting...') + for real_path in self.file_path_modifier.modify_file_paths(jellyfin_paths): + logging.debug(f"Skipping active Jellyfin session file: {real_path}") + self.files_to_skip.append(real_path) if sessions: if self.config_manager.exit_if_active_session: logging.warning('There is an active session. Exiting...') @@ -1167,6 +1193,14 @@ def _process_media(self) -> None: per_user_days=self.config_manager.plex.per_user_ondeck_days, prefetch_minimum_minutes=self.config_manager.cache.prefetch_minimum_minutes ) + if self.jellyfin_manager: + jellyfin_items = self.jellyfin_manager.get_on_deck_media( + self.config_manager.plex.days_to_monitor, + self.config_manager.plex.number_episodes, + prefetch_minimum_minutes=self.config_manager.cache.prefetch_minimum_minutes + ) + logging.info(f"[FETCH] Jellyfin OnDeck: {len(jellyfin_items)} items") + ondeck_items_list.extend(jellyfin_items) # Extract just the file paths for path modification ondeck_files = [item.file_path for item in ondeck_items_list] @@ -1348,6 +1382,9 @@ def _process_media(self) -> None: if not self.plex_manager.is_ondeck_data_complete(): logging.warning("Skipping array restore - OnDeck data incomplete (Plex server unreachable)") logging.warning("Files will remain on cache until next successful run") + elif self.jellyfin_manager and not self.jellyfin_manager.is_ondeck_data_complete(): + logging.warning("Skipping array restore - Jellyfin OnDeck data incomplete (Jellyfin unreachable)") + logging.warning("Files will remain on cache until next successful run") elif (self.config_manager.cache.watchlist_toggle and not self.plex_manager.is_watchlist_data_complete()): logging.warning("Skipping array restore - watchlist data incomplete (plex.tv unreachable)") diff --git a/core/config.py b/core/config.py index 215aca5..baa1558 100644 --- a/core/config.py +++ b/core/config.py @@ -8,7 +8,7 @@ import logging from pathlib import Path from typing import Dict, Any, Optional, List, Tuple -from dataclasses import dataclass +from dataclasses import dataclass, field from core.system_utils import parse_size_bytes # Get the directory where config.py is located @@ -129,6 +129,24 @@ def __post_init__(self): self.users = [] +@dataclass +class JellyfinConfig: + """Optional Jellyfin server as an additional OnDeck source. + + Enabled when both url and api_key are set. path_mappings translate + Jellyfin paths into Plex paths, e.g. {"/media/": "/mnt/user/plex-media/"}, + so the regular path_mappings apply unchanged. + """ + url: str = "" + api_key: str = "" + path_mappings: Dict[str, str] = field(default_factory=dict) + skip_users: List[str] = field(default_factory=list) + + @property + def enabled(self) -> bool: + return bool(self.url and self.api_key) + + @dataclass class CacheConfig: """Configuration for caching behavior.""" @@ -385,6 +403,7 @@ def __init__(self, config_file: str): self.notification = NotificationConfig() self.paths = PathConfig() self.plex = PlexConfig() + self.jellyfin = JellyfinConfig() self.cache = CacheConfig() self.performance = PerformanceConfig() self.logging = LoggingConfig() @@ -448,6 +467,7 @@ def _process_first_start(self) -> None: def _load_all_configs(self) -> None: """Load all configuration sections.""" self._load_plex_config() + self._load_jellyfin_config() self._load_cache_config() self._load_path_config() self._load_performance_config() @@ -500,6 +520,23 @@ def _load_plex_config(self) -> None: self.plex.per_user_ondeck_days = per_user_ondeck_days or None self.plex.per_user_watchlist_days = per_user_watchlist_days or None + def _load_jellyfin_config(self) -> None: + """Load optional Jellyfin settings. Env vars win so the API key can stay out of the settings file.""" + self.jellyfin.url = os.environ.get('JELLYFIN_URL') or self.settings_data.get('jellyfin_url', '') + self.jellyfin.api_key = os.environ.get('JELLYFIN_API_KEY') or self.settings_data.get('jellyfin_api_key', '') + self.jellyfin.path_mappings = self.settings_data.get('jellyfin_path_mappings', {}) or {} + self.jellyfin.skip_users = self.settings_data.get('jellyfin_skip_users', []) or [] + # JELLYFIN_PATH_MAPPINGS is a JSON object, JELLYFIN_SKIP_USERS a comma-separated list + env_mappings = os.environ.get('JELLYFIN_PATH_MAPPINGS') + if env_mappings: + try: + self.jellyfin.path_mappings = json.loads(env_mappings) + except json.JSONDecodeError as e: + logging.error(f"Invalid JSON in JELLYFIN_PATH_MAPPINGS: {e}") + env_skip_users = os.environ.get('JELLYFIN_SKIP_USERS') + if env_skip_users: + self.jellyfin.skip_users = [u.strip() for u in env_skip_users.split(',') if u.strip()] + def _validate_eviction_settings(self) -> None: """Range-check the eviction trio, and flag a threshold that cannot act. diff --git a/core/jellyfin_api.py b/core/jellyfin_api.py new file mode 100644 index 0000000..4a21d06 --- /dev/null +++ b/core/jellyfin_api.py @@ -0,0 +1,191 @@ +""" +Jellyfin API integration for PlexCache. +Optional second OnDeck source: Continue Watching + Next Up of every Jellyfin +user, plus active sessions. Jellyfin paths are translated into Plex paths so +the regular path_mappings handle them like any Plex item. +""" + +import logging +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Any, Dict, List, Optional + +import requests + +from core.plex_api import OnDeckItem, PlexManager + + +REQUEST_TIMEOUT = 30 +ITEM_FIELDS = "Path,MediaSources" +# RunTimeTicks are 100ns units +TICKS_PER_MS = 10_000 + + +def _parse_jellyfin_date(value: Optional[str]) -> Optional[datetime]: + """Parse a Jellyfin UTC timestamp (7 fractional digits + 'Z') to second precision.""" + if not value: + return None + try: + return datetime.fromisoformat(value[:19]).replace(tzinfo=timezone.utc) + except ValueError: + return None + + +class JellyfinManager: + """Fetches OnDeck-equivalent media from a Jellyfin server via an admin API key.""" + + def __init__(self, url: str, api_key: str, path_mappings: Optional[Dict[str, str]] = None, + skip_users: Optional[List[str]] = None, session: Optional[Any] = None): + self.url = url.rstrip('/') + # Longest prefix first, so nested mappings win + self.path_mappings = sorted((path_mappings or {}).items(), key=lambda kv: len(kv[0]), reverse=True) + self.skip_users = set(skip_users or []) + self.session = session or requests.Session() + self.session.headers['Authorization'] = f'MediaBrowser Client="PlexCache-D", Token="{api_key}"' + self._ondeck_data_complete = True + + def is_ondeck_data_complete(self) -> bool: + return self._ondeck_data_complete + + def _get(self, path: str, **params) -> Any: + response = self.session.get(f"{self.url}{path}", params=params, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + return response.json() + + def to_plex_path(self, path: str) -> Optional[str]: + """Translate a Jellyfin path into the Plex path space. None if no mapping matches.""" + for jellyfin_prefix, plex_prefix in self.path_mappings: + if path.startswith(jellyfin_prefix): + return plex_prefix + path[len(jellyfin_prefix):] + return None + + def _item_paths(self, item: Dict[str, Any]) -> List[str]: + """Plex-space paths of all versions of an item.""" + paths = [s['Path'] for s in item.get('MediaSources') or [] if s.get('Path')] + if not paths and item.get('Path'): + paths = [item['Path']] + result = [] + for path in dict.fromkeys(paths): + plex_path = self.to_plex_path(path) + if plex_path: + result.append(plex_path) + else: + logging.debug(f"[JELLYFIN] No jellyfin_path_mappings entry for {path}, skipping") + return result + + def get_on_deck_media(self, days_to_monitor: int, number_episodes: int, + prefetch_minimum_minutes: int = 0) -> List[OnDeckItem]: + """Continue Watching + Next Up (with the next N episodes) for every enabled user. + + Any failure marks the data incomplete, so move-back is skipped instead of + evicting media a Jellyfin user is still watching. + """ + self._ondeck_data_complete = True + try: + users = self._get('/Users', isDisabled='false') + except Exception as e: + logging.error(f"[JELLYFIN] Failed to list users: {type(e).__name__}: {e}") + self._ondeck_data_complete = False + return [] + + on_deck_files: List[OnDeckItem] = [] + for user in users: + username = user.get('Name', '') + if username in self.skip_users: + logging.info(f"[JELLYFIN] [USER:{username}] Skipping for OnDeck — in skip list") + continue + try: + on_deck_files.extend(self._fetch_user_on_deck_media( + user['Id'], username, days_to_monitor, number_episodes, prefetch_minimum_minutes)) + except Exception as e: + logging.error(f"[JELLYFIN] [USER:{username}] Failed to fetch OnDeck: {type(e).__name__}: {e}") + self._ondeck_data_complete = False + + for item in on_deck_files: + logging.debug(f"[JELLYFIN] [USER:{item.username}] OnDeck found: {item.file_path}") + return on_deck_files + + def _fetch_user_on_deck_media(self, user_id: str, username: str, days_to_monitor: int, + number_episodes: int, prefetch_minimum_minutes: int) -> List[OnDeckItem]: + cutoff = datetime.now(timezone.utc) - timedelta(days=days_to_monitor) + resume = self._get('/UserItems/Resume', userId=user_id, fields=ITEM_FIELDS, + mediaTypes='Video').get('Items', []) + # Next Up filters by the series' last played date server-side + next_up = self._get('/Shows/NextUp', userId=user_id, fields=ITEM_FIELDS, + nextUpDateCutoff=cutoff.strftime('%Y-%m-%dT%H:%M:%SZ'), + enableResumable='true').get('Items', []) + + on_deck_files: List[OnDeckItem] = [] + seen_ids = set() + for item in resume + next_up: + if item.get('Id') in seen_ids: + continue + seen_ids.add(item.get('Id')) + last_played = _parse_jellyfin_date((item.get('UserData') or {}).get('LastPlayedDate')) + if last_played and last_played < cutoff: + continue + if item.get('Type') == 'Episode': + self._process_episode_ondeck(item, user_id, username, number_episodes, + prefetch_minimum_minutes, on_deck_files) + elif item.get('Type') == 'Movie': + for path in self._item_paths(item): + on_deck_files.append(OnDeckItem(file_path=path, username=username, is_current_ondeck=True)) + else: + logging.debug(f"[JELLYFIN] Skipping OnDeck item '{item.get('Name')}' — type {item.get('Type')}") + return on_deck_files + + def _process_episode_ondeck(self, item: Dict[str, Any], user_id: str, username: str, + number_episodes: int, prefetch_minimum_minutes: int, + on_deck_files: List[OnDeckItem]) -> None: + show = item.get('SeriesName') + season = item.get('ParentIndexNumber') + episode = item.get('IndexNumber') + episode_info = None + if season is not None and episode is not None: + episode_info = {'show': show, 'season': season, 'episode': episode} + + for path in self._item_paths(item): + on_deck_files.append(OnDeckItem(file_path=path, username=username, + episode_info=episode_info, is_current_ondeck=True)) + + if episode_info is None or not item.get('SeriesId'): + logging.warning(f"[JELLYFIN] Skipping next episode fetch for '{show}' - missing index data") + return + + episodes = self._get(f"/Shows/{item['SeriesId']}/Episodes", userId=user_id, + fields=ITEM_FIELDS, isMissing='false').get('Items', []) + # Shape Jellyfin episodes like plexapi ones to share the prefetch logic + candidates = [ + SimpleNamespace(parentIndex=e.get('ParentIndexNumber'), index=e.get('IndexNumber'), + title=e.get('Name'), grandparentTitle=show, + duration=(e.get('RunTimeTicks') or 0) // TICKS_PER_MS, item=e) + for e in episodes + ] + next_episodes = PlexManager._get_next_episodes( + candidates, season, episode, number_episodes, + prefetch_minimum_minutes=prefetch_minimum_minutes) + for next_episode in next_episodes: + next_info = {'show': show, 'season': next_episode.parentIndex, 'episode': next_episode.index} + for path in self._item_paths(next_episode.item): + on_deck_files.append(OnDeckItem(file_path=path, username=username, + episode_info=next_info, is_current_ondeck=False)) + + def get_active_session_paths(self) -> List[str]: + """Plex-space paths of everything currently playing on Jellyfin.""" + paths = [] + try: + for session in self._get('/Sessions'): + item = session.get('NowPlayingItem') + if not item: + continue + logging.debug(f"[JELLYFIN] Active session detected, skipping: {item.get('SeriesName') or item.get('Name')}") + item_paths = self._item_paths(item) + if not item_paths and item.get('Id'): + # NowPlayingItem may come without path fields + items = self._get('/Items', ids=item['Id'], userId=session.get('UserId'), + fields=ITEM_FIELDS).get('Items', []) + item_paths = [p for i in items for p in self._item_paths(i)] + paths.extend(item_paths) + except Exception as e: + logging.warning(f"[JELLYFIN] Failed to fetch active sessions: {type(e).__name__}: {e}") + return paths diff --git a/core/plex_api.py b/core/plex_api.py index 0545f35..e7e49e5 100644 --- a/core/plex_api.py +++ b/core/plex_api.py @@ -1211,7 +1211,8 @@ def _process_movie_ondeck(self, video: Movie, on_deck_files: List[OnDeckItem], u rating_key=movie_rating_key or None )) - def _get_next_episodes(self, episodes: List[Episode], current_season: int, + @staticmethod + def _get_next_episodes(episodes: List[Episode], current_season: int, current_episode_index: int, number_episodes: int, prefetch_minimum_minutes: int = 0) -> List[Episode]: """Get the next episodes after the current one. diff --git a/tests/test_jellyfin_api.py b/tests/test_jellyfin_api.py new file mode 100644 index 0000000..01e23f6 --- /dev/null +++ b/tests/test_jellyfin_api.py @@ -0,0 +1,252 @@ +"""Tests for the optional Jellyfin OnDeck source (core/jellyfin_api.py).""" + +import os +import sys +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +import pytest + +sys.modules['fcntl'] = MagicMock() +for _mod in [ + 'apscheduler', 'apscheduler.schedulers', + 'apscheduler.schedulers.background', 'apscheduler.triggers', + 'apscheduler.triggers.cron', 'apscheduler.triggers.interval', + 'plexapi', 'plexapi.server', 'plexapi.video', 'plexapi.myplex', + 'plexapi.library', 'plexapi.exceptions', 'requests', +]: + sys.modules.setdefault(_mod, MagicMock()) + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.jellyfin_api import JellyfinManager + + +MAPPINGS = {"/media/en/": "/mnt/user/plex-media/", "/media/": "/data/"} + + +def _ago(days): + return (datetime.now(timezone.utc) - timedelta(days=days)).strftime('%Y-%m-%dT%H:%M:%S.0000000Z') + + +def _episode(item_id, season, episode, path, runtime_min=45, series_id="s1", last_played=None): + return { + "Id": item_id, "Type": "Episode", "Name": f"Ep {season}x{episode}", + "SeriesId": series_id, "SeriesName": "Show", + "ParentIndexNumber": season, "IndexNumber": episode, + "RunTimeTicks": runtime_min * 60 * 10_000_000, + "MediaSources": [{"Path": path}], + "UserData": {"LastPlayedDate": last_played} if last_played else {}, + } + + +class FakeSession: + """Routes GET requests to canned JSON by path; raises for paths mapped to an exception.""" + + def __init__(self, routes): + self.routes = routes + self.headers = {} + self.calls = [] + + def get(self, url, params=None, timeout=None): + path = url.split("http://jf", 1)[1] + self.calls.append((path, params)) + result = self.routes[path] + if isinstance(result, Exception): + raise result + if callable(result): + result = result(params) + response = MagicMock() + response.json.return_value = result + return response + + +def _manager(routes, **kwargs): + return JellyfinManager("http://jf/", "key", path_mappings=MAPPINGS, session=FakeSession(routes), **kwargs) + + +def _routes(resume=None, next_up=None, episodes=None, users=None): + return { + "/Users": users if users is not None else [{"Id": "u1", "Name": "roger"}], + "/UserItems/Resume": {"Items": resume or []}, + "/Shows/NextUp": {"Items": next_up or []}, + "/Shows/s1/Episodes": {"Items": episodes or []}, + } + + +class TestPathTranslation: + def test_longest_prefix_wins(self): + m = _manager({}) + assert m.to_plex_path("/media/en/Movies/a.mkv") == "/mnt/user/plex-media/Movies/a.mkv" + assert m.to_plex_path("/media/de/Movies/a.mkv") == "/data/de/Movies/a.mkv" + + def test_unmapped_path_is_dropped(self): + m = _manager({}) + assert m.to_plex_path("/other/a.mkv") is None + assert m._item_paths({"Path": "/other/a.mkv"}) == [] + + def test_all_versions_and_path_fallback(self): + m = _manager({}) + item = {"MediaSources": [{"Path": "/media/en/a.mkv"}, {"Path": "/media/en/a-4k.mkv"}]} + assert m._item_paths(item) == ["/mnt/user/plex-media/a.mkv", "/mnt/user/plex-media/a-4k.mkv"] + assert m._item_paths({"Path": "/media/en/b.mkv"}) == ["/mnt/user/plex-media/b.mkv"] + + def test_auth_header(self): + m = _manager({}) + assert m.session.headers["Authorization"] == 'MediaBrowser Client="PlexCache-D", Token="key"' + + +class TestOnDeck: + def test_movie_and_episode_with_prefetch(self): + movie = {"Id": "m1", "Type": "Movie", "Name": "Film", "Path": "/media/en/Movies/film.mkv", + "UserData": {"LastPlayedDate": _ago(1)}} + current = _episode("e2", 1, 2, "/media/en/TV/s01e02.mkv") + episodes = [_episode(f"e{i}", 1, i, f"/media/en/TV/s01e0{i}.mkv") for i in range(1, 6)] + m = _manager(_routes(resume=[movie], next_up=[current], episodes=episodes)) + + items = m.get_on_deck_media(days_to_monitor=30, number_episodes=2) + + assert [(i.file_path, i.is_current_ondeck) for i in items] == [ + ("/mnt/user/plex-media/Movies/film.mkv", True), + ("/mnt/user/plex-media/TV/s01e02.mkv", True), + ("/mnt/user/plex-media/TV/s01e03.mkv", False), + ("/mnt/user/plex-media/TV/s01e04.mkv", False), + ] + assert all(i.username == "roger" for i in items) + assert items[1].episode_info == {"show": "Show", "season": 1, "episode": 2} + assert items[3].episode_info == {"show": "Show", "season": 1, "episode": 4} + assert m.is_ondeck_data_complete() + + def test_prefetch_minimum_minutes_uses_runtime(self): + current = _episode("e1", 1, 1, "/media/en/TV/e1.mkv") + episodes = [_episode(f"e{i}", 1, i, f"/media/en/TV/e{i}.mkv", runtime_min=20) for i in range(1, 8)] + m = _manager(_routes(next_up=[current], episodes=episodes)) + + items = m.get_on_deck_media(days_to_monitor=30, number_episodes=1, prefetch_minimum_minutes=60) + + # 3 x 20 min needed to cover 60 minutes + assert len([i for i in items if not i.is_current_ondeck]) == 3 + + def test_item_in_resume_and_next_up_counted_once(self): + current = _episode("e1", 1, 1, "/media/en/TV/e1.mkv", last_played=_ago(1)) + m = _manager(_routes(resume=[current], next_up=[current], episodes=[current])) + + items = m.get_on_deck_media(days_to_monitor=30, number_episodes=1) + + assert [i.file_path for i in items] == ["/mnt/user/plex-media/TV/e1.mkv"] + + def test_stale_resume_item_is_skipped(self): + movie = {"Id": "m1", "Type": "Movie", "Path": "/media/en/old.mkv", + "UserData": {"LastPlayedDate": _ago(90)}} + m = _manager(_routes(resume=[movie])) + + assert m.get_on_deck_media(days_to_monitor=30, number_episodes=1) == [] + + def test_next_up_cutoff_sent_to_server(self): + m = _manager(_routes()) + m.get_on_deck_media(days_to_monitor=30, number_episodes=1) + + params = next(p for path, p in m.session.calls if path == "/Shows/NextUp") + cutoff = datetime.strptime(params["nextUpDateCutoff"], '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc) + assert abs((datetime.now(timezone.utc) - cutoff) - timedelta(days=30)) < timedelta(minutes=1) + + def test_skip_users(self): + movie = {"Id": "m1", "Type": "Movie", "Path": "/media/en/a.mkv"} + users = [{"Id": "u1", "Name": "roger"}, {"Id": "u2", "Name": "kid"}] + m = _manager(_routes(resume=[movie], users=users), skip_users=["kid"]) + + items = m.get_on_deck_media(days_to_monitor=30, number_episodes=1) + + assert [i.username for i in items] == ["roger"] + assert [p["userId"] for path, p in m.session.calls if path == "/UserItems/Resume"] == ["u1"] + + @pytest.mark.critical + def test_user_list_failure_marks_incomplete(self): + m = _manager({"/Users": RuntimeError("down")}) + + assert m.get_on_deck_media(days_to_monitor=30, number_episodes=1) == [] + assert not m.is_ondeck_data_complete() + + @pytest.mark.critical + def test_single_user_failure_marks_incomplete(self): + routes = _routes() + routes["/UserItems/Resume"] = RuntimeError("timeout") + m = _manager(routes) + + m.get_on_deck_media(days_to_monitor=30, number_episodes=1) + + assert not m.is_ondeck_data_complete() + + def test_complete_flag_resets_on_next_run(self): + routes = _routes() + routes["/Users"] = RuntimeError("down") + m = _manager(routes) + m.get_on_deck_media(days_to_monitor=30, number_episodes=1) + routes["/Users"] = [{"Id": "u1", "Name": "roger"}] + + m.get_on_deck_media(days_to_monitor=30, number_episodes=1) + + assert m.is_ondeck_data_complete() + + +class TestActiveSessions: + def test_path_from_now_playing_item(self): + sessions = [{"UserId": "u1", "NowPlayingItem": {"Id": "m1", "Path": "/media/en/a.mkv"}}, {"UserId": "u2"}] + m = _manager({"/Sessions": sessions}) + + assert m.get_active_session_paths() == ["/mnt/user/plex-media/a.mkv"] + + def test_fetches_item_when_path_missing(self): + sessions = [{"UserId": "u1", "NowPlayingItem": {"Id": "m1"}}] + m = _manager({"/Sessions": sessions, "/Items": {"Items": [{"Path": "/media/en/a.mkv"}]}}) + + assert m.get_active_session_paths() == ["/mnt/user/plex-media/a.mkv"] + assert m.session.calls[-1][1]["userId"] == "u1" + + def test_failure_returns_empty(self): + m = _manager({"/Sessions": RuntimeError("down")}) + + assert m.get_active_session_paths() == [] + + +class TestConfig: + def _load(self, tmp_path, monkeypatch, settings, env=None): + from core.config import ConfigManager + for key in ("JELLYFIN_URL", "JELLYFIN_API_KEY", "JELLYFIN_PATH_MAPPINGS", "JELLYFIN_SKIP_USERS"): + monkeypatch.delenv(key, raising=False) + for key, value in (env or {}).items(): + monkeypatch.setenv(key, value) + cm = ConfigManager(str(tmp_path / "settings.json")) + cm.settings_data = settings + cm._load_jellyfin_config() + return cm.jellyfin + + def test_disabled_by_default(self, tmp_path, monkeypatch): + assert not self._load(tmp_path, monkeypatch, {}).enabled + + def test_settings_file(self, tmp_path, monkeypatch): + cfg = self._load(tmp_path, monkeypatch, { + "jellyfin_url": "http://jf", "jellyfin_api_key": "k", + "jellyfin_path_mappings": MAPPINGS, "jellyfin_skip_users": ["kid"], + }) + assert cfg.enabled + assert cfg.path_mappings == MAPPINGS + assert cfg.skip_users == ["kid"] + + def test_env_overrides_settings(self, tmp_path, monkeypatch): + cfg = self._load(tmp_path, monkeypatch, {"jellyfin_url": "http://file", "jellyfin_api_key": "file"}, + env={"JELLYFIN_URL": "http://env", "JELLYFIN_API_KEY": "env"}) + assert (cfg.url, cfg.api_key) == ("http://env", "env") + + def test_env_mappings_and_skip_users(self, tmp_path, monkeypatch): + cfg = self._load(tmp_path, monkeypatch, {"jellyfin_skip_users": ["file"]}, env={ + "JELLYFIN_PATH_MAPPINGS": '{"/media/en/": "/mnt/user/plex-media/"}', + "JELLYFIN_SKIP_USERS": "kid, guest", + }) + assert cfg.path_mappings == {"/media/en/": "/mnt/user/plex-media/"} + assert cfg.skip_users == ["kid", "guest"] + + def test_invalid_env_mappings_keep_settings(self, tmp_path, monkeypatch): + cfg = self._load(tmp_path, monkeypatch, {"jellyfin_path_mappings": MAPPINGS}, + env={"JELLYFIN_PATH_MAPPINGS": "not json"}) + assert cfg.path_mappings == MAPPINGS