#!/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())