from contextlib import asynccontextmanager import asyncio import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import func, select from app.config import settings from app.database import async_session, engine from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter from app.user_models import User from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats def _cleanup_orphaned_sequences(sync_conn): """Drop model sequences whose backing table no longer exists. Prevents 'duplicate key value violates unique constraint' errors when create_all() tries to recreate a SERIAL-backed sequence for a table whose original sequence was left behind after a drop/recreate cycle. Only runs on PostgreSQL; no-op for SQLite. """ import sqlalchemy as sa if sync_conn.dialect.name != "postgresql": return inspector = sa.inspect(sync_conn) existing_tables = set(inspector.get_table_names()) for table in Base.metadata.tables.values(): expected_seq = f"{table.name}_id_seq" if table.name not in existing_tables: sync_conn.execute(sa.text(f"DROP SEQUENCE IF EXISTS {expected_seq}")) print(f" → Dropped orphaned sequence {expected_seq}") def _migrate_add_missing_columns(sync_conn): """Add missing columns to existing tables (startup migration — no Alembic). Handles both SQLite and PostgreSQL. Idempotent: skips columns that already exist. Receives a sync SQLAlchemy Connection from engine.run_sync(). """ import sqlalchemy as sa # Column definitions: (table, name, type, default) pending = [ ("station_config", "stream_url", sa.String(500), ""), ("station_config", "stream_metadata_url", sa.String(500), ""), ("station_config", "play_store_icon_url", sa.String(500), ""), ("station_config", "play_store_url", sa.String(500), ""), ("station_config", "app_store_embed_html", sa.Text, ""), ] # Determine dialect dialect = sync_conn.dialect.name # "sqlite" | "postgresql" inspector = sa.inspect(sync_conn) for table_name, col_name, col_type, default in pending: # Check if column already exists existing = inspector.get_columns(table_name) if any(c["name"] == col_name for c in existing): continue print(f" → Migrating: adding column {table_name}.{col_name}") # Determine SQL type from the column type object if isinstance(col_type, sa.Text): sql_type = "TEXT" elif isinstance(col_type, sa.String): sql_type = "VARCHAR(500)" else: sql_type = "TEXT" if dialect == "sqlite": sync_conn.execute( sa.text(f'ALTER TABLE {table_name} ADD COLUMN "{col_name}" TEXT NOT NULL DEFAULT "{default}"') ) else: sync_conn.execute( sa.text(f'ALTER TABLE {table_name} ADD COLUMN "{col_name}" {sql_type} NOT NULL DEFAULT "{default}"') ) print(f" ✓ Added {table_name}.{col_name}") async def _ensure_station_config(session): """Ensure the singleton station config row exists (idempotent).""" result = await session.execute( select(StationConfig).where(StationConfig.key == "default") ) if result.scalar_one_or_none() is None: print(" → Station config missing — seeding defaults...") from seed import seed as run_seed # Only seed the station config, not the full dataset from seed import STATION_CONFIG, _upsert_station_config await _upsert_station_config(session, STATION_CONFIG) await session.commit() print(" ✓ Station config seeded") @asynccontextmanager async def lifespan(app: FastAPI): # Create tables on startup, then patch any missing columns async with engine.begin() as conn: await conn.run_sync(_cleanup_orphaned_sequences) try: await conn.run_sync(Base.metadata.create_all) except Exception as e: if "duplicate" in str(e).lower(): print(f" → Tables already exist (partial creation from prior restart): {e}") else: raise await conn.run_sync(_migrate_add_missing_columns) # Auto-seed if database is empty async with async_session() as session: result = await session.execute(select(func.count(Show.id))) count = result.scalar() if count == 0: print(" → Database is empty — running seed...") from seed import seed as run_seed await run_seed() print(" ✓ Full seed completed (skipping station config check)") # Ensure station config exists (runs even if DB already had data) async with async_session() as session: await _ensure_station_config(session) # Bootstrap admin user if configured if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD: async with async_session() as session: result = await session.execute( select(User).where( User.email == f"{settings.ADMIN_USERNAME}@local", User.auth_provider == "local", ) ) if result.scalar_one_or_none() is None: admin_user = User( email=f"{settings.ADMIN_USERNAME}@local", display_name=settings.ADMIN_USERNAME, auth_provider="local", is_admin=True, ) session.add(admin_user) await session.commit() print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}") # Check GeoLite2 database geo_db = settings.GEOLITE2_DB_PATH if os.path.exists(geo_db): print(f" ✓ GeoLite2 database loaded from {geo_db}") else: print(f" WARNING: GeoLite2 database not found at {geo_db} — geo lookups will be disabled") # Auto-parse any unprocessed logs on startup (background task) async def _startup_parse(): try: from app.log_parser import process_unparsed_logs result = await process_unparsed_logs() if result.get("files_processed"): print(f" ✓ Parsed {result['files_processed']} log files ({result['rows_inserted']} geo rows)") else: print(" No unprocessed log files to parse") except Exception as e: print(f" ERROR during startup log parse: {e}") asyncio.create_task(_startup_parse()) yield app = FastAPI( title="KMountain Flower Radio API", description="Backend API for the KMountain Flower Radio Station website.", version="1.0.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Register routers app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(events.router, prefix="/api/events", tags=["events"]) app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"]) app.include_router(history.router, prefix="/api/history", tags=["history"]) app.include_router(team.router, prefix="/api/team", tags=["team"]) app.include_router(community.router, prefix="/api/community", tags=["community"]) app.include_router(shows.router, prefix="/api/shows", tags=["shows"]) app.include_router(storage.router, prefix="/api/storage", tags=["storage"]) app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"]) app.include_router(stats.router, prefix="/api/stats", tags=["stats"]) # Dev-only admin router (disabled in production) if settings.ENV != "production": from app.api import admin app.include_router(admin.router, prefix="/api/admin", tags=["admin"])