Repository navigation
Jellyfin Integration #234
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Jellyfin Integration #234
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 [] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A few things here: The messages should name the server that actually has the session. Right now the generic message means Plex and the other one says Jellyfin. Something like this would cover Plex, Jellyfin or both: active_servers = [name for name, found in (("Plex", sessions), ("Jellyfin", jellyfin_sessions)) if found]
if active_servers:
message = f"There is an active session on {' and '.join(active_servers)}. Exiting..."
The The log analyzer also matches this text. |
||
| 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)") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid JSON that isn't an object, like |
||
| 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. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The prefixes aren't normalized. |
||
| 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") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be INFO, as Plex's |
||
| 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, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The list only comes back at the end, so one failed request loses everything already collected for that user. Could each item get its own try/except, so the failure only costs that item and still marks the data incomplete? We made the same kind of change on the Plex side in #204. It's also worth remembering that incomplete Jellyfin data skips array restore for Plex files as well. I think that's the right call for safety, but the warning should make it obvious that Jellyfin is what's holding things up, especially for people running it as a backup server. |
||
| 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, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This pulls every episode of the show with |
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add that this needs Jellyfin 10.9 or newer.
/UserItems/Resumeisn't in 10.8.13, so on an older server every user fetch would 404, the data would count as incomplete, and nothing would ever move back to the array.