Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b20ebbfca6 | ||
|
|
07924fe892 | ||
|
|
23a2d1964a |
@@ -0,0 +1,13 @@
|
|||||||
|
FROM caddy:2-alpine
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
ENV LISTEN_PORT=8080 \
|
||||||
|
TARGET_URL="" \
|
||||||
|
PROXY_INSECURE_TLS=0 \
|
||||||
|
PRESERVE_HOST=0
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
dns-proxy:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
TARGET_URL: "https://internal.example.com"
|
||||||
|
# PROXY_INSECURE_TLS: "1" # if upstream uses a self-signed cert
|
||||||
|
# PRESERVE_HOST: "1" # keep the client's Host header instead of the upstream host
|
||||||
|
dns:
|
||||||
|
- 10.0.0.53 # custom DNS required to resolve TARGET_URL
|
||||||
|
# Extra hosts are also an option instead of / alongside custom DNS:
|
||||||
|
# extra_hosts:
|
||||||
|
# - "internal.example.com:10.0.0.10"
|
||||||
|
restart: unless-stopped
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
TARGET_URL="${TARGET_URL:-}"
|
||||||
|
LISTEN_PORT="${LISTEN_PORT:-8080}"
|
||||||
|
PROXY_INSECURE_TLS="${PROXY_INSECURE_TLS:-0}"
|
||||||
|
PRESERVE_HOST="${PRESERVE_HOST:-0}"
|
||||||
|
|
||||||
|
if [ -z "$TARGET_URL" ]; then
|
||||||
|
echo "TARGET_URL is required (e.g. https://internal.example.com)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$TARGET_URL" in
|
||||||
|
http://*|https://*) ;;
|
||||||
|
*)
|
||||||
|
echo "TARGET_URL must start with http:// or https://" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Strip trailing slash so paths concatenate cleanly
|
||||||
|
TARGET_URL="${TARGET_URL%/}"
|
||||||
|
|
||||||
|
CADDYFILE="/etc/caddy/Caddyfile"
|
||||||
|
mkdir -p /etc/caddy
|
||||||
|
|
||||||
|
EXTRA_DIRECTIVES=""
|
||||||
|
|
||||||
|
if [ "$PRESERVE_HOST" = "1" ]; then
|
||||||
|
EXTRA_DIRECTIVES="${EXTRA_DIRECTIVES}
|
||||||
|
header_up Host {http.request.host}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PROXY_INSECURE_TLS" = "1" ]; then
|
||||||
|
EXTRA_DIRECTIVES="${EXTRA_DIRECTIVES}
|
||||||
|
transport http {
|
||||||
|
tls_insecure_skip_verify
|
||||||
|
}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$CADDYFILE" <<EOF
|
||||||
|
{
|
||||||
|
admin off
|
||||||
|
auto_https off
|
||||||
|
}
|
||||||
|
|
||||||
|
:${LISTEN_PORT} {
|
||||||
|
reverse_proxy ${TARGET_URL} {${EXTRA_DIRECTIVES}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Proxying :${LISTEN_PORT} -> ${TARGET_URL}"
|
||||||
|
if [ "$PROXY_INSECURE_TLS" = "1" ]; then
|
||||||
|
echo "TLS verification disabled for upstream"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec caddy run --config "$CADDYFILE" --adapter caddyfile
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.git/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
plexapi
|
||||||
|
requests
|
||||||
|
python-dotenv
|
||||||
|
croniter
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
latest
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
name: "Personal Recordings"
|
name: "Personal Recordings"
|
||||||
guid: "7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a"
|
guid: "7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a"
|
||||||
version: "1.0.0.0"
|
version: "1.0.1.0"
|
||||||
targetAbi: "10.11.0.0"
|
targetAbi: "10.11.0.0"
|
||||||
framework: "net9.0"
|
framework: "net9.0"
|
||||||
owner: "bram"
|
owner: "bram"
|
||||||
|
|||||||
+35
-27
@@ -1,28 +1,36 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"guid": "7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a",
|
"guid": "7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a",
|
||||||
"name": "Personal Recordings",
|
"name": "Personal Recordings",
|
||||||
"description": "Tracks which Jellyfin user scheduled a Live TV recording and moves completed files into /recordings/<Username>/.",
|
"description": "Tracks which Jellyfin user scheduled a Live TV recording and moves completed files into /recordings/<Username>/.",
|
||||||
"overview": "Move completed Live TV recordings into per-user folders",
|
"overview": "Move completed Live TV recordings into per-user folders",
|
||||||
"owner": "bram",
|
"owner": "bram",
|
||||||
"category": "Live TV",
|
"category": "Live TV",
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
{
|
||||||
"version": "1.0.0.0",
|
"version": "1.0.1.0",
|
||||||
"changelog": "Release 1.0.0",
|
"changelog": "Release 1.0.1",
|
||||||
"targetAbi": "10.11.0.0",
|
"targetAbi": "10.11.0.0",
|
||||||
"sourceUrl": "https://gitea.bramkelchtermans.be/Bram/projects/releases/download/jellyfin-plugin-personal-recordings-v1.0.0/personal-recordings_1.0.0.0.zip",
|
"sourceUrl": "https://gitea.bramkelchtermans.be/Bram/projects/releases/download/jellyfin-plugin-personal-recordings-v1.0.1/personal-recordings_1.0.1.0.zip",
|
||||||
"checksum": "63ed4d39770330b8fd714f116cd57f4c1e8744b88afb9865ccbed6599f043b49",
|
"checksum": "69bb2094dc7eb81e674e0afac42cedfd",
|
||||||
"timestamp": "2026-08-22T20:19:39Z"
|
"timestamp": "2026-08-22T20:31:14Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.0.0",
|
"version": "1.0.0.0",
|
||||||
"changelog": "Release 1.0.0",
|
"changelog": "Release 1.0.0",
|
||||||
"targetAbi": "10.11.0.0",
|
"targetAbi": "10.11.0.0",
|
||||||
"sourceUrl": "https://gitea.bramkelchtermans.be/Bram/projects/releases/download/jellyfin-plugin-personal-recordings-v1.0.0/personal-recordings_1.0.0.0.zip",
|
"sourceUrl": "https://gitea.bramkelchtermans.be/Bram/projects/releases/download/jellyfin-plugin-personal-recordings-v1.0.0/personal-recordings_1.0.0.0.zip",
|
||||||
"checksum": "66cd0155334fd10b77d35af8ddba07d0e2e60fa886bd63369a5e0d4ce289a441",
|
"checksum": "63ed4d39770330b8fd714f116cd57f4c1e8744b88afb9865ccbed6599f043b49",
|
||||||
"timestamp": "2026-08-22T20:07:03Z"
|
"timestamp": "2026-08-22T20:19:39Z"
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
}
|
"version": "1.0.0.0",
|
||||||
]
|
"changelog": "Release 1.0.0",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.bramkelchtermans.be/Bram/projects/releases/download/jellyfin-plugin-personal-recordings-v1.0.0/personal-recordings_1.0.0.0.zip",
|
||||||
|
"checksum": "66cd0155334fd10b77d35af8ddba07d0e2e60fa886bd63369a5e0d4ce289a441",
|
||||||
|
"timestamp": "2026-08-22T20:07:03Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user