[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
"""Stage, validate and atomically publish read-only catalogue updates."""
import argparse
from contextlib import closing
from datetime import datetime, timezone
import fcntl
import json
import os
from pathlib import Path
import sqlite3
import stat
import subprocess
import time
import uuid
class UpdateError(Exception):
pass
def now():
return datetime.now(timezone.utc).isoformat()
def fsync_directory(path):
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def atomic_json(path, value):
temp = path.with_name('.' + path.name + '.' + uuid.uuid4().hex)
try:
with temp.open('x', encoding='utf-8') as stream:
os.chmod(temp, 0o600)
json.dump(value, stream, ensure_ascii=False, indent=2)
stream.write('\n')
stream.flush()
os.fsync(stream.fileno())
os.replace(temp, path)
fsync_directory(path.parent)
finally:
temp.unlink(missing_ok=True)
def check_candidate(source, stage):
with closing(sqlite3.connect(source.resolve().as_uri() + '?mode=ro', uri=True)) as old:
with closing(sqlite3.connect(stage.resolve().as_uri() + '?mode=ro', uri=True)) as candidate:
if candidate.execute('PRAGMA quick_check').fetchone()[0] != 'ok':
raise UpdateError('Candidate integrity check failed')
if candidate.execute('PRAGMA foreign_key_check').fetchone() is not None:
raise UpdateError('Candidate relation check failed')
for table in ('titles', 'genres', 'countries', 'title_genres', 'title_countries',
'series_fetch_status', 'seasons', 'episodes',
'movie_sources', 'episode_sources'):
before = old.execute('SELECT COUNT(*) FROM ' + table).fetchone()[0]
after = candidate.execute('SELECT COUNT(*) FROM ' + table).fetchone()[0]
if after < before:
raise UpdateError('Candidate would remove existing catalogue entries')
source_counts = (
'SELECT title_id, COUNT(*) FROM movie_sources GROUP BY title_id',
'''SELECT e.series_id, e.id,
CASE WHEN e.id IS NULL THEN e.season_index END,
CASE WHEN e.id IS NULL THEN e.episode_index END, COUNT(*)
FROM episodes AS e JOIN episode_sources AS s
USING (series_id, season_index, episode_index)
GROUP BY 1, 2, 3, 4''',
)
for query in source_counts:
after = {row[:-1]: row[-1] for row in candidate.execute(query)}
for row in old.execute(query):
if after.get(row[:-1], 0) < row[-1]:
raise UpdateError('Candidate would remove existing catalogue sources')
row = candidate.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()
if row is None or not isinstance(json.loads(row[0]), str):
raise UpdateError('Candidate timestamp missing')
return json.loads(row[0])
def run_update(database, state_dir, upstream, *, synchronize_fn=None,
restart_fn=None, health_fn=None, max_pages=2000, progress=None):
database, state_dir = Path(database).resolve(), Path(state_dir).resolve()
state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
if synchronize_fn is None:
from catalog_sync import synchronize
synchronize_fn = synchronize
restart_fn = restart_fn or restart_api
health_fn = health_fn or wait_for_api
with (state_dir / 'sync.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise UpdateError('A catalogue update is already running') from None
original = database.stat()
identity = (original.st_ino, original.st_size, original.st_mtime_ns)
stage = database.with_name('.catalog-sync-' + uuid.uuid4().hex + '.sqlite')
backup = database.with_name('catalog.previous.sqlite')
backup_temp = database.with_name('.catalog-backup-' + uuid.uuid4().hex)
published = False
started = now()
atomic_json(state_dir / 'last-result.json', {'status': 'running', 'started_at_utc': started})
try:
with closing(sqlite3.connect(database.as_uri() + '?mode=ro', uri=True)) as old:
row = old.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()
if row is None or not isinstance(json.loads(row[0]), str):
raise UpdateError('Previous catalogue timestamp missing')
previous_stamp = json.loads(row[0])
report = synchronize_fn(database, stage, upstream, max_pages=max_pages, progress=progress)
stamp = check_candidate(database, stage)
current = database.stat()
if identity != (current.st_ino, current.st_size, current.st_mtime_ns):
raise UpdateError('Live catalogue changed during synchronisation')
os.chown(stage, original.st_uid, original.st_gid)
os.chmod(stage, stat.S_IMODE(original.st_mode))
with stage.open('rb') as stream:
os.fsync(stream.fileno())
os.link(database, backup_temp)
os.replace(backup_temp, backup)
fsync_directory(database.parent)
os.replace(stage, database)
published = True
fsync_directory(database.parent)
restart_fn()
health_fn(stamp)
result = {**report, 'status': 'success', 'started_at_utc': started,
'completed_at_utc': now(), 'api_health_verified': True,
'media_files_downloaded': False}
atomic_json(state_dir / 'last-success.json', result)
atomic_json(state_dir / 'last-result.json', result)
return result
except Exception as error:
rollback_failed = False
if published:
try:
os.replace(backup, database)
fsync_directory(database.parent)
restart_fn()
health_fn(previous_stamp)
except Exception:
rollback_failed = True
atomic_json(state_dir / 'last-result.json', {
'status': 'failed', 'started_at_utc': started, 'completed_at_utc': now(),
'error_type': type(error).__name__, 'rollback_failed': rollback_failed,
'media_files_downloaded': False,
})
if rollback_failed:
raise UpdateError('Update failed and API restart needs operator attention') from None
raise UpdateError('Update failed; the previous catalogue was retained') from None
finally:
for suffix in ('', '-journal', '-wal', '-shm'):
Path(str(stage) + suffix).unlink(missing_ok=True)
backup_temp.unlink(missing_ok=True)
def restart_api():
subprocess.run(['/bin/systemctl', 'restart', 'reelbox-api.service'],
check=True, capture_output=True, timeout=120)
def wait_for_api(expected_stamp):
from urllib.request import ProxyHandler, build_opener
opener = build_opener(ProxyHandler({}))
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
try:
with opener.open('http://127.0.0.1:18765/api/health', timeout=5) as response:
health = json.loads(response.read(65536))
if (health.get('status') == 'ok' and health.get('media_proxy') is False
and health.get('snapshot_built_at_utc') == expected_stamp):
return
except Exception:
pass
time.sleep(2)
raise UpdateError('Updated API did not become healthy in time')
def emit_progress(event):
fields = {key: event[key] for key in ('phase', 'total') if key in event}
if 'completed' in event or 'count' in event:
fields['completed'] = event.get('completed', event.get('count'))
print(json.dumps({'progress': fields}), flush=True)
def probe(upstream):
genres = upstream.get('genre/all')
movies = upstream.get('movie/by/filtres/0/created/0')
series = upstream.get('serie/by/filtres/0/created/0')
if not all(isinstance(rows, list) and rows for rows in (genres, movies, series)):
raise UpdateError('Upstream metadata probe returned an unexpected shape')
movie_id = movies[0]['id']
series_id = series[0]['id']
detail = upstream.get('movie/by/' + str(movie_id))
seasons = upstream.get('season/by/serie/' + str(series_id))
if not isinstance(detail, dict) or not isinstance(seasons, list):
raise UpdateError('Upstream detail or season probe failed')
return {'status': 'probe_ok', 'movie_id': movie_id, 'series_id': series_id,
'movie_sources': len(detail.get('sources', [])), 'seasons': len(seasons),
'media_files_downloaded': False}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--database', type=Path, default=Path('/var/lib/reelbox-api/catalog.sqlite'))
parser.add_argument('--state-dir', type=Path, default=Path('/var/lib/reelbox-sync'))
parser.add_argument('--interval', type=float, default=0.25)
parser.add_argument('--max-pages', type=int, default=2000)
parser.add_argument('--probe', action='store_true')
args = parser.parse_args()
from catalog_sync import Upstream
key = os.environ.get('REELBOX_UPSTREAM_KEY', '')
if not key:
print('REELBOX_UPSTREAM_KEY is required; no requests sent', flush=True)
return 2
try:
upstream = Upstream(key, interval=max(0.25, args.interval))
result = probe(upstream) if args.probe else run_update(
args.database, args.state_dir, upstream, max_pages=args.max_pages, progress=emit_progress)
print(json.dumps(result, ensure_ascii=False), flush=True)
return 0
except Exception as error:
print(json.dumps({'status': 'failed', 'error_type': type(error).__name__,
'message': str(error) if isinstance(error, UpdateError) else 'Metadata update failed; see safe status record'}), flush=True)
return 1
if __name__ == '__main__':
raise SystemExit(main())