Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from models.assets import Save, Screenshot, State # noqa
from models.base import BaseModel
from models.collection import VirtualCollection
from models.download_event import DownloadEvent # noqa
from models.firmware import Firmware # noqa
from models.music import MusicFavoriteTrack, MusicPlaylist, MusicPlaylistTrack # noqa
from models.platform import Platform # noqa
Expand Down
120 changes: 120 additions & 0 deletions backend/alembic/versions/0108_download_statistics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Add download_events table and rom download counters

Revision ID: 0108_download_statistics
Revises: 0107_roms_dedup_cover_index
Create Date: 2026-07-29 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "0108_download_statistics"
down_revision = "0107_roms_dedup_cover_index"
branch_labels = None
depends_on = None

DOWNLOAD_SOURCES = ("webui", "basic_auth", "client_token", "oauth", "anonymous")
DOWNLOAD_KINDS = ("rom", "file")


def upgrade() -> None:
op.create_table(
"download_events",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("rom_id", sa.Integer(), nullable=True),
sa.Column("platform_id", sa.Integer(), nullable=True),
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("rom_name", sa.String(length=450), nullable=False),
sa.Column("platform_name", sa.String(length=400), nullable=False),
sa.Column(
"source",
sa.Enum(
*DOWNLOAD_SOURCES,
native_enum=False,
length=20,
name="download_source",
),
nullable=False,
),
sa.Column(
"kind",
sa.Enum(
*DOWNLOAD_KINDS,
native_enum=False,
length=10,
name="download_kind",
),
nullable=False,
),
sa.Column("file_count", sa.Integer(), nullable=False, server_default="1"),
sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("client_ip", sa.String(length=45), nullable=True),
sa.Column("user_agent", sa.String(length=512), nullable=True),
sa.Column("downloaded_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["rom_id"], ["roms.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["platform_id"], ["platforms.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("download_events") as batch_op:
batch_op.create_index(
"ix_download_events_rom_time",
["rom_id", "downloaded_at"],
)
batch_op.create_index(
"ix_download_events_user_time",
["user_id", "downloaded_at"],
)
batch_op.create_index(
"ix_download_events_time",
["downloaded_at"],
)

with op.batch_alter_table("roms") as batch_op:
batch_op.add_column(
sa.Column(
"download_count",
sa.BigInteger(),
nullable=False,
server_default="0",
)
)
batch_op.add_column(
sa.Column(
"last_downloaded_at",
sa.TIMESTAMP(timezone=True),
nullable=True,
)
)
batch_op.create_index(
"ix_roms_download_count",
["download_count"],
)


def downgrade() -> None:
with op.batch_alter_table("roms") as batch_op:
batch_op.drop_index("ix_roms_download_count")
batch_op.drop_column("last_downloaded_at")
batch_op.drop_column("download_count")

with op.batch_alter_table("download_events") as batch_op:
batch_op.drop_index("ix_download_events_time")
batch_op.drop_index("ix_download_events_user_time")
batch_op.drop_index("ix_download_events_rom_time")
op.drop_table("download_events")
9 changes: 9 additions & 0 deletions backend/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,15 @@ def _get_env(var: str, fallback: str | None = None) -> str | None:
"SCHEDULED_RETROACHIEVEMENTS_PROGRESS_SYNC_CRON",
"0 4 * * *", # At 4:00 AM every day
)
# Age limit for rows in `download_events`. 0 (the default) keeps them forever;
# the per-rom counters are lifetime totals and are never affected by pruning.
DOWNLOAD_EVENTS_RETENTION_DAYS: Final[int] = safe_int(
_get_env("DOWNLOAD_EVENTS_RETENTION_DAYS"), 0
)
SCHEDULED_CLEANUP_DOWNLOAD_EVENTS_CRON: Final[str] = _get_env(
"SCHEDULED_CLEANUP_DOWNLOAD_EVENTS_CRON",
"30 4 * * *", # At 4:30 AM every day
)

# SYNC
SYNC_BASE_PATH: Final[str] = f"{ROMM_BASE_PATH}/sync"
Expand Down
103 changes: 103 additions & 0 deletions backend/endpoints/downloads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from datetime import datetime, timedelta, timezone
from typing import Annotated

from fastapi import Query, Request

from decorators.auth import protected_route
from endpoints.responses.downloads import DownloadLogPage, DownloadStatsOverview
from handler.auth.constants import Scope
from handler.database import db_download_handler
from models.download_event import DownloadSource
from utils.router import APIRouter

router = APIRouter(
prefix="/stats/downloads",
tags=["downloads"],
)

DEFAULT_WINDOW_DAYS = 30
MAX_WINDOW_DAYS = 365


def _window_start(days: int) -> datetime:
return datetime.now(timezone.utc) - timedelta(days=days)


# Every route here is admin-gated: the log carries usernames, IPs and user
# agents, which no non-admin should be able to read.


@protected_route(router.get, "", [Scope.USERS_READ])
def get_download_overview(
request: Request,
days: Annotated[
int,
Query(
description="Size of the trailing window used for the timeline and windowed totals.",
ge=1,
le=MAX_WINDOW_DAYS,
),
] = DEFAULT_WINDOW_DAYS,
top_limit: Annotated[
int,
Query(
description="How many entries to return in the top-roms list.",
ge=1,
le=100,
),
] = 10,
) -> DownloadStatsOverview:
"""Aggregate download statistics for the admin center (admin only)."""
since = _window_start(days)

return DownloadStatsOverview(
summary=db_download_handler.get_summary(since=since),
top_roms=db_download_handler.get_top_roms(limit=top_limit),
by_platform=db_download_handler.get_downloads_by_platform(),
by_source=db_download_handler.get_downloads_by_source(),
timeline=db_download_handler.get_timeline(days=days),
)


@protected_route(router.get, "/log", [Scope.USERS_READ])
def get_download_log(
request: Request,
limit: Annotated[int, Query(description="Page size.", ge=1, le=200)] = 50,
offset: Annotated[int, Query(description="Rows to skip.", ge=0)] = 0,
rom_id: Annotated[
int | None, Query(description="Only downloads of this rom.", ge=1)
] = None,
user_id: Annotated[
int | None, Query(description="Only downloads by this user.", ge=1)
] = None,
platform_id: Annotated[
int | None, Query(description="Only downloads from this platform.", ge=1)
] = None,
source: Annotated[
DownloadSource | None, Query(description="Only downloads from this source.")
] = None,
days: Annotated[
int | None,
Query(
description="Only downloads from the last N days.",
ge=1,
le=MAX_WINDOW_DAYS,
),
] = None,
) -> DownloadLogPage:
"""Paginated per-download log, newest first (admin only)."""
return db_download_handler.get_download_log(
limit=limit,
offset=offset,
rom_id=rom_id,
user_id=user_id,
platform_id=platform_id,
source=source,
since=_window_start(days) if days else None,
)


@protected_route(router.post, "/resync", [Scope.USERS_WRITE])
def resync_download_counters(request: Request) -> dict[str, int]:
"""Rebuild the per-rom counters from the event log (admin only)."""
return {"roms_with_downloads": db_download_handler.resync_rom_counters()}
80 changes: 80 additions & 0 deletions backend/endpoints/responses/downloads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from typing import TypedDict

from .base import UTCDatetime


class DownloadLogEntry(TypedDict):
"""One served download. Admin-only: carries user identity and client info."""

id: int
user_id: int | None
username: str
rom_id: int | None
rom_name: str
platform_id: int | None
platform_name: str
source: str
kind: str
file_count: int
size_bytes: int
client_ip: str | None
user_agent: str | None
downloaded_at: UTCDatetime


class DownloadLogPage(TypedDict):
items: list[DownloadLogEntry]
total: int
limit: int
offset: int


class TopDownloadedRom(TypedDict):
rom_id: int
rom_name: str
platform_id: int
platform_name: str
platform_slug: str
path_cover_small: str | None
download_count: int
last_downloaded_at: UTCDatetime | None
file_size_bytes: int


class PlatformDownloadStat(TypedDict):
platform_id: int
platform_name: str
platform_slug: str
download_count: int
size_bytes: int


class DownloadSourceStat(TypedDict):
source: str
count: int


class DownloadTimelinePoint(TypedDict):
date: str
count: int
size_bytes: int


class DownloadStatsSummary(TypedDict):
total_downloads: int
total_bytes: int
downloads_in_window: int
bytes_in_window: int
unique_roms_downloaded: int
unique_users: int
roms_total: int
never_downloaded_count: int
never_downloaded_bytes: int


class DownloadStatsOverview(TypedDict):
summary: DownloadStatsSummary
top_roms: list[TopDownloadedRom]
by_platform: list[PlatformDownloadStat]
by_source: list[DownloadSourceStat]
timeline: list[DownloadTimelinePoint]
4 changes: 4 additions & 0 deletions backend/endpoints/responses/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ class RomSchema(BaseModel):
missing_from_fs: bool
has_notes: bool

# Aggregate only. Who downloaded what stays in the admin-only download log.
download_count: int
last_downloaded_at: UTCDatetime | None

rom_user: RomUserSchema
merged_screenshots: list[str]
merged_ra_metadata: RomRAMetadata | None
Expand Down
9 changes: 9 additions & 0 deletions backend/endpoints/roms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,12 @@
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
from models.download_event import DownloadKind
from models.permission import PermAction, PermEntity
from models.rom import Rom, RomUserStatus, compute_name_sort_key
from utils.background_tasks import fire_and_forget
from utils.database import safe_int, safe_str_to_bool
from utils.downloads import record_rom_download
from utils.filesystem import sanitize_filename
from utils.hashing import crc32_to_hex
from utils.m3u import generate_m3u_content
Expand Down Expand Up @@ -1003,6 +1005,11 @@ async def download_roms(
f"User {hl(current_username, color=BLUE)} is downloading {len(rom_objects)} ROMs as zip"
)

# One event per rom, so a bulk zip credits each title instead of being
# invisible to the stats.
for rom in rom_objects:
record_rom_download(request, rom, rom.files, kind=DownloadKind.ROM)

all_entries = []
for rom in rom_objects:
rom_files = sorted(rom.files, key=lambda x: x.file_name)
Expand Down Expand Up @@ -1369,6 +1376,8 @@ async def get_rom_content(
f"User {hl(current_username, color=BLUE)} is downloading {hl(rom.fs_name)}"
)

record_rom_download(request, rom, files, kind=DownloadKind.ROM)

# If .cue files are present, only list those in the M3U
# (avoids invalid entries like raw .bin tracks)
cue_files = [f for f in files if f.file_extension.lower() == "cue"]
Expand Down
8 changes: 8 additions & 0 deletions backend/endpoints/roms/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
from models.download_event import DownloadKind
from models.permission import PermAction, PermEntity
from models.rom import RomFileCategory
from utils.audio_tags import guess_audio_media_type
from utils.downloads import record_rom_download
from utils.media_types import (
guess_media_file_type,
is_allowed_document_file,
Expand Down Expand Up @@ -131,6 +133,12 @@ async def get_romfile_content(
# Markdown manual into HTML).
headers = {"X-Content-Type-Options": "nosniff"} if disposition == "inline" else {}

# Inline media (soundtrack tracks, manual pages, cover art) is rendered in
# the details view rather than saved, so only attachments count as a
# download. Otherwise opening a game page would inflate its stats.
if disposition == "attachment":
record_rom_download(request, rom, [file], kind=DownloadKind.FILE)

# Serve the file directly in development mode for emulatorjs
if DEV_MODE:
rom_path = fs_rom_handler.validate_path(file.full_path)
Expand Down
Loading