"""Deploy only Enigma2 after an explicitly approved Jellyfin maintenance window.""" import argparse import datetime import hashlib import json import os import pathlib import re import shutil import subprocess import time import urllib.error import urllib.request import uuid VERSION = '7.0.9.0' ABI = '12.2.0.0' GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a' DLL = 'Jellyfin.Plugin.Enigma2.dll' PLUGINS = pathlib.Path('/var/lib/jellyfin/plugins') BACKUPS = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups') LOGS = pathlib.Path('/var/log/jellyfin') PROTECTED = tuple(map(pathlib.Path, ( '/etc/default/jellyfin', '/etc/jellyfin/encoding.xml', '/usr/local/lib/jellyfin-enigma/ffmpeg', '/usr/local/lib/jellyfin-enigma/ffprobe', '/usr/share/jellyfin/web/config.json', '/usr/share/jellyfin/web/index.html', ))) def sha256(path): digest = hashlib.sha256() with path.open('rb') as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b''): digest.update(chunk) return digest.hexdigest() def hashes(): # Include missing paths so an unexpected new file is also detected. return {str(p): sha256(p) if p.exists() else None for p in PROTECTED} def read_manifest(directory): value = json.loads((directory / 'meta.json').read_text(encoding='utf-8-sig')) if not isinstance(value, dict): raise ValueError('Plugin manifest must be an object') return value def validate_manifest(metadata, version=None): if metadata.get('name') != 'Enigma2' or str(uuid.UUID(metadata.get('guid', ''))) != GUID: raise ValueError('Unexpected plugin identity') if metadata.get('targetAbi') != ABI: raise ValueError('Unexpected plugin target ABI') if not re.fullmatch(r'\d+\.\d+\.\d+\.\d+', str(metadata.get('version', ''))): raise ValueError('Invalid plugin version') if metadata.get('status') != 'Active': raise ValueError('Existing plugin is not Active') if version is not None and metadata['version'] != version: raise ValueError('Installed manifest version mismatch') def existing_plugin(plugins): matches = [] for directory in plugins.iterdir(): if not directory.is_dir(): continue metadata = {} if (directory / 'meta.json').is_file(): try: metadata = read_manifest(directory) except (ValueError, OSError): pass if (directory.name.startswith('Enigma2_') or (directory / DLL).exists() or metadata.get('guid') == GUID or metadata.get('name') == 'Enigma2'): matches.append(directory) if len(matches) != 1: raise ValueError('Expected exactly one existing Enigma2 installation; resolve conflicts first') old = matches[0] if old.is_symlink() or any(p.is_symlink() for p in old.rglob('*')): raise ValueError('Symlinks in the plugin installation are unsupported') metadata = read_manifest(old) validate_manifest(metadata) if old.name != 'Enigma2_' + metadata['version'] or not (old / DLL).is_file(): raise ValueError('Plugin directory, manifest and DLL are inconsistent') return old, metadata def command(arguments): # Never print raw service logs, which can contain unrelated sensitive data. return subprocess.run(arguments, check=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True).stdout def service(action): command(['systemctl', action, 'jellyfin']) def log_boundary(directory): offsets = {} for path in directory.glob('jellyfin*.log'): if path.is_file() and not path.is_symlink(): stat = path.stat() offsets[(stat.st_dev, stat.st_ino)] = stat.st_size if not offsets: raise RuntimeError('Cannot establish a fresh Jellyfin file-log boundary') return offsets def fresh_plugin_load(directory, boundary, version): for path in directory.glob('jellyfin*.log'): if not path.is_file() or path.is_symlink(): continue try: with path.open('rb') as stream: stat = os.fstat(stream.fileno()) start = boundary.get((stat.st_dev, stat.st_ino), 0) if start > stat.st_size: # Existing log was truncated at restart. start = 0 stream.seek(start) remaining = stat.st_size - start carry = '' while remaining > 0: chunk = stream.read(min(65536, remaining)) if not chunk: break remaining -= len(chunk) text = carry + chunk.decode('utf-8', errors='replace') if loaded_version(text, version): return True carry = text[-256:] except FileNotFoundError: # Rotation between listing and opening; retry next poll. continue return False def loaded_version(log, version): # Match only the loader's exact plugin name/version; do not return log content. return re.search(r'Loaded plugin:\s*"?Enigma2"?\s+"?' + re.escape(version) + r'"?(?=\s|$)', log, flags=re.MULTILINE) is not None def wait_ready(directory, version, expected_hash, protected_before, boundary, timeout=60): deadline = time.monotonic() + timeout observed_version = '' while time.monotonic() < deadline: if hashes() != protected_before: raise RuntimeError('Protected Jellyfin configuration, wrapper or web file changed') validate_manifest(read_manifest(directory), version) if sha256(directory / DLL) != expected_hash: raise RuntimeError('Installed DLL checksum mismatch') try: with urllib.request.urlopen('http://127.0.0.1:8096/System/Info/Public', timeout=3) as response: info = json.load(response) actual_version = info.get('Version') if isinstance(info, dict) else None # The public endpoint may return incomplete startup data before it is ready. # Report only a short numeric version, never arbitrary response content. if actual_version is None: observed_version = '' elif isinstance(actual_version, str) and re.fullmatch(r'[0-9]{1,5}(?:\.[0-9]{1,5}){1,3}', actual_version): observed_version = actual_version else: observed_version = '' if actual_version == '12.2.0' and fresh_plugin_load(LOGS, boundary, version): return except (urllib.error.URLError, TimeoutError, ConnectionError, json.JSONDecodeError): pass time.sleep(1) raise RuntimeError('Public health and fresh plugin load confirmation did not pass; ' + 'observedVersion=' + observed_version) def activate(old, new, stage, backup, verify, verify_rollback, control=service): """Keep the original plugin until the replacement passes; restore on any failure.""" saved = backup / old.name moved_old = False installed = False try: control('stop') old.rename(saved) moved_old = True stage.rename(new) installed = True control('start') verify() except BaseException as error: rollback_errors = [] # Even a failed start may leave a running process; stop before restoring files. try: control('stop') except Exception: rollback_errors.append('stop') # Avoid modifying files while Jellyfin may still be running. if not rollback_errors: try: if installed: shutil.rmtree(new) if moved_old: saved.rename(old) # Rename preserves original ownership; retain a content backup as well. shutil.copytree(old, saved, copy_function=shutil.copy2) except Exception: rollback_errors.append('restore') try: control('start') if not rollback_errors: verify_rollback() except Exception: rollback_errors.append('start_or_verification') if rollback_errors: raise RuntimeError('Deployment failed; rollback requires attention: ' + ', '.join(rollback_errors) + '; backup: ' + str(backup)) from error raise RuntimeError('Deployment failed; previous plugin restored and started; backup: ' + str(backup)) from error finally: if stage.exists(): shutil.rmtree(stage) def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--maintenance-approved', action='store_true', help='Required confirmation of an already agreed Jellyfin restart window') parser.add_argument('--dll', type=pathlib.Path, required=True) parser.add_argument('--expected-sha256', required=True) args = parser.parse_args(argv) if not args.maintenance_approved: parser.error('--maintenance-approved is required; this script cannot inspect playback sessions') expected = args.expected_sha256.lower() if not re.fullmatch(r'[0-9a-f]{64}', expected): parser.error('--expected-sha256 must contain exactly 64 hexadecimal digits') if not args.dll.is_file() or sha256(args.dll) != expected: raise ValueError('Input DLL is missing or does not match the supplied checksum') old, metadata = existing_plugin(PLUGINS) new = PLUGINS / ('Enigma2_' + VERSION) if new.exists() or new.is_symlink(): raise ValueError('Target plugin directory already exists; refusing to replace it') before = hashes() old_hash = sha256(old / DLL) boundary = log_boundary(LOGS) stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') backup = BACKUPS / ('plugin-only-' + stamp) backup.mkdir(mode=0o700, parents=True, exist_ok=False) stage = backup / 'candidate' try: shutil.copytree(old, stage, copy_function=shutil.copy2) shutil.copy2(args.dll, stage / DLL) updated = dict(metadata, version=VERSION, status='Active', targetAbi=ABI) (stage / 'meta.json').write_text(json.dumps(updated, indent=2) + '\n', encoding='utf-8') for path in [stage, *stage.rglob('*')]: shutil.chown(path, user='jellyfin', group='jellyfin') if sha256(stage / DLL) != expected or hashes() != before: raise RuntimeError('Pre-restart checksum verification failed') activate(old, new, stage, backup, lambda: wait_ready(new, VERSION, expected, before, boundary), lambda: wait_ready(old, metadata['version'], old_hash, before, boundary)) finally: if stage.exists(): shutil.rmtree(stage) print(json.dumps({'plugin': VERSION, 'backup': str(backup), 'ready': True, 'sha256': expected, 'fresh_plugin_load_confirmed': True, 'server_configuration_and_wrappers_unchanged': True})) if __name__ == '__main__': main()