Skip to content
Open
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Collaborator

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/Resume isn'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.


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.
Expand Down
39 changes: 38 additions & 1 deletion core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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...")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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..."

get_active_session_paths() only returns mapped paths, so a session on an unmapped path looks like no session and exit_if_active_session doesn't fire. It would help to have it report the session separately from the paths.

The [FILTER] Skipped N active session(s) line sits inside if sessions, so Jellyfin-only skips never show up at INFO. It should cover both.

The log analyzer also matches this text. docs/index.html has /There is an active session\. Exiting/i, which should become /There is an active session.*Exiting/i. The timeline pattern /active session.*Exiting/i already works with the wording above, but neither one matches "active Jellyfin session" as it's written now.

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...')
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)")
Expand Down
39 changes: 38 additions & 1 deletion core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid JSON that isn't an object, like [], gets past this and then raises on .items() when the manager is created. A bad setting on an optional integration shouldn't stop a Plex run, so please check it's a dict and log an error if it isn't.

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.

Expand Down
191 changes: 191 additions & 0 deletions core/jellyfin_api.py
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prefixes aren't normalized. {"/media": "/mnt/user/plex-media/"} also matches /media2/... and produces // in the path, and I'd expect that to carry through into the tracker and exclude list entries. Could this normalize trailing slashes on both sides?

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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be INFO, as Plex's [CONFIG] Skipping unmapped path is. At DEBUG, a wrong mapping just looks like "Jellyfin OnDeck: 0 items" and nothing tells the user why. One line per library prefix would be enough.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pulls every episode of the show with MediaSources, once per Next Up item and per user, under a 30s timeout. A long-running show on a slow NAS could plausibly time out, and that then runs into the problem above. /Shows/{seriesId}/Episodes supports startItemId, so passing the current episode would skip everything before it.

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
3 changes: 2 additions & 1 deletion core/plex_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading