plex excluder
Build and Push Docker Images / build-and-push (push) Successful in 1m14s

This commit is contained in:
2026-09-05 23:25:26 +02:00
parent 07924fe892
commit b20ebbfca6
9 changed files with 617 additions and 0 deletions
+452
View File
@@ -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())