#!/usr/bin/env python """Export GS's config (subscriptions, sources, credentials) as a single JSON file that FabledCurator's FC-5 ingest can read. One-shot tool. Excludes: - downloads (history not migrated; FC's scheduler refills going forward). - content_items (same). Credentials are decrypted in this script's process using GS's secret_key (passed via --secret-key or the SECRET_KEY env var); FC re-encrypts on ingest with its own Fernet key. The output JSON contains plaintext credentials — delete it after the FC ingest completes. Designed to run INSIDE the live GS container: docker cp scripts/export_for_fabledcurator.py :/app/scripts/ docker exec -w /app python scripts/export_for_fabledcurator.py \\ --secret-key "$SECRET_KEY" > ~/gs-export.json The container has GS's Python deps (asyncpg, sqlalchemy, cryptography) and the runtime env vars (DATABASE_URL, SECRET_KEY) already set. """ from __future__ import annotations import argparse import asyncio import json import os import sys from datetime import UTC, datetime # Ensure the container's WORKDIR (/app) is on sys.path so the GS package # `app.*` imports resolve. Container layout: /app/app/ is the package. sys.path.insert(0, os.getcwd()) from sqlalchemy import select # noqa: E402 from sqlalchemy.ext.asyncio import ( # noqa: E402 AsyncSession, async_sessionmaker, create_async_engine, ) from app.models.credential import Credential # noqa: E402 from app.models.source import Source # noqa: E402 from app.models.subscription import Subscription # noqa: E402 from app.utils.encryption import decrypt_data # noqa: E402 def _async_database_url() -> str: """Resolve GS's async DB URL the same way app.config.Settings does.""" url = os.environ.get("DATABASE_URL") if not url: # GS's default fallback chain — should rarely fire in prod since # the container's DATABASE_URL is set by compose. url = "postgresql://gdl:password@localhost:5432/gallery_subscriber" return url.replace("postgresql://", "postgresql+asyncpg://") async def _export_async(secret_key: str) -> dict: engine = create_async_engine(_async_database_url()) Session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) try: async with Session() as session: subs_out: list[dict] = [] subs = (await session.execute(select(Subscription))).scalars().all() for sub in subs: srcs = (await session.execute( select(Source).where(Source.subscription_id == sub.id) )).scalars().all() sources_out = [{ "platform": s.platform, "url": s.url, "enabled": bool(s.enabled), "check_interval": int(s.check_interval or 28800), "metadata": s.metadata_ or {}, } for s in srcs] subs_out.append({ "name": sub.name, "enabled": bool(sub.enabled), "metadata": sub.metadata_ or {}, "sources": sources_out, }) creds_out: list[dict] = [] creds = (await session.execute(select(Credential))).scalars().all() for cred in creds: try: plaintext = decrypt_data(cred.data, secret_key) except Exception as exc: print( f"WARNING: failed to decrypt credential for " f"platform={cred.platform!r}: {exc}", file=sys.stderr, ) continue creds_out.append({ "platform": cred.platform, "credential_type": cred.credential_type, "plaintext": plaintext, "expires_at": ( cred.expires_at.isoformat() if cred.expires_at else None ), }) return { "source_app": "gallerysubscriber", "schema_version": 1, "exported_at": datetime.now(UTC).isoformat(), "subscriptions": subs_out, "credentials": creds_out, } finally: await engine.dispose() def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--secret-key", default=os.environ.get("SECRET_KEY"), help="GS SECRET_KEY (defaults to $SECRET_KEY env var).", ) args = parser.parse_args() if not args.secret_key: print( "ERROR: --secret-key required (or set SECRET_KEY env var)", file=sys.stderr, ) sys.exit(2) export = asyncio.run(_export_async(args.secret_key)) json.dump(export, sys.stdout, indent=2) sys.stdout.write("\n") if __name__ == "__main__": main()