#!/usr/bin/env python3 """Strict, selective, read-only Plex state salvage from a damaged ext4 LV.""" import argparse import datetime as dt import hashlib import json import math import os import re import shutil import stat import subprocess import sys from pathlib import Path LV = '/dev/mapper/plex--new-plex--new' DEST_MOUNT = Path('/mnt/recovery') BASE = '/plexmediaserver/Library/Application Support/Plex Media Server' DEAD_FIRST = 3418636288 DEAD_LAST = 4395389951 BLOCK_SIZE = 4096 RESERVE = 2 * 1024**3 VERSION = 1 ROOT_FILES = ['Preferences.xml', '.LocalAdminToken'] DATABASE_DIR = f'{BASE}/Plug-in Support/Databases' PREFERENCES_DIR = f'{BASE}/Plug-in Support/Preferences' EXTRA_TREES = [ f'{BASE}/Plug-ins/Hama.bundle', f'{BASE}/Plug-ins/Audnexus.bundle', f'{BASE}/Scanners/Series', ] DB_NAME = re.compile(r'^com\.plexapp\.plugins\.library(?:\.blobs)?\.db(?:-(?:[0-9]{4}-[0-9]{2}-[0-9]{2}|wal|shm))?$') ETB = re.compile(r'\(ETB([0-9]+)\):([0-9]+)') EXTENT = re.compile(r'\(([0-9]+)(?:-([0-9]+))?\):([0-9]+)(?:-([0-9]+))?') INODE = re.compile(r'\bInode:\s*([0-9]+)\s+Type:\s*([^\s]+)') SIZE = re.compile(r'\bSize:\s*([0-9]+)') OWNER = re.compile(r'\bUser:\s*([0-9]+)\s+Group:\s*([0-9]+)') MODE = re.compile(r'\bMode:\s*([0-7]{3,4})') def human(n): units = ('B', 'KiB', 'MiB', 'GiB', 'TiB') v = float(n) for unit in units: if v < 1024 or unit == units[-1]: return f'{v:.2f} {unit}' v /= 1024 def die(message): raise SystemExit(f'ERROR: {message}') def run(cmd): return subprocess.run(cmd, capture_output=True, text=True, errors='replace') def dbg(command): env = os.environ.copy() env['DEBUGFS_PAGER'] = 'cat' return subprocess.run( ['debugfs', '-c', '-R', command, LV], capture_output=True, text=True, errors='replace', env=env, ) def quote(path): return '"' + path.replace('\\', '\\\\').replace('"', '\\"') + '"' def warnings(result): return '\n'.join( line for line in result.stderr.splitlines() if line.strip() and not re.match(r'^debugfs [0-9]', line.strip()) ).strip() def config(profile): prefix = f'plex-state-{profile}' return { 'profile': profile, 'manifest': DEST_MOUNT / f'{prefix}-manifest.jsonl', 'summary': DEST_MOUNT / f'{prefix}-scan-summary.txt', 'recover_log': DEST_MOUNT / f'{prefix}-recovery.jsonl', 'recover_summary': DEST_MOUNT / f'{prefix}-recovery-summary.txt', 'tmp': DEST_MOUNT / '.plex-state-tmp', } def preflight(cfg, need_manifest=False): if os.geteuid() != 0: die('Run as root; the source block device requires read access.') for binary in ('debugfs', 'blockdev', 'findmnt'): if shutil.which(binary) is None: die(f'Required program not installed: {binary}') if not Path(LV).exists(): die(f'LV missing: {LV}') ro = run(['blockdev', '--getro', LV]) if ro.returncode != 0 or ro.stdout.strip() != '1': die(f'{LV} is not kernel read-only; refusing to proceed') m = run(['findmnt', '-n', '-o', 'TARGET,SOURCE,FSTYPE', '-T', str(DEST_MOUNT)]) if m.returncode != 0 or not m.stdout.strip(): die(f'No mounted recovery filesystem detected at {DEST_MOUNT}') parts = m.stdout.split() if len(parts) < 3 or parts[0] != str(DEST_MOUNT): die(f'{DEST_MOUNT} must itself be the mounted recovery filesystem: {m.stdout.strip()}') if LV in m.stdout or 'plex--new' in m.stdout: die('Recovery destination appears to be the damaged LV') if not os.access(DEST_MOUNT, os.W_OK): die(f'{DEST_MOUNT} is not writable') if need_manifest and not cfg['manifest'].exists(): die(f'Missing {cfg["manifest"]}. Scan {cfg["profile"]} first.') print(f'Source: {LV} (read-only)') print(f'Destination: {m.stdout.strip()}') print(f'Dead physical filesystem blocks: {DEAD_FIRST}-{DEAD_LAST}') if parts[2].lower() == 'exfat': print('NOTICE: destination is exFAT. Tokens and database files are plaintext here;') print(' Linux ownership/permissions cannot be preserved on this filesystem.') print() def get_stat(inode_or_path): spec = f'<{inode_or_path}>' if isinstance(inode_or_path, int) else quote(inode_or_path) r = dbg(f'stat {spec}') match = INODE.search(r.stdout) if not match: return None, (warnings(r) or (r.stdout + '\n' + r.stderr).strip()) owner = OWNER.search(r.stdout) mode = MODE.search(r.stdout) size = SIZE.search(r.stdout) info = { 'inode': int(match.group(1)), 'type': match.group(2), 'size': int(size.group(1)) if size else None, 'uid': int(owner.group(1)) if owner else None, 'gid': int(owner.group(2)) if owner else None, 'mode': mode.group(1) if mode else None, 'text': r.stdout, } return info, warnings(r) def get_dir_entries(path): info, issue = get_stat(path) if not info or info['type'] != 'directory': return None, f'Cannot open directory: {path} | {issue or info}' r = dbg(f'ls -p <{info["inode"]}>') issue = warnings(r) # Do not silently accept a partial directory listing if debugfs reported errors. if issue: return None, f'Error listing {path} (inode {info["inode"]}): {issue}' entries = [] for line in r.stdout.splitlines(): if not line.startswith('/'): continue bits = line.split('/') if len(bits) < 7: return None, f'Unparseable directory entry in {path}: {line!r}' try: inode, mode = int(bits[1]), int(bits[2], 8) except ValueError: return None, f'Unparseable inode/mode in {path}: {line!r}' name = bits[5] if not name or name in ('.', '..') or inode == 0: continue entries.append((name, inode, mode)) return entries, None def inspect_file(path, inode): info, issue = get_stat(inode) rec = {'path': path, 'inode': inode} if not info or info['type'] != 'regular' or info['size'] is None: return {**rec, 'status': 'UNKNOWN', 'reason': f'Inode unavailable: {issue}'} rec.update({key: info[key] for key in ('size', 'uid', 'gid', 'mode')}) if issue: return {**rec, 'status': 'UNKNOWN', 'reason': f'debugfs reported an issue: {issue}'} if info['size'] == 0: return {**rec, 'status': 'SAFE', 'reason': 'empty file', 'extents': []} if 'EXTENTS:' not in info['text']: return {**rec, 'status': 'UNKNOWN', 'reason': 'No EXTENTS section (legacy block maps not supported)'} data = info['text'].split('EXTENTS:', 1)[1] etbs = [{'level': int(l), 'block': int(b)} for l, b in ETB.findall(data)] extents = [] for m in EXTENT.finditer(data): ll = int(m.group(1)); lh = int(m.group(2) or m.group(1)) pl = int(m.group(3)); ph = int(m.group(4) or m.group(3)) extents.append({'logical_start': ll, 'logical_end': lh, 'physical_start': pl, 'physical_end': ph}) rec.update({'extents': extents, 'etbs': etbs}) dead_etbs = [x for x in etbs if DEAD_FIRST <= x['block'] <= DEAD_LAST] if dead_etbs: return {**rec, 'status': 'ETB_LOST', 'reason': 'Required extent-tree metadata is on dead PV'} if not extents: return {**rec, 'status': 'ETB_SURVIVES' if etbs else 'UNKNOWN', 'reason': 'No usable data extents resolved'} if any(x['physical_start'] <= DEAD_LAST and x['physical_end'] >= DEAD_FIRST for x in extents): return {**rec, 'status': 'LOST', 'reason': 'Data extents overlap dead PV'} # Strict check: a file can look SAFE if debugfs returned only the surviving # *subset* of extents. Demand complete, non-overlapping, physically plausible # block coverage with no holes; intentionally reject sparse/uninitialized files. needed = math.ceil(info['size'] / BLOCK_SIZE) cursor = 0 for x in sorted(extents, key=lambda row: row['logical_start']): if x['logical_start'] != cursor: return {**rec, 'status': 'UNKNOWN', 'reason': f'Incomplete/overlapping logical extent coverage at file block {cursor}'} logical_count = x['logical_end'] - x['logical_start'] + 1 physical_count = x['physical_end'] - x['physical_start'] + 1 if logical_count != physical_count or logical_count <= 0 or x['physical_start'] == 0: return {**rec, 'status': 'UNKNOWN', 'reason': 'Extent length mismatch / invalid physical extent'} cursor = x['logical_end'] + 1 if cursor != needed: return {**rec, 'status': 'UNKNOWN', 'reason': f'Extent coverage ends at {cursor} blocks; expected {needed}'} # Reject unexpected extent syntax (e.g. uninitialized extent annotations) # rather than treating any recognized extents as a complete mapping. unparsed = ETB.sub('', data) unparsed = EXTENT.sub('', unparsed) unparsed = re.sub(r'[\s,]+', '', unparsed) if unparsed: return {**rec, 'status': 'UNKNOWN', 'reason': f'Unexpected EXTENTS text: {unparsed[:100]}'} return {**rec, 'status': 'SAFE', 'reason': 'Complete extent coverage outside dead PV'} def scan(profile): cfg = config(profile) preflight(cfg) selected = {} issues = [] skipped = 0 visited_dirs = set() def select_file(path, inode=None): nonlocal skipped if path in selected: return if inode is None: info, issue = get_stat(path) if not info or info['type'] != 'regular': issues.append(f'MISSING/UNREADABLE FILE {path}: {issue or info}') return inode = info['inode'] selected[path] = inspect_file(path, inode) rec = selected[path] if rec['status'] != 'SAFE': print(f'{rec["status"]:13} {path}: {rec.get("reason", "")}') def select_directory(directory, filter_func=lambda _name, _mode: True, recursive=False): nonlocal skipped stack = [directory] while stack: current = stack.pop() if current in visited_dirs: continue visited_dirs.add(current) entries, error = get_dir_entries(current) if error: issues.append(error) print('WARNING:', error) continue for name, inode, mode in entries: path = current + '/' + name if stat.S_ISDIR(mode): if recursive and filter_func(name, mode): stack.append(path) elif stat.S_ISREG(mode): if filter_func(name, mode): select_file(path, inode) else: skipped += 1 else: issues.append(f'IGNORED nonregular entry: {path} mode={oct(mode)}') if profile == 'core': for filename in ROOT_FILES: select_file(f'{BASE}/{filename}') select_directory(DATABASE_DIR, lambda name, _mode: bool(DB_NAME.fullmatch(name))) select_directory(PREFERENCES_DIR, lambda name, _mode: name.endswith('.xml')) elif profile == 'extras': for directory in EXTRA_TREES: select_directory(directory, recursive=True) else: die(f'Unknown profile {profile}') counts = {} sizes = {} for rec in selected.values(): status = rec['status'] counts[status] = counts.get(status, 0) + 1 sizes[status] = sizes.get(status, 0) + (rec.get('size') or 0) meta = { 'version': VERSION, 'profile': profile, 'base': BASE, 'lv': LV, 'dead_first': DEAD_FIRST, 'dead_last': DEAD_LAST, 'block_size': BLOCK_SIZE, 'selected_count': len(selected), 'created_at': dt.datetime.now(dt.timezone.utc).isoformat(), 'issues': issues, } new_path = Path(str(cfg['manifest']) + '.new') with new_path.open('w', encoding='utf-8') as f: f.write(json.dumps({'_meta': meta}, ensure_ascii=False) + '\n') for rec in sorted(selected.values(), key=lambda item: item['path']): f.write(json.dumps(rec, ensure_ascii=False) + '\n') new_path.replace(cfg['manifest']) lines = [ f'PLEX SELECTIVE SCAN: {profile}', '=' * 35, f'Source: {BASE}', f'Selected regular files: {len(selected)}', f'Skipped by allowlist: {skipped}', f'Scanned directories: {len(visited_dirs)}', ] for status in ('SAFE', 'LOST', 'ETB_LOST', 'ETB_SURVIVES', 'UNKNOWN'): lines.append(f'{status:14} {counts.get(status, 0):>4} files {human(sizes.get(status, 0)):>12}') lines += [ '', f'Available recovery space NOW: {human(shutil.disk_usage(DEST_MOUNT).free)}', f'Issues/warnings: {len(issues)}', ] lines += [' ' + issue for issue in issues] if profile == 'core': lines += ['', 'DATABASE CANDIDATES (extent classification; NOT SQLite integrity):'] for rec in sorted(selected.values(), key=lambda r: r['path']): if rec['path'].startswith(DATABASE_DIR + '/'): lines.append(f' {rec["status"]:12} {Path(rec["path"]).name}') live = [rec for rec in selected.values() if rec['path'].startswith(DATABASE_DIR + '/') and Path(rec['path']).name in ('com.plexapp.plugins.library.db', 'com.plexapp.plugins.library.blobs.db')] if len(live) < 2: lines.append(' WARNING: One or both live database files absent/unreadable') lines += ['', f'Manifest: {cfg["manifest"]}', 'CAUTION: SAFE is based on block mapping; validate recovered databases separately.'] report = '\n'.join(lines) + '\n' cfg['summary'].write_text(report, encoding='utf-8') print('\n' + report) if issues: print('WARNING: scan had missing/inaccessible entries. Review warning lines; do not assume completeness.') def recover(profile): cfg = config(profile) preflight(cfg, need_manifest=True) with cfg['manifest'].open(encoding='utf-8') as f: parsed = [json.loads(line) for line in f if line.strip()] if not parsed or '_meta' not in parsed[0]: die('No manifest metadata header') meta = parsed[0]['_meta'] if any((meta.get(k) != expected for k, expected in ( ('profile', profile), ('lv', LV), ('dead_first', DEAD_FIRST), ('dead_last', DEAD_LAST), ('block_size', BLOCK_SIZE), ('version', VERSION), ))): die('Manifest geometry/profile/version does not match this script') entries = [r for r in parsed[1:] if r.get('status') == 'SAFE'] print(f'{len(entries)} SAFE files selected, {human(sum(r["size"] for r in entries))} total') if meta.get('issues'): print('WARNING: scan had missing/unreadable paths; review scan summary.') cfg['tmp'].mkdir(exist_ok=True) stats = {'RECOVERED': 0, 'ALREADY_PRESENT_UNVERIFIED': 0, 'ERROR': 0, 'NO_SPACE': 0} bytes_new = 0 mode = 'a' if cfg['recover_log'].exists() else 'w' with cfg['recover_log'].open(mode, encoding='utf-8') as logfile: for idx, rec in enumerate(entries, 1): src = rec['path'] size = rec['size'] destination = DEST_MOUNT / src.lstrip('/') label = None; detail = '' digest = None # Recheck the live inode's mapping and byte size before copying. check = inspect_file(src, rec['inode']) if check['status'] != 'SAFE' or check.get('size') != size or check.get('extents') != rec.get('extents'): label = 'ERROR'; detail = 'Source inode extent map changed or fails strict safety recheck' elif destination.exists(): if destination.is_file() and destination.stat().st_size == size: label = 'ALREADY_PRESENT_UNVERIFIED'; detail = 'Already present with matching size; not rewritten' else: label = 'ERROR'; detail = 'Destination exists with different size or is not a file; refusing overwrite' elif shutil.disk_usage(DEST_MOUNT).free < size + RESERVE: label = 'NO_SPACE'; detail = 'Need file size plus 2 GiB free-space reserve' else: tmp = cfg['tmp'] / f'{profile}-{rec["inode"]}.part' if tmp.exists(): tmp.unlink() result = dbg(f'dump <{rec["inode"]}> {tmp}') issue = warnings(result) if issue: label = 'ERROR'; detail = f'debugfs reported: {issue[:400]}' elif not tmp.exists() or tmp.stat().st_size != size: label = 'ERROR'; detail = 'Missing output file or byte-size mismatch' else: digest_obj = hashlib.sha256() with tmp.open('rb') as source: for chunk in iter(lambda: source.read(4 * 1024 * 1024), b''): digest_obj.update(chunk) digest = digest_obj.hexdigest() destination.parent.mkdir(parents=True, exist_ok=True) try: tmp.replace(destination) label = 'RECOVERED'; bytes_new += size except OSError as err: label = 'ERROR'; detail = f'Rename failed: {err}' if label == 'ERROR' and tmp.exists(): try: tmp.unlink() except OSError: pass if label in ('ERROR', 'NO_SPACE'): print(f'{label:10} {src}: {detail}') else: print(f'{label:26} [{idx}/{len(entries)}] {src}') stats[label] += 1 logfile.write(json.dumps({ 'status': label, 'path': src, 'size': size, 'inode': rec['inode'], 'sha256': digest, 'detail': detail, }, ensure_ascii=False) + '\n') logfile.flush() if label == 'NO_SPACE': break lines = [ f'PLEX SELECTIVE RECOVERY: {profile}', '=' * 35, f'Newly recovered: {stats["RECOVERED"]}, {human(bytes_new)}', f'Already present (size-only): {stats["ALREADY_PRESENT_UNVERIFIED"]}', f'Errors: {stats["ERROR"]}', f'Space stops: {stats["NO_SPACE"]}', f'Remaining free: {human(shutil.disk_usage(DEST_MOUNT).free)}', f'Manifest: {cfg["manifest"]}', f'Hash log: {cfg["recover_log"]}', 'Hashes verify extracted copies, NOT correctness of Plex databases.', ] report = '\n'.join(lines) + '\n' cfg['recover_summary'].write_text(report, encoding='utf-8') print('\n' + report) return 1 if stats['ERROR'] or stats['NO_SPACE'] else 0 def main(): os.umask(0o077) parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=('scan', 'recover')) parser.add_argument('profile', choices=('core', 'extras'), nargs='?', default='core') args = parser.parse_args() if args.action == 'scan': scan(args.profile) else: sys.exit(recover(args.profile)) if __name__ == '__main__': main()