From 1e02685b9be1b3fa90216799a484032c708dc47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Turi=20Bal=C3=A1zs?= Date: Mon, 23 Feb 2026 15:52:56 +0100 Subject: [PATCH 01/71] =?UTF-8?q?fix:=20critical=20backend=20fixes=20?= =?UTF-8?q?=E2=80=94=20duplicate=20enums,=20missing=20jobs,=20health=20che?= =?UTF-8?q?cks=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: Critical backend fixes — duplicate enums, missing scheduler jobs, health checks (#1) --- .gitignore | 3 ++ plexaddons-api/app/main.py | 59 ++++++++++++++++++++++++++- plexaddons-api/app/models/__init__.py | 16 -------- plexaddons-api/requirements.txt | 1 - 4 files changed, 61 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 5ee229b..7d61496 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,7 @@ secrets/ # Coverage .coverage htmlcov/ + +# Audit +AUDIT_FINDINGS.txt .pytest_cache/ \ No newline at end of file diff --git a/plexaddons-api/app/main.py b/plexaddons-api/app/main.py index 24fd5db..d93bef0 100644 --- a/plexaddons-api/app/main.py +++ b/plexaddons-api/app/main.py @@ -107,6 +107,24 @@ async def publish_scheduled_versions(): print(f"[Scheduler] Published {len(versions)} scheduled versions") +async def cleanup_analytics_data(): + """Scheduled task to clean up old analytics data beyond retention period.""" + from app.services.analytics_service import AnalyticsService + + async with AsyncSessionLocal() as db: + await AnalyticsService.cleanup_old_data(db, retention_days=settings.analytics_retention_premium) + print(f"[Scheduler] Cleaned up analytics data older than {settings.analytics_retention_premium} days") + + +async def recalculate_analytics_unique_users(): + """Scheduled task to recalculate unique user counts from version check logs.""" + from app.services.analytics_service import AnalyticsService + + async with AsyncSessionLocal() as db: + await AnalyticsService.recalculate_unique_users(db) + print("[Scheduler] Recalculated unique user counts for today") + + async def bootstrap_initial_admin(): """Create initial admin user if configured.""" if not settings.initial_admin_discord_id: @@ -191,6 +209,18 @@ async def lifespan(app: FastAPI): "interval", minutes=5, # Check every 5 minutes for scheduled versions ) + scheduler.add_job( + cleanup_analytics_data, + "cron", + hour=5, # Run at 5 AM daily + minute=0, + ) + scheduler.add_job( + recalculate_analytics_unique_users, + "cron", + hour=5, # Run at 5 AM daily + minute=30, + ) scheduler.start() print("[Startup] Scheduler started") @@ -290,11 +320,38 @@ async def add_security_headers(request: Request, call_next): # Health check endpoint @app.get("/health") async def health_check(): - return { + health = { "status": "healthy", "version": settings.app_version, "environment": settings.environment, + "checks": {}, } + + # Check database connectivity + try: + async with AsyncSessionLocal() as db: + from sqlalchemy import text + await db.execute(text("SELECT 1")) + health["checks"]["database"] = "ok" + except Exception as e: + health["checks"]["database"] = f"error: {str(e)}" + health["status"] = "degraded" + + # Check Redis connectivity + try: + from app.core.rate_limit import get_redis_client + rc = get_redis_client() + if rc: + await rc.ping() + health["checks"]["redis"] = "ok" + else: + health["checks"]["redis"] = "not configured" + except Exception as e: + health["checks"]["redis"] = f"error: {str(e)}" + health["status"] = "degraded" + + status_code = 200 if health["status"] == "healthy" else 503 + return JSONResponse(content=health, status_code=status_code) @app.get("/") diff --git a/plexaddons-api/app/models/__init__.py b/plexaddons-api/app/models/__init__.py index 613fecb..e6a39d9 100644 --- a/plexaddons-api/app/models/__init__.py +++ b/plexaddons-api/app/models/__init__.py @@ -88,22 +88,6 @@ class TicketCategory(str, enum.Enum): BUG_REPORT = "bug_report" -class SubscriptionStatus(str, enum.Enum): - ACTIVE = "active" - PAST_DUE = "past_due" - CANCELED = "canceled" - UNPAID = "unpaid" - TRIALING = "trialing" - PAUSED = "paused" - INCOMPLETE = "incomplete" - INCOMPLETE_EXPIRED = "incomplete_expired" - - -class PaymentProvider(str, enum.Enum): - STRIPE = "stripe" - PAYPAL = "paypal" - - class User(Base): __tablename__ = "users" diff --git a/plexaddons-api/requirements.txt b/plexaddons-api/requirements.txt index 52c604a..22dcb02 100644 --- a/plexaddons-api/requirements.txt +++ b/plexaddons-api/requirements.txt @@ -12,7 +12,6 @@ psycopg2-binary==2.9.9 # Redis redis==5.0.1 -aioredis==2.0.1 # Authentication python-jose[cryptography]==3.3.0 From 9408b1e329901ee576f41d7e2a20d11d7cb47638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Turi=20Bal=C3=A1zs?= Date: Mon, 23 Feb 2026 15:53:05 +0100 Subject: [PATCH 02/71] perf: fix N+1 queries in addon listing endpoints (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit perf: Fix N+1 queries in addon listing — 60+ queries → 1 (#2) --- plexaddons-api/app/services/addon_service.py | 161 ++++++++++++------- 1 file changed, 101 insertions(+), 60 deletions(-) diff --git a/plexaddons-api/app/services/addon_service.py b/plexaddons-api/app/services/addon_service.py index 0d89850..a31d881 100644 --- a/plexaddons-api/app/services/addon_service.py +++ b/plexaddons-api/app/services/addon_service.py @@ -1,7 +1,8 @@ from typing import Optional, List from fastapi import BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ +from sqlalchemy import select, func, and_, literal_column +from sqlalchemy.orm import aliased from app.models import Addon, Version, User from app.schemas import AddonCreate, AddonUpdate from app.utils import slugify @@ -126,12 +127,8 @@ async def list_addons( search: Optional[str] = None, public_only: bool = True, ) -> tuple[List[dict], int]: - """List addons with latest version info.""" - # Base query - query = select(Addon) - count_query = select(func.count(Addon.id)) - - # Filters + """List addons with latest version info using optimized JOINs.""" + # Base filters filters = [] if public_only: filters.append(Addon.is_public == True) @@ -142,41 +139,73 @@ async def list_addons( safe_search = sanitize_ilike_pattern(search) filters.append(Addon.name.ilike(f"%{safe_search}%")) + # Get total count + count_query = select(func.count(Addon.id)) if filters: - query = query.where(and_(*filters)) count_query = count_query.where(and_(*filters)) - - # Get total count total_result = await db.execute(count_query) total = total_result.scalar() or 0 - # Get addons + # Subquery for latest version per addon + latest_version_sq = ( + select( + Version.addon_id, + Version.version.label("latest_version"), + Version.release_date.label("latest_release_date"), + func.row_number().over( + partition_by=Version.addon_id, + order_by=[Version.release_date.desc(), Version.created_at.desc()] + ).label("rn") + ) + .subquery("latest_v") + ) + + # Subquery for version count per addon + version_count_sq = ( + select( + Version.addon_id, + func.count(Version.id).label("version_count") + ) + .group_by(Version.addon_id) + .subquery("v_count") + ) + + # Main query with JOINs + query = ( + select( + Addon, + User.discord_username.label("owner_username"), + User.discord_id.label("owner_discord_id"), + latest_version_sq.c.latest_version, + latest_version_sq.c.latest_release_date, + func.coalesce(version_count_sq.c.version_count, 0).label("version_count"), + ) + .join(User, User.id == Addon.owner_id, isouter=True) + .join( + latest_version_sq, + and_( + latest_version_sq.c.addon_id == Addon.id, + latest_version_sq.c.rn == 1, + ), + isouter=True, + ) + .join( + version_count_sq, + version_count_sq.c.addon_id == Addon.id, + isouter=True, + ) + ) + + if filters: + query = query.where(and_(*filters)) + query = query.order_by(Addon.updated_at.desc()).offset(skip).limit(limit) result = await db.execute(query) - addons = result.scalars().all() + rows = result.all() - # Enrich with latest version and owner info enriched_addons = [] - for addon in addons: - # Get owner - owner_result = await db.execute(select(User).where(User.id == addon.owner_id)) - owner = owner_result.scalar_one_or_none() - - # Get latest version - latest_version_result = await db.execute( - select(Version) - .where(Version.addon_id == addon.id) - .order_by(Version.release_date.desc(), Version.created_at.desc()) - .limit(1) - ) - latest_version = latest_version_result.scalar_one_or_none() - - # Get version count - version_count_result = await db.execute( - select(func.count(Version.id)).where(Version.addon_id == addon.id) - ) - version_count = version_count_result.scalar() or 0 - + for row in rows: + addon = row[0] enriched_addons.append({ "id": addon.id, "slug": addon.slug, @@ -189,11 +218,11 @@ async def list_addons( "is_public": addon.is_public, "verified": addon.verified, "owner_id": addon.owner_id, - "owner_username": owner.discord_username if owner else None, - "owner_discord_id": owner.discord_id if owner else None, - "latest_version": latest_version.version if latest_version else None, - "latest_release_date": latest_version.release_date if latest_version else None, - "version_count": version_count, + "owner_username": row.owner_username, + "owner_discord_id": row.owner_discord_id, + "latest_version": row.latest_version, + "latest_release_date": row.latest_release_date, + "version_count": row.version_count, "created_at": addon.created_at, "updated_at": addon.updated_at, }) @@ -204,32 +233,47 @@ async def list_addons( async def get_all_public_addons_for_json(db: AsyncSession, client_ip_hash: str = None) -> List[dict]: """ Get all public addons with latest version for versions.json format. + Uses optimized JOINs instead of per-addon queries. Args: client_ip_hash: Optional hashed IP for A/B rollout consistency. If provided, respects rollout_percentage. """ - result = await db.execute( - select(Addon) + # Fetch all public addons with owner info in a single query + addons_query = ( + select(Addon, User.discord_username.label("owner_username")) + .join(User, User.id == Addon.owner_id, isouter=True) .where(Addon.is_public == True) .where(Addon.is_active == True) ) - addons = result.scalars().all() + addons_result = await db.execute(addons_query) + addon_rows = addons_result.all() + + if not addon_rows: + return [] + + # Collect addon IDs + addon_ids = [row[0].id for row in addon_rows] + + # Fetch all published versions for these addons in a single query + versions_result = await db.execute( + select(Version) + .where(Version.addon_id.in_(addon_ids)) + .where(Version.is_published == True) + .order_by(Version.addon_id, Version.release_date.desc(), Version.created_at.desc()) + ) + all_versions = versions_result.scalars().all() + + # Group versions by addon_id + versions_by_addon: dict[int, list] = {} + for v in all_versions: + versions_by_addon.setdefault(v.addon_id, []).append(v) addon_data = [] - for addon in addons: - # Get owner - owner_result = await db.execute(select(User).where(User.id == addon.owner_id)) - owner = owner_result.scalar_one_or_none() - - # Get latest PUBLISHED version that user is eligible for - latest_version_result = await db.execute( - select(Version) - .where(Version.addon_id == addon.id) - .where(Version.is_published == True) # Only published versions - .order_by(Version.release_date.desc(), Version.created_at.desc()) - ) - versions = latest_version_result.scalars().all() + for row in addon_rows: + addon = row[0] + owner_username = row.owner_username + versions = versions_by_addon.get(addon.id, []) # Find the latest version this user is eligible for (based on rollout) latest_version = None @@ -243,12 +287,9 @@ async def get_all_public_addons_for_json(db: AsyncSession, client_ip_hash: str = if hash_value < version.rollout_percentage: latest_version = version break - elif version.rollout_percentage >= 100: - latest_version = version - break - # Fallback to first published version if no rollout match - if not latest_version and versions: + # Fallback to first fully-rolled-out published version + if not latest_version: for v in versions: if v.rollout_percentage >= 100: latest_version = v @@ -265,7 +306,7 @@ async def get_all_public_addons_for_json(db: AsyncSession, client_ip_hash: str = "breaking": latest_version.breaking, "urgent": latest_version.urgent, "external": addon.external, - "author": owner.discord_username if owner else None, + "author": owner_username, "homepage": addon.homepage, "changelog": latest_version.changelog_url, "tags": addon.tags or [], From 66dc2daef7581a3298ab0952ca908dd07db43095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Turi=20Bal=C3=A1zs?= Date: Mon, 23 Feb 2026 15:53:14 +0100 Subject: [PATCH 03/71] =?UTF-8?q?fix:=20frontend=20routing=20fixes=20?= =?UTF-8?q?=E2=80=94=20React=20Router=20navigation,=20404=20page,=20copyri?= =?UTF-8?q?ght=20year=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: Frontend routing fixes — React Router navigation, 404 page, copyright year (#3) --- plexaddons-web/src/App.tsx | 4 ++++ plexaddons-web/src/components/Layout.tsx | 2 +- plexaddons-web/src/pages/AddonDetail.tsx | 5 +++-- plexaddons-web/src/pages/Addons.tsx | 5 +++-- plexaddons-web/src/pages/NotFound.tsx | 22 ++++++++++++++++++++++ 5 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 plexaddons-web/src/pages/NotFound.tsx diff --git a/plexaddons-web/src/App.tsx b/plexaddons-web/src/App.tsx index 35dcaca..63f8c61 100644 --- a/plexaddons-web/src/App.tsx +++ b/plexaddons-web/src/App.tsx @@ -36,6 +36,7 @@ import AdminAuditLog from './pages/admin/AdminAuditLog' import AdminTickets from './pages/admin/AdminTickets' import AdminTicketDetail from './pages/admin/AdminTicketDetail' import AdminCannedResponses from './pages/admin/AdminCannedResponses' +import NotFound from './pages/NotFound' import ProtectedRoute from './components/ProtectedRoute' import AdminRoute from './components/AdminRoute' @@ -107,6 +108,9 @@ export default function App() { } /> } /> + + {/* Catch-all 404 */} + } /> diff --git a/plexaddons-web/src/components/Layout.tsx b/plexaddons-web/src/components/Layout.tsx index 1424537..943693d 100644 --- a/plexaddons-web/src/components/Layout.tsx +++ b/plexaddons-web/src/components/Layout.tsx @@ -87,7 +87,7 @@ export default function Layout() {