Author SHA1 Message Date
Bram b20ebbfca6 plex excluder
Build and Push Docker Images / build-and-push (push) Successful in 1m14s
2026-09-05 23:25:26 +02:00
Bram 07924fe892 dns proxy
Build and Push Docker Images / build-and-push (push) Failing after 52s
2026-08-30 23:00:48 +02:00
gitea-actions 23a2d1964a chore(jellyfin): update plugin catalog [skip ci] 2026-08-22 20:31:14 +00:00
14 changed files with 740 additions and 28 deletions
+13
View File
@@ -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
+59
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
.env
.venv/
venv/
.git/
.idea/
.vscode/
*.md
+22
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.env
__pycache__/
*.pyc
.venv/
venv/
+23
View File
@@ -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
+82
View File
@@ -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())
+4
View File
@@ -0,0 +1,4 @@
plexapi
requests
python-dotenv
croniter
+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())
+1
View File
@@ -0,0 +1 @@
latest
@@ -1,6 +1,6 @@
name: "Personal Recordings"
guid: "7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a"
version: "1.0.0.0"
version: "1.0.1.0"
targetAbi: "10.11.0.0"
framework: "net9.0"
owner: "bram"
+34 -26
View File
@@ -1,28 +1,36 @@
[
{
"guid": "7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a",
"name": "Personal Recordings",
"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",
"owner": "bram",
"category": "Live TV",
"versions": [
{
"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": "63ed4d39770330b8fd714f116cd57f4c1e8744b88afb9865ccbed6599f043b49",
"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"
}
]
}
{
"guid": "7f3e9c2a-4b1d-4e8f-9a6c-2d5e8f1b3c4a",
"name": "Personal Recordings",
"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",
"owner": "bram",
"category": "Live TV",
"versions": [
{
"version": "1.0.1.0",
"changelog": "Release 1.0.1",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.bramkelchtermans.be/Bram/projects/releases/download/jellyfin-plugin-personal-recordings-v1.0.1/personal-recordings_1.0.1.0.zip",
"checksum": "69bb2094dc7eb81e674e0afac42cedfd",
"timestamp": "2026-08-22T20:31:14Z"
},
{
"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": "63ed4d39770330b8fd714f116cd57f4c1e8744b88afb9865ccbed6599f043b49",
"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"
}
]
}
]