"""Audited operator repair. Preview by default; --apply requires explicit authorization.""" import argparse import asyncio from contextlib import closing import json import os from pathlib import Path import sqlite3 from app import db from app.services import duplicate_accounts as duplicates from app.services import identity_review as review async def reconcile(apply, output): os.umask(0o077) output.mkdir(parents=True, exist_ok=False, mode=0o700) if apply: with closing(db._connect()) as source, closing(sqlite3.connect(output / 'before.sqlite')) as target: source.backup(target) changes, blocked, seen = [], [], set() actor = {'username': 'maintenance:authorized-identity-reconciliation'} report, local, runtime = await review.review_identities() initial = report['counts'] while True: target = next((row for row in report['rows'] if row['candidate_jellyfin_id'] and row['candidate_jellyfin_id'] not in seen and len(duplicates.identity_group(report, row)) > 1), None) if not target: break identity = target['candidate_jellyfin_id'] seen.add(identity) ids = [row['user']['id'] for row in duplicates.identity_group(report, target)] with closing(db._connect()) as conn: state = duplicates.account_state(conn, ids) preview = duplicates.build_preview(report, local, runtime, state, target['user']['id']) if preview['can_confirm']: result = duplicates.consolidate(preview, report, local, runtime, state, actor) if apply else { 'kept_user_id': preview['keep_id'], 'consolidated': len(ids) - 1} changes.append(result) if apply: report, local, runtime = await review.review_identities() else: blocked.append({'ids': ids, 'jellyfin_id': identity, 'issues': preview['issues']}) (output / 'progress.json').write_text(json.dumps({'changes': changes, 'blocked': blocked})) if len(seen) % 20 == 0: print('Reviewed groups:', len(seen), 'consolidated rows:', sum(r['consolidated'] for r in changes), flush=True) if apply: ready = [row['user']['id'] for row in report['rows'] if row['can_confirm'] and row['basis'] in {'confirmed_id', 'stored_jellyfin_id', 'stored_seerr_id'}] if ready: review.save_confirmations(report, local, runtime, ready, actor) report, _, _ = await review.review_identities() summary = {'applied': apply, 'before': initial, 'after': report['counts'], 'consolidated_rows': sum(r['consolidated'] for r in changes), 'groups': len(changes), 'blocked': blocked} (output / 'result.json').write_text(json.dumps(summary, indent=2)) print(json.dumps(summary), flush=True) if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--apply', action='store_true') parser.add_argument('--output', type=Path, required=True, help='New private backup/report directory') args = parser.parse_args() asyncio.run(reconcile(args.apply, args.output))