diff --git a/Dockers/plex-excluder/.dockerignore b/Dockers/plex-excluder/.dockerignore new file mode 100644 index 0000000..6862a78 --- /dev/null +++ b/Dockers/plex-excluder/.dockerignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv/ +venv/ +.git/ +.idea/ +.vscode/ +*.md diff --git a/Dockers/plex-excluder/.env.example b/Dockers/plex-excluder/.env.example new file mode 100644 index 0000000..3f9c6de --- /dev/null +++ b/Dockers/plex-excluder/.env.example @@ -0,0 +1,22 @@ +PLEX_URL=http://10.2.0.100:32400 +PLEX_TOKEN= +JELLYSEERR_URL=http://10.2.0.102:4022 +JELLYSEERR_API_KEY= + +# Formaat: Label:user1,user2;AnderLabel:user3 +# Die users mogen het label zien; alle andere Plex-users krijgen label! exclusion. +LABEL_ACCESS=Debby:debby961 + +# Cron (5-veld) of macro: @hourly @daily @weekly … +CRON_SCHEDULE=0 */6 * * * +RUN_ON_START=true +DRY_RUN=false + +TZ=Europe/Amsterdam + +# Alleen nodig als Plex andere paden gebruikt dan de mounts hieronder. +# Defaults: /mnt/Series en /mnt/Movies (mount je volumes daarheen). +# PLEX_SERIES_PREFIX=/mnt/Series +# PLEX_MOVIES_PREFIX=/mnt/Movies +# LOCAL_SERIES_ROOT=/mnt/Series +# LOCAL_MOVIES_ROOT=/mnt/Movies diff --git a/Dockers/plex-excluder/.gitignore b/Dockers/plex-excluder/.gitignore new file mode 100644 index 0000000..ac7d762 --- /dev/null +++ b/Dockers/plex-excluder/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.pyc +.venv/ +venv/ diff --git a/Dockers/plex-excluder/Dockerfile b/Dockers/plex-excluder/Dockerfile new file mode 100644 index 0000000..c66461f --- /dev/null +++ b/Dockers/plex-excluder/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + TZ=Europe/Amsterdam \ + PLEX_SERIES_PREFIX=/mnt/Series \ + PLEX_MOVIES_PREFIX=/mnt/Movies \ + CRON_SCHEDULE="0 */6 * * *" \ + RUN_ON_START=true \ + DRY_RUN=false + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends tzdata ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY sync.py entrypoint.py ./ + +ENTRYPOINT ["python", "entrypoint.py"] diff --git a/Dockers/plex-excluder/docker-compose.example.yml b/Dockers/plex-excluder/docker-compose.example.yml new file mode 100644 index 0000000..6bfbdb3 --- /dev/null +++ b/Dockers/plex-excluder/docker-compose.example.yml @@ -0,0 +1,18 @@ +services: + plex-label-sync: + build: . + container_name: plex-label-sync + env_file: + - .env + environment: + TZ: Europe/Amsterdam + # Label:users — meerdere labels met ; gescheiden + # LABEL_ACCESS: "Debby:debby961;Kids:alice,bob" + CRON_SCHEDULE: "0 */6 * * *" + RUN_ON_START: "true" + DRY_RUN: "false" + volumes: + # Zelfde paden als in Plex → geen LOCAL_*/PLEX_* prefix-env nodig + - /path/to/Series:/mnt/Series + - /path/to/Movies:/mnt/Movies + restart: unless-stopped diff --git a/Dockers/plex-excluder/entrypoint.py b/Dockers/plex-excluder/entrypoint.py new file mode 100644 index 0000000..ae87c89 --- /dev/null +++ b/Dockers/plex-excluder/entrypoint.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Run plex-label-sync on a cron schedule from env.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +from datetime import datetime +from zoneinfo import ZoneInfo + +from croniter import croniter + +CRON_MACROS = { + '@yearly': '0 0 1 1 *', + '@annually': '0 0 1 1 *', + '@monthly': '0 0 1 * *', + '@weekly': '0 0 * * 0', + '@daily': '0 0 * * *', + '@midnight': '0 0 * * *', + '@hourly': '0 * * * *', +} + + +def log(msg: str) -> None: + print(f"[{datetime.now().astimezone().isoformat(timespec='seconds')}] {msg}", flush=True) + + +def zone() -> ZoneInfo: + name = os.environ.get('TZ') or 'UTC' + try: + return ZoneInfo(name) + except Exception as exc: + raise SystemExit(f"Invalid TZ '{name}': {exc}") from exc + + +def resolve_cron_expr() -> str: + for key in ('CRON_SCHEDULE', 'CRON', 'SYNC_CRON'): + value = os.environ.get(key, '').strip() + if value: + expr = CRON_MACROS.get(value.lower(), value) + if not croniter.is_valid(expr): + raise SystemExit( + f"Invalid {key} '{value}' (expected a 5-field cron expression)" + ) + return expr + return '0 */6 * * *' + + +def next_run(expr: str, after: datetime) -> datetime: + return croniter(expr, after).get_next(datetime) + + +def run_sync() -> int: + log('Starting sync…') + result = subprocess.run([sys.executable, '/app/sync.py'], check=False) + log(f'Sync finished with code {result.returncode}.') + return result.returncode + + +def main() -> int: + tz = zone() + expr = resolve_cron_expr() + run_on_start = os.environ.get('RUN_ON_START', 'true').lower() in {'1', 'true', 'yes'} + + log(f"plex-label-sync scheduler (TZ={tz.key}, CRON_SCHEDULE='{expr}')") + + if run_on_start: + run_sync() + + while True: + now = datetime.now(tz=tz) + nxt = next_run(expr, now) + sleep_for = max(1.0, (nxt - now).total_seconds()) + log(f"Next run at {nxt.isoformat(timespec='seconds')} (sleep {sleep_for:.0f}s)") + time.sleep(sleep_for) + run_sync() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/Dockers/plex-excluder/requirements.txt b/Dockers/plex-excluder/requirements.txt new file mode 100644 index 0000000..4a2e502 --- /dev/null +++ b/Dockers/plex-excluder/requirements.txt @@ -0,0 +1,4 @@ +plexapi +requests +python-dotenv +croniter diff --git a/Dockers/plex-excluder/sync.py b/Dockers/plex-excluder/sync.py new file mode 100644 index 0000000..41250ef --- /dev/null +++ b/Dockers/plex-excluder/sync.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""Label Jellyseerr requests in Plex and exclude those labels from other users.""" + +from __future__ import annotations + +from pathlib import Path +import os +import sys + +import requests +from dotenv import load_dotenv +from plexapi.server import PlexServer + +load_dotenv() + +PLEX_URL = os.environ['PLEX_URL'] +PLEX_TOKEN = os.environ['PLEX_TOKEN'] +JELLYSEERR_URL = os.environ['JELLYSEERR_URL'].rstrip('/') +JELLYSEERR_API_KEY = os.environ['JELLYSEERR_API_KEY'] +PLEX_SERIES_PREFIX = os.environ.get('PLEX_SERIES_PREFIX', '/mnt/Series') +LOCAL_SERIES_ROOT = Path(os.environ.get('LOCAL_SERIES_ROOT', PLEX_SERIES_PREFIX)) +PLEX_MOVIES_PREFIX = os.environ.get('PLEX_MOVIES_PREFIX', '/mnt/Movies') +LOCAL_MOVIES_ROOT = Path(os.environ.get('LOCAL_MOVIES_ROOT', PLEX_MOVIES_PREFIX)) +IGNORE_FILENAME = '.ignore' +DRY_RUN = os.environ.get('DRY_RUN', 'false').lower() in {'1', 'true', 'yes'} + + +def parse_label_access(raw: str) -> dict[str, list[str]]: + """Parse LABEL_ACCESS=Debby:debby961;Kids:alice,bob into {label: [users]}.""" + mapping: dict[str, list[str]] = {} + for entry in raw.split(';'): + entry = entry.strip() + if not entry: + continue + if ':' not in entry: + raise SystemExit( + f"Ongeldige LABEL_ACCESS entry '{entry}' " + "(verwacht Label:user1,user2;AnderLabel:user3)" + ) + label, users_raw = entry.split(':', 1) + label = label.strip() + users = [name.strip() for name in users_raw.split(',') if name.strip()] + if not label or not users: + raise SystemExit( + f"Ongeldige LABEL_ACCESS entry '{entry}' " + "(label en minstens één gebruiker vereist)" + ) + mapping[label] = users + if not mapping: + raise SystemExit( + 'LABEL_ACCESS is leeg. Voorbeeld: Debby:debby961;Kids:alice,bob' + ) + return mapping + + +def load_label_access() -> dict[str, list[str]]: + raw = os.environ.get('LABEL_ACCESS', '').strip() + if raw: + return parse_label_access(raw) + + # Backward-compatible single-label env vars + label = os.environ.get('LABEL', '').strip() + plex_user = os.environ.get('PLEX_USER', '').strip() + skip_users = [ + name.strip() + for name in os.environ.get('SKIP_USERS', plex_user).split(',') + if name.strip() + ] + if label and skip_users: + return {label: skip_users} + raise SystemExit( + 'Stel LABEL_ACCESS in (bijv. Debby:debby961) ' + 'of LABEL + SKIP_USERS / PLEX_USER.' + ) + + +LABEL_ACCESS = load_label_access() + +jellyseerr = requests.Session() +jellyseerr.headers['X-Api-Key'] = JELLYSEERR_API_KEY +jellyseerr.headers['Accept'] = 'application/json' + +plex = PlexServer(PLEX_URL, PLEX_TOKEN) +account = plex.myPlexAccount() + + +def jellyseerr_get(path, params=None): + response = jellyseerr.get(f'{JELLYSEERR_URL}/api/v1{path}', params=params, timeout=30) + response.raise_for_status() + return response.json() + + +def find_jellyseerr_user(plex_username): + data = jellyseerr_get('/user', params={'q': plex_username, 'take': 50}) + needle = plex_username.lower() + for user in data.get('results', []): + fields = [ + user.get('plexUsername'), + user.get('username'), + user.get('displayName'), + user.get('email'), + ] + if any(field and field.lower() == needle for field in fields): + return user + raise SystemExit(f'Jellyseerr-gebruiker niet gevonden: {plex_username}') + + +def fetch_user_requests(user_id): + skip = 0 + take = 50 + requests_found = [] + while True: + data = jellyseerr_get(f'/user/{user_id}/requests', params={'take': take, 'skip': skip}) + page = data.get('results', []) + requests_found.extend(page) + total = data.get('pageInfo', {}).get('results', 0) + skip += take + if skip >= total or not page: + break + return requests_found + + +def media_title(request_type, tmdb_id, fallback): + endpoint = f'/tv/{tmdb_id}' if request_type == 'tv' else f'/movie/{tmdb_id}' + title_key = 'name' if request_type == 'tv' else 'title' + try: + details = jellyseerr_get(endpoint) + return details.get(title_key) or fallback + except Exception: + return fallback + + +def find_plex_item(media, request_type): + libtype = 'show' if request_type == 'tv' else 'movie' + rating_key = media.get('ratingKey') or media.get('ratingKey4k') + if rating_key: + try: + item = plex.fetchItem(int(rating_key)) + item_type = getattr(item, 'type', None) + if request_type == 'tv': + if item_type == 'show': + return item + if item_type in {'season', 'episode'} and getattr(item, 'show', None): + return item.show() + elif item_type == 'movie': + return item + except Exception: + pass + + guids = [] + tmdb_id = media.get('tmdbId') + if tmdb_id: + guids.append(f'tmdb://{tmdb_id}') + if request_type == 'tv': + tvdb_id = media.get('tvdbId') + if tvdb_id: + guids.append(f'tvdb://{tvdb_id}') + + for guid in guids: + matches = plex.library.search(libtype=libtype, guid=guid) + if matches: + return matches[0] + return None + + +def has_label(item, label): + return any(tag.tag == label for tag in getattr(item, 'labels', [])) + + +def local_media_dirs(item, plex_prefix, local_root): + """Map Plex library paths to local folders.""" + dirs = [] + for location in getattr(item, 'locations', None) or []: + path = Path(location) + try: + relative = path.relative_to(plex_prefix) + except ValueError: + if path.is_absolute() and local_root in path.parents: + dirs.append(path if path.is_dir() else path.parent) + continue + local = local_root / relative + dirs.append(local if local.is_dir() or not local.suffix else local.parent) + return dirs + + +def ensure_ignore_file(media_dir): + ignore_path = media_dir / IGNORE_FILENAME + if not media_dir.is_dir(): + return 'missing_dir', ignore_path + if ignore_path.exists(): + return 'exists', ignore_path + if DRY_RUN: + return 'would_create', ignore_path + ignore_path.touch() + return 'created', ignore_path + + +def parse_filters(raw): + """Zet Plex filterstring om naar dict, bijv. {'label!': ['Kids'], 'contentRating': ['G']}.""" + filters = {} + if not raw: + return filters + + decoded = raw.replace('%2C', ',').replace('%20', ' ') + for part in decoded.replace('|', '&').split('&'): + if '=' not in part: + continue + key, value = part.split('=', 1) + labels = [item.strip() for item in value.split(',') if item.strip()] + if labels: + filters[key] = labels + return filters + + +def build_filter_string(filters): + parts = [] + for key, values in filters.items(): + if values: + parts.append(f"{key}={','.join(values)}") + return '&'.join(parts) + + +def user_matches(user, names): + fields = [user.title, getattr(user, 'username', None), getattr(user, 'email', None)] + lowered = {name.lower() for name in names} + return any(field and field.lower() in lowered for field in fields) + + +def display_name(user): + extras = [value for value in (user.username, user.email) if value and value != user.title] + if extras: + return f"{user.title} ({', '.join(extras)})" + return user.title + + +def apply_label_exclusions(filters, allowed_labels: set[str], excluded_labels: set[str]): + """Zorg dat allowed labels zichtbaar blijven en excluded labels in label! staan.""" + current = list(filters.get('label!', [])) + changed = False + + for label in allowed_labels: + if label in current: + current = [item for item in current if item != label] + changed = True + + for label in excluded_labels: + if label not in current: + current.append(label) + changed = True + + filters = dict(filters) + if current: + filters['label!'] = current + elif 'label!' in filters: + del filters['label!'] + changed = True + return filters, changed + + +def update_user_filters(user_id, filter_television=None, filter_movies=None): + params = {'X-Plex-Token': PLEX_TOKEN} + if filter_television is not None: + params['filterTelevision'] = filter_television + if filter_movies is not None: + params['filterMovies'] = filter_movies + response = requests.put( + f'https://plex.tv/api/users/{user_id}', + params=params, + timeout=30, + ) + response.raise_for_status() + return response + + +def process_requests(requests_list, request_type, label, plex_prefix, local_root): + kind = 'TV' if request_type == 'tv' else 'Film' + print(f"\n=== [{label}] {kind}-requests: {len(requests_list)} ===") + + labeled = 0 + already = 0 + missing = 0 + failed = 0 + ignore_created = 0 + ignore_exists = 0 + ignore_missing_dir = 0 + ignore_failed = 0 + seen_keys = set() + + for request in requests_list: + media = request.get('media') or {} + tmdb_id = media.get('tmdbId') + dedupe_key = media.get('ratingKey') or media.get('ratingKey4k') or tmdb_id + if dedupe_key in seen_keys: + continue + seen_keys.add(dedupe_key) + + title = media_title(request_type, tmdb_id, media.get('externalServiceSlug') or f'tmdb:{tmdb_id}') + item = find_plex_item(media, request_type) + + if not item: + print(f"Niet in Plex: {title}") + missing += 1 + continue + + if has_label(item, label): + print(f"Al gelabeld: {item.title}") + already += 1 + else: + print(f"Label toevoegen ({label}): {item.title}") + if not DRY_RUN: + try: + item.addLabel(label) + labeled += 1 + except Exception as error: + print(f" Fout label: {error}") + failed += 1 + else: + labeled += 1 + + media_dirs = local_media_dirs(item, plex_prefix, local_root) + if not media_dirs: + print(f" Geen lokale map gevonden voor {item.title}") + ignore_missing_dir += 1 + continue + + for media_dir in media_dirs: + try: + status, ignore_path = ensure_ignore_file(media_dir) + except Exception as error: + print(f" Fout .ignore in {media_dir}: {error}") + ignore_failed += 1 + continue + + if status == 'created': + print(f" .ignore gezet: {ignore_path}") + ignore_created += 1 + elif status == 'would_create': + print(f" zou .ignore zetten: {ignore_path}") + ignore_created += 1 + elif status == 'exists': + print(f" .ignore bestond al: {ignore_path}") + ignore_exists += 1 + elif status == 'missing_dir': + print(f" map ontbreekt lokaal: {media_dir}") + ignore_missing_dir += 1 + + action = 'zou labelen' if DRY_RUN else 'gelabeld' + ignore_action = 'zou zetten' if DRY_RUN else 'gezet' + print( + f"\n[{label}] {kind} klaar: {labeled} {action}, {already} hadden het label al, " + f"{missing} niet in Plex, {failed} label-fouten." + ) + print( + f".ignore: {ignore_created} {ignore_action}, {ignore_exists} bestonden al, " + f"{ignore_missing_dir} map ontbreekt, {ignore_failed} fouten." + ) + + +def sync_label_from_jellyseerr(label: str, plex_usernames: list[str]): + seen_user_ids = set() + for plex_username in plex_usernames: + user = find_jellyseerr_user(plex_username) + user_id = user['id'] + if user_id in seen_user_ids: + continue + seen_user_ids.add(user_id) + + print( + f"\nJellyseerr-gebruiker voor label '{label}': " + f"{user.get('displayName')} (id {user_id}, {user.get('requestCount')} requests) " + f"[match: {plex_username}]" + ) + + all_requests = fetch_user_requests(user_id) + tv_requests = [item for item in all_requests if item.get('type') == 'tv'] + movie_requests = [item for item in all_requests if item.get('type') == 'movie'] + + process_requests(tv_requests, 'tv', label, PLEX_SERIES_PREFIX, LOCAL_SERIES_ROOT) + process_requests(movie_requests, 'movie', label, PLEX_MOVIES_PREFIX, LOCAL_MOVIES_ROOT) + + +def sync_user_filters(): + print('\n=== Plex-filters (TV + Films) ===') + all_labels = set(LABEL_ACCESS) + updated = 0 + skipped = 0 + failed = 0 + + for user in account.users(): + name = display_name(user) + allowed = {label for label, users in LABEL_ACCESS.items() if user_matches(user, users)} + excluded = all_labels - allowed + + tv_filters, tv_changed = apply_label_exclusions( + parse_filters(user.filterTelevision), allowed, excluded + ) + movie_filters, movie_changed = apply_label_exclusions( + parse_filters(user.filterMovies), allowed, excluded + ) + + if not tv_changed and not movie_changed: + print(f"Al ingesteld: {name} (zichtbaar: {sorted(allowed) or '-'})") + skipped += 1 + continue + + tv_string = build_filter_string(tv_filters) + movie_string = build_filter_string(movie_filters) + + print(f"Aanpassen: {name}") + print(f" mag zien: {sorted(allowed) or '-'}") + print(f" exclude: {sorted(excluded) or '-'}") + if tv_changed: + print(f" TV-filters: {tv_string or '(leeg)'}") + if movie_changed: + print(f" Film-filters: {movie_string or '(leeg)'}") + + if DRY_RUN: + updated += 1 + continue + + try: + update_user_filters( + user.id, + filter_television=tv_string if tv_changed else None, + filter_movies=movie_string if movie_changed else None, + ) + updated += 1 + except Exception as error: + print(f" Fout: {error}") + failed += 1 + + action = 'zou aanpassen' if DRY_RUN else 'aangepast' + print(f"\nFilters: {updated} gebruiker(s) {action}, {skipped} overgeslagen, {failed} mislukt.") + + +def main(): + if DRY_RUN: + print('DRY_RUN actief — er wordt niets weggeschreven.\n') + + print('Label-toegang:') + for label, users in LABEL_ACCESS.items(): + print(f" {label}: {', '.join(users)}") + + for label, users in LABEL_ACCESS.items(): + sync_label_from_jellyseerr(label, users) + + sync_user_filters() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/Dockers/plex-excluder/version b/Dockers/plex-excluder/version new file mode 100644 index 0000000..a0f9a4b --- /dev/null +++ b/Dockers/plex-excluder/version @@ -0,0 +1 @@ +latest