diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 4b278d2b93..67a4d19e96 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -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 diff --git a/backend/alembic/versions/0108_download_statistics.py b/backend/alembic/versions/0108_download_statistics.py new file mode 100644 index 0000000000..7485f3f51e --- /dev/null +++ b/backend/alembic/versions/0108_download_statistics.py @@ -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") diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 500ae95714..7ff955cfc9 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -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" diff --git a/backend/endpoints/downloads.py b/backend/endpoints/downloads.py new file mode 100644 index 0000000000..3dd910103d --- /dev/null +++ b/backend/endpoints/downloads.py @@ -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()} diff --git a/backend/endpoints/responses/downloads.py b/backend/endpoints/responses/downloads.py new file mode 100644 index 0000000000..777163d3f0 --- /dev/null +++ b/backend/endpoints/responses/downloads.py @@ -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] diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index aa8d7084d1..58f8b89da6 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -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 diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 203f5dd402..688328a82a 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -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 @@ -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) @@ -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"] diff --git a/backend/endpoints/roms/files.py b/backend/endpoints/roms/files.py index 0d6b344ab6..0ecf30c91a 100644 --- a/backend/endpoints/roms/files.py +++ b/backend/endpoints/roms/files.py @@ -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, @@ -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) diff --git a/backend/handler/auth/constants.py b/backend/handler/auth/constants.py index 1b7f39d61d..5ed1822e4f 100644 --- a/backend/handler/auth/constants.py +++ b/backend/handler/auth/constants.py @@ -9,6 +9,21 @@ SESSION_COOKIE_NAME: Final = "romm_session" +class AuthMethod(enum.StrEnum): + """How a request proved who it is. + + Stashed on `request.state.auth_method` by HybridAuthBackend so downstream + code (e.g. download logging) can tell a browser apart from an API client. + """ + + SESSION = "session" + BASIC = "basic" + # Names an auth mechanism, not a credential. + CLIENT_TOKEN = "client_token" # nosec B105 + OAUTH = "oauth" + KIOSK = "kiosk" + + class Scope(enum.StrEnum): ME_READ = "me.read" ME_WRITE = "me.write" diff --git a/backend/handler/auth/hybrid_auth.py b/backend/handler/auth/hybrid_auth.py index eb38a44cd0..c22cd98a80 100644 --- a/backend/handler/auth/hybrid_auth.py +++ b/backend/handler/auth/hybrid_auth.py @@ -14,7 +14,7 @@ from models.user import User from utils.datetime import to_utc -from .constants import READ_SCOPES +from .constants import READ_SCOPES, AuthMethod class HybridAuthBackend(AuthenticationBackend): @@ -25,6 +25,7 @@ async def authenticate( user = await auth_handler.get_current_active_user_from_session(conn) if user: user.set_last_active() + conn.state.auth_method = AuthMethod.SESSION return (AuthCredentials(user.oauth_scopes), user) # Check if Authorization header exists @@ -48,6 +49,7 @@ async def authenticate( return None user.set_last_active() + conn.state.auth_method = AuthMethod.BASIC return (AuthCredentials(user.oauth_scopes), user) # Check if bearer auth header is valid @@ -73,6 +75,7 @@ async def authenticate( db_client_token_handler.update_last_used(client_token.id) user.set_last_active() + conn.state.auth_method = AuthMethod.CLIENT_TOKEN conn.state.client_token_id = client_token.id conn.state.device_id = client_token.device_id if client_token.device_id: @@ -99,11 +102,13 @@ async def authenticate( overlapping_scopes = list(token_scopes & set(user.oauth_scopes)) user.set_last_active() + conn.state.auth_method = AuthMethod.OAUTH return (AuthCredentials(overlapping_scopes), user) # Check if we're in KIOSK_MODE if KIOSK_MODE: user = User.kiosk_mode_user() + conn.state.auth_method = AuthMethod.KIOSK return (AuthCredentials(READ_SCOPES), user) return None diff --git a/backend/handler/database/__init__.py b/backend/handler/database/__init__.py index 1d2f4cfe49..bcf92b8e2c 100644 --- a/backend/handler/database/__init__.py +++ b/backend/handler/database/__init__.py @@ -2,6 +2,7 @@ from .collections_handler import DBCollectionsHandler from .device_save_sync_handler import DBDeviceSaveSyncHandler from .devices_handler import DBDevicesHandler +from .downloads_handler import DBDownloadsHandler from .firmware_handler import DBFirmwareHandler from .music_playlists_handler import DBMusicPlaylistsHandler from .permissions_handler import DBPermissionsHandler @@ -19,6 +20,7 @@ db_collection_handler = DBCollectionsHandler() db_device_handler = DBDevicesHandler() db_device_save_sync_handler = DBDeviceSaveSyncHandler() +db_download_handler = DBDownloadsHandler() db_firmware_handler = DBFirmwareHandler() db_music_playlist_handler = DBMusicPlaylistsHandler() db_permission_handler = DBPermissionsHandler() diff --git a/backend/handler/database/downloads_handler.py b/backend/handler/database/downloads_handler.py new file mode 100644 index 0000000000..809c3e0793 --- /dev/null +++ b/backend/handler/database/downloads_handler.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +from sqlalchemy import Date, cast, delete, distinct, func, select, update +from sqlalchemy.orm import Session +from sqlalchemy.sql.elements import ColumnElement + +from decorators.database import begin_session +from endpoints.responses.downloads import ( + DownloadLogEntry, + DownloadLogPage, + DownloadSourceStat, + DownloadStatsSummary, + DownloadTimelinePoint, + PlatformDownloadStat, + TopDownloadedRom, +) +from models.download_event import DownloadEvent, DownloadKind, DownloadSource +from models.platform import Platform +from models.rom import Rom, RomFile + +from .base_handler import DBBaseHandler + +MAX_LOG_PAGE_SIZE = 200 +MAX_TOP_ROMS = 100 + + +def _rom_size_subquery(): + """Per-rom byte total, matching how the server-wide filesize stat is computed.""" + return ( + select( + RomFile.rom_id.label("rom_id"), + func.coalesce(func.sum(RomFile.file_size_bytes), 0).label("size_bytes"), + ) + .group_by(RomFile.rom_id) + .subquery() + ) + + +def _serialize_event(event: DownloadEvent) -> DownloadLogEntry: + return DownloadLogEntry( + id=event.id, + user_id=event.user_id, + username=event.username, + rom_id=event.rom_id, + rom_name=event.rom_name, + platform_id=event.platform_id, + platform_name=event.platform_name, + source=str(event.source), + kind=str(event.kind), + file_count=event.file_count, + size_bytes=event.size_bytes, + client_ip=event.client_ip, + user_agent=event.user_agent, + downloaded_at=event.downloaded_at, + ) + + +class DBDownloadsHandler(DBBaseHandler): + @begin_session + def record_download( + self, + rom: Rom, + username: str, + source: DownloadSource, + kind: DownloadKind, + file_count: int, + size_bytes: int, + user_id: int | None = None, + client_ip: str | None = None, + user_agent: str | None = None, + session: Session = None, # type: ignore + ) -> DownloadEvent: + """Log a download and bump the rom's denormalized counters.""" + now = datetime.now(timezone.utc) + + event = DownloadEvent( + user_id=user_id, + rom_id=rom.id, + platform_id=rom.platform_id, + username=username, + rom_name=rom.name or rom.fs_name, + platform_name=rom.platform_display_name, + source=source, + kind=kind, + file_count=file_count, + size_bytes=size_bytes, + client_ip=client_ip, + user_agent=user_agent, + downloaded_at=now, + ) + session.add(event) + + # Bump in SQL rather than read-modify-write so concurrent downloads of + # the same rom can't lose an increment. + session.execute( + update(Rom) + .where(Rom.id == rom.id) + .values( + download_count=Rom.download_count + 1, + last_downloaded_at=now, + ) + .execution_options(synchronize_session=False) + ) + session.flush() + return event + + @begin_session + def get_download_log( + self, + limit: int = 50, + offset: int = 0, + rom_id: int | None = None, + user_id: int | None = None, + platform_id: int | None = None, + source: DownloadSource | None = None, + since: datetime | None = None, + session: Session = None, # type: ignore + ) -> DownloadLogPage: + """Paginated per-download log, newest first.""" + limit = max(1, min(limit, MAX_LOG_PAGE_SIZE)) + offset = max(0, offset) + + filters = [] + if rom_id is not None: + filters.append(DownloadEvent.rom_id == rom_id) + if user_id is not None: + filters.append(DownloadEvent.user_id == user_id) + if platform_id is not None: + filters.append(DownloadEvent.platform_id == platform_id) + if source is not None: + filters.append(DownloadEvent.source == source) + if since is not None: + filters.append(DownloadEvent.downloaded_at >= since) + + total = ( + session.scalar( + select(func.count()).select_from(DownloadEvent).where(*filters) + ) + or 0 + ) + + events = ( + session.scalars( + select(DownloadEvent) + .where(*filters) + .order_by(DownloadEvent.downloaded_at.desc(), DownloadEvent.id.desc()) + .limit(limit) + .offset(offset) + ) + .unique() + .all() + ) + + return DownloadLogPage( + items=[_serialize_event(e) for e in events], + total=total, + limit=limit, + offset=offset, + ) + + @begin_session + def get_summary( + self, + since: datetime | None = None, + session: Session = None, # type: ignore + ) -> DownloadStatsSummary: + """Headline counters for the admin overview.""" + totals = session.execute( + select( + func.count(DownloadEvent.id), + func.coalesce(func.sum(DownloadEvent.size_bytes), 0), + func.count(distinct(DownloadEvent.rom_id)), + func.count(distinct(DownloadEvent.user_id)), + ) + ).one() + + if since is not None: + windowed_row = session.execute( + select( + func.count(DownloadEvent.id), + func.coalesce(func.sum(DownloadEvent.size_bytes), 0), + ).where(DownloadEvent.downloaded_at >= since) + ).one() + windowed_count, windowed_bytes = windowed_row[0], windowed_row[1] + else: + windowed_count, windowed_bytes = totals[0], totals[1] + + roms_total = session.scalar(select(func.count()).select_from(Rom)) or 0 + + size_sq = _rom_size_subquery() + never = session.execute( + select( + func.count(Rom.id), + func.coalesce(func.sum(size_sq.c.size_bytes), 0), + ) + .select_from(Rom) + .outerjoin(size_sq, size_sq.c.rom_id == Rom.id) + .where(Rom.download_count == 0) + ).one() + + return DownloadStatsSummary( + total_downloads=int(totals[0] or 0), + total_bytes=int(totals[1] or 0), + downloads_in_window=int(windowed_count or 0), + bytes_in_window=int(windowed_bytes or 0), + unique_roms_downloaded=int(totals[2] or 0), + unique_users=int(totals[3] or 0), + roms_total=roms_total, + never_downloaded_count=int(never[0] or 0), + never_downloaded_bytes=int(never[1] or 0), + ) + + @begin_session + def get_top_roms( + self, + limit: int = 10, + since: datetime | None = None, + session: Session = None, # type: ignore + ) -> list[TopDownloadedRom]: + """Most-downloaded roms. Without `since` this reads the denormalized + counter; with one it counts events inside the window.""" + limit = max(1, min(limit, MAX_TOP_ROMS)) + size_sq = _rom_size_subquery() + + if since is None: + rows = session.execute( + select( + Rom.id, + Rom.name, + Rom.fs_name, + Rom.platform_id, + Platform.name, + Platform.custom_name, + Platform.slug, + Rom.path_cover_s, + Rom.download_count, + Rom.last_downloaded_at, + func.coalesce(size_sq.c.size_bytes, 0), + ) + .join(Platform, Platform.id == Rom.platform_id) + .outerjoin(size_sq, size_sq.c.rom_id == Rom.id) + .where(Rom.download_count > 0) + .order_by(Rom.download_count.desc(), Rom.id.asc()) + .limit(limit) + ).all() + else: + counts_sq = ( + select( + DownloadEvent.rom_id.label("rom_id"), + func.count(DownloadEvent.id).label("download_count"), + func.max(DownloadEvent.downloaded_at).label("last_downloaded_at"), + ) + .where( + DownloadEvent.downloaded_at >= since, + DownloadEvent.rom_id.is_not(None), + ) + .group_by(DownloadEvent.rom_id) + .subquery() + ) + rows = session.execute( + select( + Rom.id, + Rom.name, + Rom.fs_name, + Rom.platform_id, + Platform.name, + Platform.custom_name, + Platform.slug, + Rom.path_cover_s, + counts_sq.c.download_count, + counts_sq.c.last_downloaded_at, + func.coalesce(size_sq.c.size_bytes, 0), + ) + .join(counts_sq, counts_sq.c.rom_id == Rom.id) + .join(Platform, Platform.id == Rom.platform_id) + .outerjoin(size_sq, size_sq.c.rom_id == Rom.id) + .order_by(counts_sq.c.download_count.desc(), Rom.id.asc()) + .limit(limit) + ).all() + + return [ + TopDownloadedRom( + rom_id=row[0], + rom_name=row[1] or row[2], + platform_id=row[3], + platform_name=row[5] or row[4], + platform_slug=row[6], + path_cover_small=row[7] or None, + download_count=int(row[8] or 0), + last_downloaded_at=row[9], + file_size_bytes=int(row[10] or 0), + ) + for row in rows + ] + + @begin_session + def get_downloads_by_platform( + self, + since: datetime | None = None, + session: Session = None, # type: ignore + ) -> list[PlatformDownloadStat]: + filters: list[ColumnElement[bool]] = [DownloadEvent.platform_id.is_not(None)] + if since is not None: + filters.append(DownloadEvent.downloaded_at >= since) + + rows = session.execute( + select( + Platform.id, + Platform.name, + Platform.custom_name, + Platform.slug, + func.count(DownloadEvent.id), + func.coalesce(func.sum(DownloadEvent.size_bytes), 0), + ) + .join(Platform, Platform.id == DownloadEvent.platform_id) + .where(*filters) + .group_by(Platform.id, Platform.name, Platform.custom_name, Platform.slug) + .order_by(func.count(DownloadEvent.id).desc()) + ).all() + + return [ + PlatformDownloadStat( + platform_id=row[0], + platform_name=row[2] or row[1], + platform_slug=row[3], + download_count=int(row[4] or 0), + size_bytes=int(row[5] or 0), + ) + for row in rows + ] + + @begin_session + def get_downloads_by_source( + self, + since: datetime | None = None, + session: Session = None, # type: ignore + ) -> list[DownloadSourceStat]: + filters = [] + if since is not None: + filters.append(DownloadEvent.downloaded_at >= since) + + rows = session.execute( + select(DownloadEvent.source, func.count(DownloadEvent.id)) + .where(*filters) + .group_by(DownloadEvent.source) + .order_by(func.count(DownloadEvent.id).desc()) + ).all() + + return [ + DownloadSourceStat(source=str(row[0]), count=int(row[1] or 0)) + for row in rows + ] + + @begin_session + def get_timeline( + self, + days: int = 30, + session: Session = None, # type: ignore + ) -> list[DownloadTimelinePoint]: + """Daily download counts for the last `days` days, gap-filled with zeros.""" + days = max(1, min(days, 365)) + today = datetime.now(timezone.utc).date() + start = today - timedelta(days=days - 1) + + # CAST to DATE rather than a dialect-specific date function, so the + # grouping works the same on MariaDB, MySQL and Postgres. + day_col = cast(DownloadEvent.downloaded_at, Date).label("day") + rows = session.execute( + select( + day_col, + func.count(DownloadEvent.id), + func.coalesce(func.sum(DownloadEvent.size_bytes), 0), + ) + .where( + DownloadEvent.downloaded_at + >= datetime(start.year, start.month, start.day, tzinfo=timezone.utc) + ) + .group_by(day_col) + .order_by(day_col) + ).all() + + by_day: dict[date, tuple[int, int]] = {} + for row in rows: + day = ( + row[0] if isinstance(row[0], date) else date.fromisoformat(str(row[0])) + ) + by_day[day] = (int(row[1] or 0), int(row[2] or 0)) + + timeline: list[DownloadTimelinePoint] = [] + for offset in range(days): + day = start + timedelta(days=offset) + count, size_bytes = by_day.get(day, (0, 0)) + timeline.append( + DownloadTimelinePoint( + date=day.isoformat(), count=count, size_bytes=size_bytes + ) + ) + return timeline + + @begin_session + def prune_events_older_than( + self, + cutoff: datetime, + session: Session = None, # type: ignore + ) -> int: + """Delete log rows older than `cutoff`, returning how many went. + + Deliberately does not touch `roms.download_count`, that is a lifetime + total, and an admin pruning the audit log shouldn't silently rewrite + history on the game pages. + """ + result = session.execute( + delete(DownloadEvent) + .where(DownloadEvent.downloaded_at < cutoff) + .execution_options(synchronize_session=False) + ) + return result.rowcount or 0 + + @begin_session + def get_rom_download_count( + self, + rom_id: int, + session: Session = None, # type: ignore + ) -> int: + return ( + session.scalar( + select(func.count()) + .select_from(DownloadEvent) + .where(DownloadEvent.rom_id == rom_id) + ) + or 0 + ) + + @begin_session + def resync_rom_counters( + self, + session: Session = None, # type: ignore + ) -> int: + """Rebuild `roms.download_count` / `last_downloaded_at` from the event log. + + A repair path for counters that drifted (restored backup, manual edits). + Returns the number of roms with a non-zero count afterwards. + + Note the interaction with retention: this rebuilds from the rows that + are still *present*, so running it after the log has been pruned lowers + every counter to the retained window. Only use it when the counters are + actually wrong. + """ + counts_sq = ( + select( + DownloadEvent.rom_id.label("rom_id"), + func.count(DownloadEvent.id).label("download_count"), + func.max(DownloadEvent.downloaded_at).label("last_downloaded_at"), + ) + .where(DownloadEvent.rom_id.is_not(None)) + .group_by(DownloadEvent.rom_id) + .subquery() + ) + + session.execute( + update(Rom) + .values( + download_count=func.coalesce( + select(counts_sq.c.download_count) + .where(counts_sq.c.rom_id == Rom.id) + .scalar_subquery(), + 0, + ), + last_downloaded_at=select(counts_sq.c.last_downloaded_at) + .where(counts_sq.c.rom_id == Rom.id) + .scalar_subquery(), + ) + .execution_options(synchronize_session=False) + ) + + return ( + session.scalar( + select(func.count()).select_from(Rom).where(Rom.download_count > 0) + ) + or 0 + ) diff --git a/backend/main.py b/backend/main.py index bba55b4706..8817da86b8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -37,6 +37,7 @@ from endpoints.configs import router as configs_router from endpoints.device import router as device_router from endpoints.device_auth import router as device_auth_router +from endpoints.downloads import router as downloads_router from endpoints.export import router as export_router from endpoints.feeds import router as feeds_router from endpoints.firmware import router as firmware_router @@ -187,6 +188,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.include_router(feeds_router, prefix="/api") app.include_router(configs_router, prefix="/api") app.include_router(stats_router, prefix="/api") +app.include_router(downloads_router, prefix="/api") app.include_router(logs_router, prefix="/api") app.include_router(screenshots_router, prefix="/api") app.include_router(firmware_router, prefix="/api") diff --git a/backend/models/download_event.py b/backend/models/download_event.py new file mode 100644 index 0000000000..f070ddf0c9 --- /dev/null +++ b/backend/models/download_event.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import enum +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import ( + TIMESTAMP, + BigInteger, + Enum, + ForeignKey, + Index, + Integer, + String, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models.base import FILE_NAME_MAX_LENGTH, BaseModel, utc_now + +if TYPE_CHECKING: + from models.platform import Platform + from models.rom import Rom + from models.user import User + +USERNAME_SNAPSHOT_LENGTH = 255 +PLATFORM_NAME_SNAPSHOT_LENGTH = 400 +USER_AGENT_MAX_LENGTH = 512 +# Long enough for an IPv6 address, including an IPv4-mapped suffix. +CLIENT_IP_MAX_LENGTH = 45 + +ANONYMOUS_USERNAME = "anonymous" + + +class DownloadSource(enum.StrEnum): + """Where a download came from, derived from how the request authenticated.""" + + WEBUI = "webui" + BASIC_AUTH = "basic_auth" + # Names a download source, not a credential. + CLIENT_TOKEN = "client_token" # nosec B105 + OAUTH = "oauth" + ANONYMOUS = "anonymous" + + +class DownloadKind(enum.StrEnum): + """Which download endpoint served the request.""" + + # A whole rom: a single file, or a generated zip for multi-part roms. + ROM = "rom" + # One individual file of a rom (manual, soundtrack track, single disc...). + FILE = "file" + + +def _portable_enum(enum_cls: type[enum.StrEnum], length: int) -> Enum: + """VARCHAR-backed enum so the vocabulary stays portable across dialects.""" + return Enum( + enum_cls, + native_enum=False, + length=length, + values_callable=lambda e: [m.value for m in e], + ) + + +class DownloadEvent(BaseModel): + """One row per served download request. + + Feeds the admin download log and the aggregate counters used to find + content nobody downloads. + """ + + __tablename__ = "download_events" + __table_args__ = ( + Index("ix_download_events_rom_time", "rom_id", "downloaded_at"), + Index("ix_download_events_user_time", "user_id", "downloaded_at"), + Index("ix_download_events_time", "downloaded_at"), + {"extend_existing": True}, + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + # All three FKs null out rather than cascade: the log has to outlive the + # very deletions it exists to inform. + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), default=None + ) + rom_id: Mapped[int | None] = mapped_column( + ForeignKey("roms.id", ondelete="SET NULL"), default=None + ) + platform_id: Mapped[int | None] = mapped_column( + ForeignKey("platforms.id", ondelete="SET NULL"), default=None + ) + + # Snapshots so a row still reads correctly once its FKs are gone. + username: Mapped[str] = mapped_column( + String(length=USERNAME_SNAPSHOT_LENGTH), default=ANONYMOUS_USERNAME + ) + rom_name: Mapped[str] = mapped_column( + String(length=FILE_NAME_MAX_LENGTH), default="" + ) + platform_name: Mapped[str] = mapped_column( + String(length=PLATFORM_NAME_SNAPSHOT_LENGTH), default="" + ) + + source: Mapped[DownloadSource] = mapped_column( + _portable_enum(DownloadSource, 20), default=DownloadSource.ANONYMOUS + ) + kind: Mapped[DownloadKind] = mapped_column( + _portable_enum(DownloadKind, 10), default=DownloadKind.ROM + ) + + file_count: Mapped[int] = mapped_column(Integer(), default=1) + size_bytes: Mapped[int] = mapped_column(BigInteger(), default=0) + + client_ip: Mapped[str | None] = mapped_column( + String(length=CLIENT_IP_MAX_LENGTH), default=None + ) + user_agent: Mapped[str | None] = mapped_column( + String(length=USER_AGENT_MAX_LENGTH), default=None + ) + + downloaded_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), default=utc_now + ) + + user: Mapped[User | None] = relationship(lazy="raise") + rom: Mapped[Rom | None] = relationship(lazy="raise") + platform: Mapped[Platform | None] = relationship(lazy="raise") diff --git a/backend/models/rom.py b/backend/models/rom.py index 95a7b0f9bf..d9e62484eb 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -430,6 +430,15 @@ class Rom(BaseModel): missing_from_fs: Mapped[bool] = mapped_column(default=False, nullable=False) + # Denormalized from download_events so gallery sorting and the "never + # downloaded" sweep don't need an aggregate join per rom. + download_count: Mapped[int] = mapped_column( + BigInteger(), default=0, nullable=False, server_default="0", index=True + ) + last_downloaded_at: Mapped[datetime | None] = mapped_column( + TIMESTAMP(timezone=True), default=None + ) + platform_id: Mapped[int] = mapped_column( ForeignKey("platforms.id", ondelete="CASCADE") ) diff --git a/backend/startup.py b/backend/startup.py index 4d58a41cf3..55781598e5 100644 --- a/backend/startup.py +++ b/backend/startup.py @@ -32,6 +32,7 @@ from tasks.manual.recompute_save_content_hashes import ( recompute_save_content_hashes_task, ) +from tasks.scheduled.cleanup_download_events import cleanup_download_events_task from tasks.scheduled.cleanup_netplay import cleanup_netplay_task from tasks.scheduled.cleanup_orphaned_resources import cleanup_orphaned_resources_task from tasks.scheduled.cleanup_upload_tmp import cleanup_upload_tmp_task @@ -144,6 +145,7 @@ async def main() -> None: cleanup_netplay_task.init() cleanup_zip_cache_task.init() cleanup_upload_tmp_task.init() + cleanup_download_events_task.init() cleanup_orphaned_resources_task.init() if ENABLE_SCHEDULED_RESCAN: diff --git a/backend/tasks/scheduled/cleanup_download_events.py b/backend/tasks/scheduled/cleanup_download_events.py new file mode 100644 index 0000000000..20c812d74d --- /dev/null +++ b/backend/tasks/scheduled/cleanup_download_events.py @@ -0,0 +1,54 @@ +"""Background task to age out rows from the download event log.""" + +from datetime import datetime, timedelta, timezone + +from config import ( + DOWNLOAD_EVENTS_RETENTION_DAYS, + SCHEDULED_CLEANUP_DOWNLOAD_EVENTS_CRON, +) +from handler.database import db_download_handler +from logger.logger import log +from tasks.tasks import PeriodicTask, TaskType + + +class CleanupDownloadEventsTask(PeriodicTask): + """Trims `download_events` to the configured retention window. + + Off by default: the log is an audit trail, so it only shrinks when an + admin sets `DOWNLOAD_EVENTS_RETENTION_DAYS`. Per-rom counters are lifetime + totals and are never rewritten by this task. + """ + + def __init__(self): + super().__init__( + title="Scheduled download log cleanup", + description=( + "Deletes download log entries older than " + "DOWNLOAD_EVENTS_RETENTION_DAYS" + ), + task_type=TaskType.CLEANUP, + enabled=DOWNLOAD_EVENTS_RETENTION_DAYS > 0, + manual_run=True, + cron_string=SCHEDULED_CLEANUP_DOWNLOAD_EVENTS_CRON, + func="tasks.scheduled.cleanup_download_events.cleanup_download_events_task.run", + ) + + async def run(self) -> None: + if not self.enabled or DOWNLOAD_EVENTS_RETENTION_DAYS <= 0: + self.unschedule() + return + + cutoff = datetime.now(timezone.utc) - timedelta( + days=DOWNLOAD_EVENTS_RETENTION_DAYS + ) + deleted = db_download_handler.prune_events_older_than(cutoff) + + if deleted: + log.info( + f"Pruned {deleted} download log " + f"{'entry' if deleted == 1 else 'entries'} older than " + f"{DOWNLOAD_EVENTS_RETENTION_DAYS} days" + ) + + +cleanup_download_events_task = CleanupDownloadEventsTask() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ced230b688..48aa02d6a3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -26,6 +26,7 @@ from models.client_token import ClientToken from models.device import Device from models.device_save_sync import DeviceSaveSync +from models.download_event import DownloadEvent from models.platform import Platform from models.play_session import PlaySession from models.rom import Rom, RomFile @@ -90,6 +91,7 @@ def setup_database(): @pytest.fixture(autouse=True) def clear_database(): with session.begin() as s: + s.query(DownloadEvent).delete(synchronize_session="evaluate") s.query(PlaySession).delete(synchronize_session="evaluate") s.query(ClientToken).delete(synchronize_session="evaluate") s.query(SyncSession).delete(synchronize_session="evaluate") diff --git a/backend/tests/endpoints/roms/test_rom.py b/backend/tests/endpoints/roms/test_rom.py index ff191ef739..88347d6831 100644 --- a/backend/tests/endpoints/roms/test_rom.py +++ b/backend/tests/endpoints/roms/test_rom.py @@ -5,7 +5,11 @@ from fastapi.testclient import TestClient from config.config_manager import MetadataMediaType -from handler.database import db_collection_handler, db_rom_handler +from handler.database import ( + db_collection_handler, + db_download_handler, + db_rom_handler, +) from handler.database.base_handler import sync_session from handler.filesystem.resources_handler import FSResourcesHandler from handler.filesystem.roms_handler import FSRomsHandler @@ -1900,3 +1904,55 @@ def test_update_rom_unmatch_metadata_with_other_data( assert body["igdb_id"] is None assert body["name"] == rom.fs_name assert body["summary"] == "" + + +def test_get_rom_content_records_download( + client: TestClient, access_token: str, rom: Rom, rom_file +): + client.get( + f"/api/roms/{rom.id}/content/test_rom.zip", + headers={"Authorization": f"Bearer {access_token}"}, + follow_redirects=False, + ) + + page = db_download_handler.get_download_log(rom_id=rom.id) + assert page["total"] == 1 + entry = page["items"][0] + assert entry["kind"] == "rom" + assert entry["size_bytes"] == rom_file.file_size_bytes + + refreshed = db_rom_handler.get_rom(rom.id) + assert refreshed is not None + assert refreshed.download_count == 1 + + +def test_failed_rom_download_records_nothing( + client: TestClient, access_token: str, rom: Rom, rom_file +): + # A 404 must not leave a phantom row behind. + client.get( + f"/api/roms/{rom.id}/content/test_rom.zip", + headers={"Authorization": f"Bearer {access_token}"}, + params={"file_ids": str(rom_file.id + 999)}, + follow_redirects=False, + ) + + assert db_download_handler.get_download_log(rom_id=rom.id)["total"] == 0 + + +def test_download_recording_failure_does_not_break_download( + client: TestClient, access_token: str, rom: Rom, rom_file +): + # Stats are best-effort: a broken recorder must still serve the bytes. + with patch( + "utils.downloads.db_download_handler.record_download", + side_effect=RuntimeError("boom"), + ): + response = client.get( + f"/api/roms/{rom.id}/content/test_rom.zip", + headers={"Authorization": f"Bearer {access_token}"}, + follow_redirects=False, + ) + + assert response.status_code == status.HTTP_200_OK + assert db_download_handler.get_download_log(rom_id=rom.id)["total"] == 0 diff --git a/backend/tests/endpoints/test_downloads.py b/backend/tests/endpoints/test_downloads.py new file mode 100644 index 0000000000..7be7e37bb5 --- /dev/null +++ b/backend/tests/endpoints/test_downloads.py @@ -0,0 +1,141 @@ +from fastapi import status + +from handler.database import db_download_handler, db_rom_handler +from models.download_event import DownloadKind, DownloadSource +from models.rom import Rom +from models.user import User + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _record(rom: Rom, user: User, **kwargs): + return db_download_handler.record_download( + rom=rom, + user_id=user.id, + username=user.username, + source=kwargs.pop("source", DownloadSource.WEBUI), + kind=kwargs.pop("kind", DownloadKind.ROM), + file_count=kwargs.pop("file_count", 1), + size_bytes=kwargs.pop("size_bytes", 1000), + **kwargs, + ) + + +def test_overview_requires_auth(client): + # Unauthenticated is 401; a signed-in caller missing the scope gets 403. + response = client.get("/api/stats/downloads") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_log_requires_auth(client): + response = client.get("/api/stats/downloads/log") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_overview_forbidden_without_users_read(client, viewer_access_token): + # `users.read` is admin-tier; a viewer must not see download stats. + response = client.get("/api/stats/downloads", headers=_auth(viewer_access_token)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_log_forbidden_without_users_read(client, viewer_access_token): + # The log carries usernames, IPs and user agents, admin only. + response = client.get( + "/api/stats/downloads/log", headers=_auth(viewer_access_token) + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_overview_returns_stats_for_admin( + client, access_token, rom: Rom, admin_user: User +): + _record(rom, admin_user, size_bytes=2048) + + response = client.get("/api/stats/downloads", headers=_auth(access_token)) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert body["summary"]["total_downloads"] == 1 + assert body["summary"]["total_bytes"] == 2048 + assert body["top_roms"][0]["rom_id"] == rom.id + assert body["by_source"][0]["source"] == "webui" + assert len(body["timeline"]) == 30 + + +def test_overview_honours_window_and_top_limit( + client, access_token, rom: Rom, admin_user: User +): + _record(rom, admin_user) + + response = client.get( + "/api/stats/downloads?days=7&top_limit=1", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert len(body["timeline"]) == 7 + assert len(body["top_roms"]) == 1 + + +def test_overview_rejects_out_of_range_window(client, access_token): + response = client.get("/api/stats/downloads?days=0", headers=_auth(access_token)) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_log_returns_entries_for_admin( + client, access_token, rom: Rom, admin_user: User +): + _record(rom, admin_user, client_ip="10.0.0.9", user_agent="pytest/1.0") + + response = client.get("/api/stats/downloads/log", headers=_auth(access_token)) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert body["total"] == 1 + entry = body["items"][0] + assert entry["username"] == admin_user.username + assert entry["rom_id"] == rom.id + assert entry["client_ip"] == "10.0.0.9" + assert entry["user_agent"] == "pytest/1.0" + + +def test_log_filters_by_source(client, access_token, rom: Rom, admin_user: User): + _record(rom, admin_user, source=DownloadSource.WEBUI) + _record(rom, admin_user, source=DownloadSource.CLIENT_TOKEN) + + response = client.get( + "/api/stats/downloads/log?source=client_token", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["total"] == 1 + assert body["items"][0]["source"] == "client_token" + + +def test_log_rejects_unknown_source(client, access_token): + response = client.get( + "/api/stats/downloads/log?source=carrier-pigeon", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_resync_requires_users_write(client, editor_access_token): + response = client.post( + "/api/stats/downloads/resync", headers=_auth(editor_access_token) + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_resync_rebuilds_counters(client, access_token, rom: Rom, admin_user: User): + _record(rom, admin_user) + db_rom_handler.update_rom(rom.id, {"download_count": 42}) + + response = client.post("/api/stats/downloads/resync", headers=_auth(access_token)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"roms_with_downloads": 1} + + refreshed = db_rom_handler.get_rom(rom.id) + assert refreshed is not None + assert refreshed.download_count == 1 diff --git a/backend/tests/handler/database/test_downloads_handler.py b/backend/tests/handler/database/test_downloads_handler.py new file mode 100644 index 0000000000..cd06abf33b --- /dev/null +++ b/backend/tests/handler/database/test_downloads_handler.py @@ -0,0 +1,240 @@ +from datetime import datetime, timedelta, timezone + +from handler.database import db_download_handler, db_rom_handler +from models.download_event import DownloadKind, DownloadSource +from models.platform import Platform +from models.rom import Rom, RomFile +from models.user import User + + +def _add_rom(platform: Platform, index: int, size_bytes: int = 1000) -> Rom: + rom = db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + name=f"dl_rom_{index}", + slug=f"dl_rom_{index}", + fs_name=f"dl_rom_{index}.zip", + fs_name_no_tags=f"dl_rom_{index}", + fs_name_no_ext=f"dl_rom_{index}", + fs_extension="zip", + fs_path=f"{platform.slug}/roms", + ) + ) + db_rom_handler.add_rom_file( + RomFile( + rom_id=rom.id, + file_name=f"dl_rom_{index}.zip", + file_path=rom.fs_path, + file_size_bytes=size_bytes, + ) + ) + return rom + + +def _record(rom: Rom, user: User | None = None, **kwargs): + return db_download_handler.record_download( + rom=rom, + user_id=user.id if user else None, + username=user.username if user else "anonymous", + source=kwargs.pop("source", DownloadSource.WEBUI), + kind=kwargs.pop("kind", DownloadKind.ROM), + file_count=kwargs.pop("file_count", 1), + size_bytes=kwargs.pop("size_bytes", 1000), + **kwargs, + ) + + +def test_record_download_bumps_rom_counters(rom: Rom, admin_user: User): + assert rom.download_count == 0 + assert rom.last_downloaded_at is None + + _record(rom, admin_user) + _record(rom, admin_user) + + refreshed = db_rom_handler.get_rom(rom.id) + assert refreshed is not None + assert refreshed.download_count == 2 + assert refreshed.last_downloaded_at is not None + + +def test_record_download_snapshots_identity(rom: Rom, admin_user: User): + event = _record(rom, admin_user, client_ip="10.0.0.5", user_agent="pytest/1.0") + + assert event.username == admin_user.username + assert event.rom_name == rom.name + assert event.platform_name == rom.platform_display_name + assert event.client_ip == "10.0.0.5" + assert event.user_agent == "pytest/1.0" + + +def test_record_download_allows_anonymous(rom: Rom): + event = _record(rom, None, source=DownloadSource.ANONYMOUS) + + assert event.user_id is None + assert event.username == "anonymous" + + +def test_get_download_log_is_newest_first_and_paginates(rom: Rom, admin_user: User): + for _ in range(3): + _record(rom, admin_user) + + page = db_download_handler.get_download_log(limit=2, offset=0) + assert page["total"] == 3 + assert len(page["items"]) == 2 + assert page["items"][0]["id"] > page["items"][1]["id"] + + second = db_download_handler.get_download_log(limit=2, offset=2) + assert len(second["items"]) == 1 + + +def test_get_download_log_filters_by_source(rom: Rom, admin_user: User): + _record(rom, admin_user, source=DownloadSource.WEBUI) + _record(rom, admin_user, source=DownloadSource.CLIENT_TOKEN) + + page = db_download_handler.get_download_log(source=DownloadSource.CLIENT_TOKEN) + assert page["total"] == 1 + assert page["items"][0]["source"] == "client_token" + + +def test_get_download_log_clamps_page_size(rom: Rom, admin_user: User): + _record(rom, admin_user) + + page = db_download_handler.get_download_log(limit=10_000, offset=-5) + assert page["limit"] == 200 + assert page["offset"] == 0 + + +def test_get_summary_counts_totals_and_unused( + platform: Platform, admin_user: User, rom: Rom +): + downloaded = _add_rom(platform, 1, size_bytes=2048) + _add_rom(platform, 2, size_bytes=4096) # never downloaded + + _record(downloaded, admin_user, size_bytes=2048) + + summary = db_download_handler.get_summary() + + assert summary["total_downloads"] == 1 + assert summary["total_bytes"] == 2048 + assert summary["unique_roms_downloaded"] == 1 + assert summary["unique_users"] == 1 + # The `rom` fixture and dl_rom_2 both have zero downloads; only dl_rom_2 + # has files, so it alone contributes bytes. + assert summary["never_downloaded_count"] == 2 + assert summary["never_downloaded_bytes"] == 4096 + + +def test_get_summary_window_excludes_older_events(rom: Rom, admin_user: User): + _record(rom, admin_user) + + future = datetime.now(timezone.utc) + timedelta(days=1) + summary = db_download_handler.get_summary(since=future) + + assert summary["total_downloads"] == 1 + assert summary["downloads_in_window"] == 0 + + +def test_get_top_roms_ranks_by_count(platform: Platform, admin_user: User): + quiet = _add_rom(platform, 1) + popular = _add_rom(platform, 2) + + _record(quiet, admin_user) + for _ in range(3): + _record(popular, admin_user) + + top = db_download_handler.get_top_roms(limit=10) + + assert [r["rom_id"] for r in top] == [popular.id, quiet.id] + assert top[0]["download_count"] == 3 + assert top[0]["platform_name"] == platform.name + + +def test_get_top_roms_excludes_never_downloaded(platform: Platform, admin_user: User): + _add_rom(platform, 1) + downloaded = _add_rom(platform, 2) + _record(downloaded, admin_user) + + top = db_download_handler.get_top_roms() + + assert [r["rom_id"] for r in top] == [downloaded.id] + + +def test_get_downloads_by_platform_and_source(rom: Rom, admin_user: User): + _record(rom, admin_user, source=DownloadSource.WEBUI, size_bytes=500) + _record(rom, admin_user, source=DownloadSource.WEBUI, size_bytes=500) + _record(rom, admin_user, source=DownloadSource.OAUTH, size_bytes=250) + + by_platform = db_download_handler.get_downloads_by_platform() + assert len(by_platform) == 1 + assert by_platform[0]["download_count"] == 3 + assert by_platform[0]["size_bytes"] == 1250 + + by_source = db_download_handler.get_downloads_by_source() + counts = {row["source"]: row["count"] for row in by_source} + assert counts == {"webui": 2, "oauth": 1} + + +def test_get_timeline_gap_fills_days(rom: Rom, admin_user: User): + _record(rom, admin_user) + + timeline = db_download_handler.get_timeline(days=7) + + assert len(timeline) == 7 + assert timeline[-1]["count"] == 1 + assert all(point["count"] == 0 for point in timeline[:-1]) + + +def test_prune_events_older_than_deletes_only_stale_rows(rom: Rom, admin_user: User): + old = _record(rom, admin_user) + recent = _record(rom, admin_user) + + # Backdate one event past the retention window. + db_download_handler.prune_events_older_than( + datetime.now(timezone.utc) - timedelta(days=3650) + ) + assert db_download_handler.get_download_log()["total"] == 2 + + from handler.database.base_handler import sync_session + from models.download_event import DownloadEvent + + with sync_session.begin() as s: + s.query(DownloadEvent).filter(DownloadEvent.id == old.id).update( + {"downloaded_at": datetime.now(timezone.utc) - timedelta(days=90)} + ) + + deleted = db_download_handler.prune_events_older_than( + datetime.now(timezone.utc) - timedelta(days=30) + ) + + assert deleted == 1 + page = db_download_handler.get_download_log() + assert page["total"] == 1 + assert page["items"][0]["id"] == recent.id + + +def test_prune_events_leaves_rom_counters_alone(rom: Rom, admin_user: User): + # The counter is a lifetime total; trimming the audit log must not rewrite + # what the game page shows. + _record(rom, admin_user) + _record(rom, admin_user) + + db_download_handler.prune_events_older_than(datetime.now(timezone.utc)) + + assert db_download_handler.get_download_log()["total"] == 0 + refreshed = db_rom_handler.get_rom(rom.id) + assert refreshed is not None + assert refreshed.download_count == 2 + + +def test_resync_rom_counters_rebuilds_from_log(rom: Rom, admin_user: User): + _record(rom, admin_user) + _record(rom, admin_user) + + # Simulate drift (restored backup, manual edit). + db_rom_handler.update_rom(rom.id, {"download_count": 99}) + + assert db_download_handler.resync_rom_counters() == 1 + + refreshed = db_rom_handler.get_rom(rom.id) + assert refreshed is not None + assert refreshed.download_count == 2 diff --git a/backend/tests/test_utils_downloads.py b/backend/tests/test_utils_downloads.py new file mode 100644 index 0000000000..a5998863d2 --- /dev/null +++ b/backend/tests/test_utils_downloads.py @@ -0,0 +1,35 @@ +from types import SimpleNamespace + +import pytest + +from handler.auth.constants import AuthMethod +from models.download_event import DownloadSource +from utils.downloads import resolve_download_source + + +def _request(auth_method=None): + state = SimpleNamespace() + if auth_method is not None: + state.auth_method = auth_method + return SimpleNamespace(state=state) + + +@pytest.mark.parametrize( + ("auth_method", "expected"), + [ + (AuthMethod.SESSION, DownloadSource.WEBUI), + # Kiosk mode is still a browser sitting in front of someone. + (AuthMethod.KIOSK, DownloadSource.WEBUI), + (AuthMethod.BASIC, DownloadSource.BASIC_AUTH), + (AuthMethod.CLIENT_TOKEN, DownloadSource.CLIENT_TOKEN), + (AuthMethod.OAUTH, DownloadSource.OAUTH), + ], +) +def test_resolve_download_source_maps_auth_method(auth_method, expected): + assert resolve_download_source(_request(auth_method)) == expected + + +def test_resolve_download_source_defaults_to_anonymous(): + # No auth_method on the request state, e.g. DISABLE_DOWNLOAD_ENDPOINT_AUTH + # serving an unauthenticated download. + assert resolve_download_source(_request()) == DownloadSource.ANONYMOUS diff --git a/backend/utils/downloads.py b/backend/utils/downloads.py new file mode 100644 index 0000000000..8b688d244b --- /dev/null +++ b/backend/utils/downloads.py @@ -0,0 +1,74 @@ +"""Helpers for logging served downloads. + +Recording is best-effort by design: a stats write must never be the reason a +user's download fails, so every entry point here swallows its own errors. +""" + +from __future__ import annotations + +from fastapi import Request + +from handler.auth.constants import AuthMethod +from handler.database import db_download_handler +from logger.logger import log +from models.download_event import ( + ANONYMOUS_USERNAME, + CLIENT_IP_MAX_LENGTH, + USER_AGENT_MAX_LENGTH, + DownloadKind, + DownloadSource, +) +from models.rom import Rom, RomFile + +_SOURCE_BY_AUTH_METHOD = { + AuthMethod.SESSION: DownloadSource.WEBUI, + AuthMethod.BASIC: DownloadSource.BASIC_AUTH, + AuthMethod.CLIENT_TOKEN: DownloadSource.CLIENT_TOKEN, + AuthMethod.OAUTH: DownloadSource.OAUTH, + AuthMethod.KIOSK: DownloadSource.WEBUI, +} + + +def resolve_download_source(request: Request) -> DownloadSource: + """Map how the request authenticated onto a download source.""" + auth_method = getattr(request.state, "auth_method", None) + if auth_method is None: + return DownloadSource.ANONYMOUS + return _SOURCE_BY_AUTH_METHOD.get(auth_method, DownloadSource.ANONYMOUS) + + +def _client_ip(request: Request) -> str | None: + ip = request.client.host if request.client else None + return ip[:CLIENT_IP_MAX_LENGTH] if ip else None + + +def _user_agent(request: Request) -> str | None: + ua = request.headers.get("user-agent") + return ua[:USER_AGENT_MAX_LENGTH] if ua else None + + +def record_rom_download( + request: Request, + rom: Rom, + files: list[RomFile], + kind: DownloadKind = DownloadKind.ROM, +) -> None: + """Log a served download against `rom`.""" + try: + user = request.user if request.user.is_authenticated else None + # The kiosk-mode user is synthetic (id -1) and has no users row, so it + # gets logged by name only, a real FK would fail the insert. + user_id = user.id if user and user.id > 0 else None + db_download_handler.record_download( + rom=rom, + user_id=user_id, + username=user.username if user else ANONYMOUS_USERNAME, + source=resolve_download_source(request), + kind=kind, + file_count=len(files), + size_bytes=sum(f.file_size_bytes or 0 for f in files), + client_ip=_client_ip(request), + user_agent=_user_agent(request), + ) + except Exception as exc: # noqa: BLE001 - stats must never break a download + log.error(f"Failed to record download for rom {rom.id}: {exc}") diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 12762d7482..614bd90283 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -70,6 +70,13 @@ export type { DeviceHeartbeatPayload } from './models/DeviceHeartbeatPayload'; export type { DeviceSchema } from './models/DeviceSchema'; export type { DeviceSyncSchema } from './models/DeviceSyncSchema'; export type { DeviceUpdatePayload } from './models/DeviceUpdatePayload'; +export type { DownloadLogEntry } from './models/DownloadLogEntry'; +export type { DownloadLogPage } from './models/DownloadLogPage'; +export { DownloadSource } from './models/DownloadSource'; +export type { DownloadSourceStat } from './models/DownloadSourceStat'; +export type { DownloadStatsOverview } from './models/DownloadStatsOverview'; +export type { DownloadStatsSummary } from './models/DownloadStatsSummary'; +export type { DownloadTimelinePoint } from './models/DownloadTimelinePoint'; export type { EarnedAchievement } from './models/EarnedAchievement'; export type { EjsControls } from './models/EjsControls'; export type { EjsControlsButton } from './models/EjsControlsButton'; @@ -125,6 +132,7 @@ export type { PermissionGroupUpdate } from './models/PermissionGroupUpdate'; export type { PermissionScopeSchema } from './models/PermissionScopeSchema'; export type { PermissionsResponse } from './models/PermissionsResponse'; export type { PlatformBindingPayload } from './models/PlatformBindingPayload'; +export type { PlatformDownloadStat } from './models/PlatformDownloadStat'; export type { PlatformSchema } from './models/PlatformSchema'; export type { PlaySessionEntry } from './models/PlaySessionEntry'; export type { PlaySessionIngestPayload } from './models/PlaySessionIngestPayload'; @@ -192,6 +200,7 @@ export type { TaskType } from './models/TaskType'; export type { TinfoilFeedFileSchema } from './models/TinfoilFeedFileSchema'; export type { TinfoilFeedSchema } from './models/TinfoilFeedSchema'; export type { TokenResponse } from './models/TokenResponse'; +export type { TopDownloadedRom } from './models/TopDownloadedRom'; export type { TrackMetaSchema } from './models/TrackMetaSchema'; export type { UpdateStats } from './models/UpdateStats'; export type { UpdateTaskMeta } from './models/UpdateTaskMeta'; diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index b2c99c0bd4..1904c540b9 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -92,6 +92,8 @@ export type DetailedRomSchema = { updated_at: string; missing_from_fs: boolean; has_notes: boolean; + download_count: number; + last_downloaded_at: (string | null); rom_user: RomUserSchema; merged_screenshots: Array; merged_ra_metadata: (RomRAMetadata | null); diff --git a/frontend/src/__generated__/models/DownloadLogEntry.ts b/frontend/src/__generated__/models/DownloadLogEntry.ts new file mode 100644 index 0000000000..de16fa2210 --- /dev/null +++ b/frontend/src/__generated__/models/DownloadLogEntry.ts @@ -0,0 +1,21 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DownloadLogEntry = { + id: number; + user_id: (number | null); + username: string; + rom_id: (number | null); + rom_name: string; + platform_id: (number | null); + platform_name: string; + source: string; + kind: string; + file_count: number; + size_bytes: number; + client_ip: (string | null); + user_agent: (string | null); + downloaded_at: string; +}; + diff --git a/frontend/src/__generated__/models/DownloadLogPage.ts b/frontend/src/__generated__/models/DownloadLogPage.ts new file mode 100644 index 0000000000..489ebfb8ce --- /dev/null +++ b/frontend/src/__generated__/models/DownloadLogPage.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { DownloadLogEntry } from './DownloadLogEntry'; +export type DownloadLogPage = { + items: Array; + total: number; + limit: number; + offset: number; +}; + diff --git a/frontend/src/__generated__/models/DownloadSource.ts b/frontend/src/__generated__/models/DownloadSource.ts new file mode 100644 index 0000000000..f44a955f69 --- /dev/null +++ b/frontend/src/__generated__/models/DownloadSource.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export enum DownloadSource { + WEBUI = 'webui', + BASIC_AUTH = 'basic_auth', + CLIENT_TOKEN = 'client_token', + OAUTH = 'oauth', + ANONYMOUS = 'anonymous', +} + diff --git a/frontend/src/__generated__/models/DownloadSourceStat.ts b/frontend/src/__generated__/models/DownloadSourceStat.ts new file mode 100644 index 0000000000..1af54cca97 --- /dev/null +++ b/frontend/src/__generated__/models/DownloadSourceStat.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DownloadSourceStat = { + source: string; + count: number; +}; + diff --git a/frontend/src/__generated__/models/DownloadStatsOverview.ts b/frontend/src/__generated__/models/DownloadStatsOverview.ts new file mode 100644 index 0000000000..43e5591b27 --- /dev/null +++ b/frontend/src/__generated__/models/DownloadStatsOverview.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { DownloadSourceStat } from './DownloadSourceStat'; +import type { DownloadStatsSummary } from './DownloadStatsSummary'; +import type { DownloadTimelinePoint } from './DownloadTimelinePoint'; +import type { PlatformDownloadStat } from './PlatformDownloadStat'; +import type { TopDownloadedRom } from './TopDownloadedRom'; +export type DownloadStatsOverview = { + summary: DownloadStatsSummary; + top_roms: Array; + by_platform: Array; + by_source: Array; + timeline: Array; +}; + diff --git a/frontend/src/__generated__/models/DownloadStatsSummary.ts b/frontend/src/__generated__/models/DownloadStatsSummary.ts new file mode 100644 index 0000000000..543c097039 --- /dev/null +++ b/frontend/src/__generated__/models/DownloadStatsSummary.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DownloadStatsSummary = { + total_downloads: number; + total_bytes: number; + downloads_in_window: number; + bytes_in_window: number; + unique_roms_downloaded: number; + unique_users: number; + roms_total: number; + never_downloaded_count: number; + never_downloaded_bytes: number; +}; + diff --git a/frontend/src/__generated__/models/DownloadTimelinePoint.ts b/frontend/src/__generated__/models/DownloadTimelinePoint.ts new file mode 100644 index 0000000000..7eae39d221 --- /dev/null +++ b/frontend/src/__generated__/models/DownloadTimelinePoint.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DownloadTimelinePoint = { + date: string; + count: number; + size_bytes: number; +}; + diff --git a/frontend/src/__generated__/models/PlatformDownloadStat.ts b/frontend/src/__generated__/models/PlatformDownloadStat.ts new file mode 100644 index 0000000000..915d4f3668 --- /dev/null +++ b/frontend/src/__generated__/models/PlatformDownloadStat.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type PlatformDownloadStat = { + platform_id: number; + platform_name: string; + platform_slug: string; + download_count: number; + size_bytes: number; +}; + diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index ed03254bac..da06a33f0c 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -84,6 +84,8 @@ export type SimpleRomSchema = { updated_at: string; missing_from_fs: boolean; has_notes: boolean; + download_count: number; + last_downloaded_at: (string | null); rom_user: RomUserSchema; merged_screenshots: Array; merged_ra_metadata: (RomRAMetadata | null); diff --git a/frontend/src/__generated__/models/TopDownloadedRom.ts b/frontend/src/__generated__/models/TopDownloadedRom.ts new file mode 100644 index 0000000000..3470404ff2 --- /dev/null +++ b/frontend/src/__generated__/models/TopDownloadedRom.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type TopDownloadedRom = { + rom_id: number; + rom_name: string; + platform_id: number; + platform_name: string; + platform_slug: string; + path_cover_small: (string | null); + download_count: number; + last_downloaded_at: (string | null); + file_size_bytes: number; +}; + diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json index 5057b175cc..a48f92fa73 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Заглуши", "volume-unmute": "Включи звука", "your-progress": "Your progress", - "youtube-video-id": "YouTube видео ID" + "youtube-video-id": "YouTube видео ID", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/bg_BG/settings.json b/frontend/src/locales/bg_BG/settings.json index b7fd0bfd74..229a1e1589 100644 --- a/frontend/src/locales/bg_BG/settings.json +++ b/frontend/src/locales/bg_BG/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Подробности за статистиката на библиотеката", "widget-random-pick": "Случаен избор", "widget-random-pick-desc": "Предлага случайна ROM от библиотеката ви, с бутон за нов избор.", - "widget-reorder-drag": "Плъзнете за пренареждане" + "widget-reorder-drag": "Плъзнете за пренареждане", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json index ee8df9a1f1..238051d983 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Ztlumit", "volume-unmute": "Zrušit ztlumení", "your-progress": "Your progress", - "youtube-video-id": "ID YouTube videa" + "youtube-video-id": "ID YouTube videa", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/cs_CZ/settings.json b/frontend/src/locales/cs_CZ/settings.json index a494853038..3afaade0e2 100644 --- a/frontend/src/locales/cs_CZ/settings.json +++ b/frontend/src/locales/cs_CZ/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Podrobnosti statistik knihovny", "widget-random-pick": "Náhodný výběr", "widget-random-pick-desc": "Navrhne náhodnou ROM z vaší knihovny s tlačítkem pro nový výběr.", - "widget-reorder-drag": "Přetažením změníte pořadí" + "widget-reorder-drag": "Přetažením změníte pořadí", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json index 56cb42f05b..89929f7d4a 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Stummschalten", "volume-unmute": "Stummschaltung aufheben", "your-progress": "Your progress", - "youtube-video-id": "YouTube-Video-ID" + "youtube-video-id": "YouTube-Video-ID", + "downloads": "Downloads", + "last-downloaded": "Zuletzt heruntergeladen" } diff --git a/frontend/src/locales/de_DE/settings.json b/frontend/src/locales/de_DE/settings.json index 21172fa166..94955281ce 100644 --- a/frontend/src/locales/de_DE/settings.json +++ b/frontend/src/locales/de_DE/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Bibliotheksstatistik-Details", "widget-random-pick": "Zufallsauswahl", "widget-random-pick-desc": "Schlägt eine zufällige ROM aus deiner Bibliothek vor, mit einer Schaltfläche zum neuen Würfeln.", - "widget-reorder-drag": "Zum Umsortieren ziehen" + "widget-reorder-drag": "Zum Umsortieren ziehen", + "downloads": "Downloads", + "downloads-intro": "Download-Aktivität auf dem Server, um ungenutzte Inhalte zu finden.", + "downloads-summary": "Download-Übersicht", + "downloads-total": "Downloads gesamt", + "downloads-in-window": "Letzte {days} Tage", + "downloads-unique-games": "Heruntergeladene Spiele", + "downloads-of-library": "{percent} % von {total} Spielen", + "downloads-unique-users": "Herunterladende Benutzer", + "downloads-never-downloaded": "Nie heruntergeladen", + "downloads-reclaimable": "Freigebbarer Speicher", + "downloads-over-time": "Downloads im Zeitverlauf", + "downloads-none-in-window": "In diesem Zeitraum keine Downloads erfasst", + "downloads-count": "{count} Downloads", + "downloads-top": "Meiste Downloads", + "downloads-top-empty": "Es wurde noch nichts heruntergeladen", + "downloads-column-count": "Downloads", + "downloads-column-last": "Letzter Download", + "downloads-column-when": "Zeitpunkt", + "downloads-column-source": "Quelle", + "downloads-column-client": "Client", + "downloads-log": "Download-Protokoll", + "downloads-log-empty": "Keine Downloads entsprechen diesen Filtern", + "downloads-log-error": "Download-Protokoll konnte nicht geladen werden", + "downloads-log-range": "{start}-{end} von {total}", + "downloads-refresh": "Aktualisieren", + "downloads-source-all": "Alle Quellen", + "downloads-source-webui": "Web-Oberfläche", + "downloads-source-client-token": "API-Token", + "downloads-source-oauth": "OAuth-App", + "downloads-source-basic-auth": "Basic-Auth", + "downloads-source-anonymous": "Anonym", + "downloads-window-all": "Gesamter Zeitraum", + "downloads-window-days": "Letzte {days} Tage", + "downloads-filter-period": "Zeitraum", + "downloads-kind-file": "Einzelne Datei", + "downloads-stats-error": "Download-Statistiken konnten nicht geladen werden" } diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json index c1c8bfefab..612ed4ffcd 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Mute", "volume-unmute": "Unmute", "your-progress": "Your progress", - "youtube-video-id": "YouTube video ID" + "youtube-video-id": "YouTube video ID", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/en_GB/settings.json b/frontend/src/locales/en_GB/settings.json index 6ca0347cd4..3d754aab8c 100644 --- a/frontend/src/locales/en_GB/settings.json +++ b/frontend/src/locales/en_GB/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Library stats detail", "widget-random-pick": "Random pick", "widget-random-pick-desc": "Suggest a random ROM from your library, with a reroll button to shuffle again.", - "widget-reorder-drag": "Drag to reorder" + "widget-reorder-drag": "Drag to reorder", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index c09174eec9..000ef36da5 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Mute", "volume-unmute": "Unmute", "your-progress": "Your progress", - "youtube-video-id": "YouTube video ID" + "youtube-video-id": "YouTube video ID", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/en_US/settings.json b/frontend/src/locales/en_US/settings.json index fcf25c4e83..c7e2211e97 100644 --- a/frontend/src/locales/en_US/settings.json +++ b/frontend/src/locales/en_US/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Library stats detail", "widget-random-pick": "Random pick", "widget-random-pick-desc": "Suggest a random ROM from your library, with a reroll button to shuffle again.", - "widget-reorder-drag": "Drag to reorder" + "widget-reorder-drag": "Drag to reorder", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json index 9096c1d67b..51822a6669 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Silenciar", "volume-unmute": "Quitar silencio", "your-progress": "Tu progreso", - "youtube-video-id": "ID de vídeo de YouTube" + "youtube-video-id": "ID de vídeo de YouTube", + "downloads": "Descargas", + "last-downloaded": "Última descarga" } diff --git a/frontend/src/locales/es_ES/settings.json b/frontend/src/locales/es_ES/settings.json index ebb59dd5f0..cae224ff5c 100644 --- a/frontend/src/locales/es_ES/settings.json +++ b/frontend/src/locales/es_ES/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Detalle de estadísticas de la biblioteca", "widget-random-pick": "Selección aleatoria", "widget-random-pick-desc": "Sugiere una ROM aleatoria de tu biblioteca, con un botón para volver a sortear.", - "widget-reorder-drag": "Arrastra para reordenar" + "widget-reorder-drag": "Arrastra para reordenar", + "downloads": "Descargas", + "downloads-intro": "Actividad de descargas del servidor, útil para detectar contenido que nadie usa.", + "downloads-summary": "Resumen de descargas", + "downloads-total": "Descargas totales", + "downloads-in-window": "Últimos {days} días", + "downloads-unique-games": "Juegos descargados", + "downloads-of-library": "{percent} % de {total} juegos", + "downloads-unique-users": "Usuarios que descargan", + "downloads-never-downloaded": "Nunca descargados", + "downloads-reclaimable": "Espacio recuperable", + "downloads-over-time": "Descargas a lo largo del tiempo", + "downloads-none-in-window": "No se registraron descargas en este periodo", + "downloads-count": "{count} descargas", + "downloads-top": "Más descargados", + "downloads-top-empty": "Todavía no se ha descargado nada", + "downloads-column-count": "Descargas", + "downloads-column-last": "Última descarga", + "downloads-column-when": "Cuándo", + "downloads-column-source": "Origen", + "downloads-column-client": "Cliente", + "downloads-log": "Registro de descargas", + "downloads-log-empty": "Ninguna descarga coincide con estos filtros", + "downloads-log-error": "No se pudo cargar el registro de descargas", + "downloads-log-range": "{start}-{end} de {total}", + "downloads-refresh": "Actualizar", + "downloads-source-all": "Todos los orígenes", + "downloads-source-webui": "Interfaz web", + "downloads-source-client-token": "Token de API", + "downloads-source-oauth": "Aplicación OAuth", + "downloads-source-basic-auth": "Autenticación básica", + "downloads-source-anonymous": "Anónimo", + "downloads-window-all": "Todo el tiempo", + "downloads-window-days": "Últimos {days} días", + "downloads-filter-period": "Periodo", + "downloads-kind-file": "Archivo individual", + "downloads-stats-error": "No se pudieron cargar las estadísticas de descargas" } diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json index 38120a8308..10b622e58a 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Couper le son", "volume-unmute": "Activer le son", "your-progress": "Your progress", - "youtube-video-id": "ID vidéo YouTube" + "youtube-video-id": "ID vidéo YouTube", + "downloads": "Téléchargements", + "last-downloaded": "Dernier téléchargement" } diff --git a/frontend/src/locales/fr_FR/settings.json b/frontend/src/locales/fr_FR/settings.json index edf6b2c7cb..1118e46d32 100644 --- a/frontend/src/locales/fr_FR/settings.json +++ b/frontend/src/locales/fr_FR/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Détail des statistiques de la bibliothèque", "widget-random-pick": "Choix aléatoire", "widget-random-pick-desc": "Suggère une ROM aléatoire de votre bibliothèque, avec un bouton pour relancer.", - "widget-reorder-drag": "Glissez pour réorganiser" + "widget-reorder-drag": "Glissez pour réorganiser", + "downloads": "Téléchargements", + "downloads-intro": "Activité de téléchargement du serveur, pour repérer le contenu que personne n'utilise.", + "downloads-summary": "Résumé des téléchargements", + "downloads-total": "Téléchargements totaux", + "downloads-in-window": "{days} derniers jours", + "downloads-unique-games": "Jeux téléchargés", + "downloads-of-library": "{percent} % de {total} jeux", + "downloads-unique-users": "Utilisateurs qui téléchargent", + "downloads-never-downloaded": "Jamais téléchargés", + "downloads-reclaimable": "Espace récupérable", + "downloads-over-time": "Téléchargements dans le temps", + "downloads-none-in-window": "Aucun téléchargement enregistré sur cette période", + "downloads-count": "{count} téléchargements", + "downloads-top": "Les plus téléchargés", + "downloads-top-empty": "Rien n'a encore été téléchargé", + "downloads-column-count": "Téléchargements", + "downloads-column-last": "Dernier téléchargement", + "downloads-column-when": "Quand", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Journal des téléchargements", + "downloads-log-empty": "Aucun téléchargement ne correspond à ces filtres", + "downloads-log-error": "Impossible de charger le journal des téléchargements", + "downloads-log-range": "{start}-{end} sur {total}", + "downloads-refresh": "Actualiser", + "downloads-source-all": "Toutes les sources", + "downloads-source-webui": "Interface web", + "downloads-source-client-token": "Jeton d'API", + "downloads-source-oauth": "Application OAuth", + "downloads-source-basic-auth": "Authentification basique", + "downloads-source-anonymous": "Anonyme", + "downloads-window-all": "Depuis toujours", + "downloads-window-days": "{days} derniers jours", + "downloads-filter-period": "Période", + "downloads-kind-file": "Fichier unique", + "downloads-stats-error": "Impossible de charger les statistiques de téléchargement" } diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json index 15c874da14..f6346cca40 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Némítás", "volume-unmute": "Némítás feloldása", "your-progress": "Your progress", - "youtube-video-id": "YouTube videó azonosító" + "youtube-video-id": "YouTube videó azonosító", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/hu_HU/settings.json b/frontend/src/locales/hu_HU/settings.json index de79852501..49f3a45536 100644 --- a/frontend/src/locales/hu_HU/settings.json +++ b/frontend/src/locales/hu_HU/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Könyvtárstatisztika részletei", "widget-random-pick": "Véletlen választás", "widget-random-pick-desc": "Véletlenszerű ROM-ot ajánl a könyvtáradból, újradobás gombbal.", - "widget-reorder-drag": "Húzd az átrendezéshez" + "widget-reorder-drag": "Húzd az átrendezéshez", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json index f1d8073a3e..f40171bae2 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Disattiva audio", "volume-unmute": "Riattiva audio", "your-progress": "Your progress", - "youtube-video-id": "ID video YouTube" + "youtube-video-id": "ID video YouTube", + "downloads": "Download", + "last-downloaded": "Ultimo download" } diff --git a/frontend/src/locales/it_IT/settings.json b/frontend/src/locales/it_IT/settings.json index ca2941b552..c6d2120213 100644 --- a/frontend/src/locales/it_IT/settings.json +++ b/frontend/src/locales/it_IT/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Dettaglio statistiche della libreria", "widget-random-pick": "Scelta casuale", "widget-random-pick-desc": "Suggerisce una ROM casuale dalla tua libreria, con un pulsante per rilanciare.", - "widget-reorder-drag": "Trascina per riordinare" + "widget-reorder-drag": "Trascina per riordinare", + "downloads": "Download", + "downloads-intro": "Attività di download del server, utile per individuare contenuti che nessuno usa.", + "downloads-summary": "Riepilogo download", + "downloads-total": "Download totali", + "downloads-in-window": "Ultimi {days} giorni", + "downloads-unique-games": "Giochi scaricati", + "downloads-of-library": "{percent} % di {total} giochi", + "downloads-unique-users": "Utenti che scaricano", + "downloads-never-downloaded": "Mai scaricati", + "downloads-reclaimable": "Spazio recuperabile", + "downloads-over-time": "Download nel tempo", + "downloads-none-in-window": "Nessun download registrato in questo periodo", + "downloads-count": "{count} download", + "downloads-top": "Più scaricati", + "downloads-top-empty": "Non è ancora stato scaricato nulla", + "downloads-column-count": "Download", + "downloads-column-last": "Ultimo download", + "downloads-column-when": "Quando", + "downloads-column-source": "Origine", + "downloads-column-client": "Client", + "downloads-log": "Registro download", + "downloads-log-empty": "Nessun download corrisponde a questi filtri", + "downloads-log-error": "Impossibile caricare il registro dei download", + "downloads-log-range": "{start}-{end} di {total}", + "downloads-refresh": "Aggiorna", + "downloads-source-all": "Tutte le origini", + "downloads-source-webui": "Interfaccia web", + "downloads-source-client-token": "Token API", + "downloads-source-oauth": "App OAuth", + "downloads-source-basic-auth": "Autenticazione di base", + "downloads-source-anonymous": "Anonimo", + "downloads-window-all": "Sempre", + "downloads-window-days": "Ultimi {days} giorni", + "downloads-filter-period": "Periodo", + "downloads-kind-file": "File singolo", + "downloads-stats-error": "Impossibile caricare le statistiche di download" } diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json index 251caada8f..7eb28426d8 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -455,5 +455,7 @@ "volume-mute": "ミュート", "volume-unmute": "ミュート解除", "your-progress": "Your progress", - "youtube-video-id": "YouTube動画ID" + "youtube-video-id": "YouTube動画ID", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/ja_JP/settings.json b/frontend/src/locales/ja_JP/settings.json index 289588da9d..9089cd93f9 100644 --- a/frontend/src/locales/ja_JP/settings.json +++ b/frontend/src/locales/ja_JP/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "ライブラリ統計の詳細", "widget-random-pick": "ランダムピック", "widget-random-pick-desc": "ライブラリからランダムな ROM を提案します。引き直しボタンで再抽選できます。", - "widget-reorder-drag": "ドラッグして並べ替え" + "widget-reorder-drag": "ドラッグして並べ替え", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json index 0266001293..8830d4a77c 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -455,5 +455,7 @@ "volume-mute": "음소거", "volume-unmute": "음소거 해제", "your-progress": "Your progress", - "youtube-video-id": "YouTube 동영상 ID" + "youtube-video-id": "YouTube 동영상 ID", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/ko_KR/settings.json b/frontend/src/locales/ko_KR/settings.json index e956611f80..51523bc5d6 100644 --- a/frontend/src/locales/ko_KR/settings.json +++ b/frontend/src/locales/ko_KR/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "라이브러리 통계 세부 정보", "widget-random-pick": "랜덤 선택", "widget-random-pick-desc": "라이브러리에서 무작위 ROM을 추천하며, 다시 뽑기 버튼으로 다시 섞을 수 있습니다.", - "widget-reorder-drag": "드래그하여 순서 변경" + "widget-reorder-drag": "드래그하여 순서 변경", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json index 6535b60924..9966980aa2 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Wycisz", "volume-unmute": "Wyłącz wyciszenie", "your-progress": "Your progress", - "youtube-video-id": "ID filmu YouTube" + "youtube-video-id": "ID filmu YouTube", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/pl_PL/settings.json b/frontend/src/locales/pl_PL/settings.json index ae4f42803d..890e0a15ea 100644 --- a/frontend/src/locales/pl_PL/settings.json +++ b/frontend/src/locales/pl_PL/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Szczegóły statystyk biblioteki", "widget-random-pick": "Losowy wybór", "widget-random-pick-desc": "Proponuje losową ROM z Twojej biblioteki, z przyciskiem ponownego losowania.", - "widget-reorder-drag": "Przeciągnij, aby zmienić kolejność" + "widget-reorder-drag": "Przeciągnij, aby zmienić kolejność", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json index 331e3795ec..2a255ea9ab 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Silenciar", "volume-unmute": "Reativar som", "your-progress": "Your progress", - "youtube-video-id": "ID do vídeo do YouTube" + "youtube-video-id": "ID do vídeo do YouTube", + "downloads": "Downloads", + "last-downloaded": "Último download" } diff --git a/frontend/src/locales/pt_BR/settings.json b/frontend/src/locales/pt_BR/settings.json index ce7cc132a8..3466dbe341 100644 --- a/frontend/src/locales/pt_BR/settings.json +++ b/frontend/src/locales/pt_BR/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Detalhes das estatísticas da biblioteca", "widget-random-pick": "Escolha aleatória", "widget-random-pick-desc": "Sugere uma ROM aleatória da sua biblioteca, com um botão para sortear novamente.", - "widget-reorder-drag": "Arraste para reordenar" + "widget-reorder-drag": "Arraste para reordenar", + "downloads": "Downloads", + "downloads-intro": "Atividade de download do servidor, para identificar conteúdo que ninguém usa.", + "downloads-summary": "Resumo de downloads", + "downloads-total": "Downloads totais", + "downloads-in-window": "Últimos {days} dias", + "downloads-unique-games": "Jogos baixados", + "downloads-of-library": "{percent} % de {total} jogos", + "downloads-unique-users": "Usuários baixando", + "downloads-never-downloaded": "Nunca baixados", + "downloads-reclaimable": "Espaço recuperável", + "downloads-over-time": "Downloads ao longo do tempo", + "downloads-none-in-window": "Nenhum download registrado neste período", + "downloads-count": "{count} downloads", + "downloads-top": "Mais baixados", + "downloads-top-empty": "Nada foi baixado ainda", + "downloads-column-count": "Downloads", + "downloads-column-last": "Último download", + "downloads-column-when": "Quando", + "downloads-column-source": "Origem", + "downloads-column-client": "Cliente", + "downloads-log": "Registro de downloads", + "downloads-log-empty": "Nenhum download corresponde a esses filtros", + "downloads-log-error": "Não foi possível carregar o registro de downloads", + "downloads-log-range": "{start}-{end} de {total}", + "downloads-refresh": "Atualizar", + "downloads-source-all": "Todas as origens", + "downloads-source-webui": "Interface web", + "downloads-source-client-token": "Token de API", + "downloads-source-oauth": "Aplicativo OAuth", + "downloads-source-basic-auth": "Autenticação básica", + "downloads-source-anonymous": "Anônimo", + "downloads-window-all": "Todo o período", + "downloads-window-days": "Últimos {days} dias", + "downloads-filter-period": "Período", + "downloads-kind-file": "Arquivo único", + "downloads-stats-error": "Não foi possível carregar as estatísticas de download" } diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json index e533ad11cd..e662e36930 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Mut", "volume-unmute": "Activează sunetul", "your-progress": "Your progress", - "youtube-video-id": "ID video YouTube" + "youtube-video-id": "ID video YouTube", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/ro_RO/settings.json b/frontend/src/locales/ro_RO/settings.json index ccf6a02c6f..586b6617b3 100644 --- a/frontend/src/locales/ro_RO/settings.json +++ b/frontend/src/locales/ro_RO/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Detalii statistici bibliotecă", "widget-random-pick": "Alegere aleatorie", "widget-random-pick-desc": "Sugerează o ROM aleatorie din biblioteca ta, cu un buton pentru a alege din nou.", - "widget-reorder-drag": "Trage pentru a reordona" + "widget-reorder-drag": "Trage pentru a reordona", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json index e9dcba8d12..7d2869729f 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Отключить звук", "volume-unmute": "Включить звук", "your-progress": "Your progress", - "youtube-video-id": "ID видео YouTube" + "youtube-video-id": "ID видео YouTube", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/ru_RU/settings.json b/frontend/src/locales/ru_RU/settings.json index 36e141e26a..a68d56a3b9 100644 --- a/frontend/src/locales/ru_RU/settings.json +++ b/frontend/src/locales/ru_RU/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Подробности статистики библиотеки", "widget-random-pick": "Случайный выбор", "widget-random-pick-desc": "Предлагает случайную ROM из вашей библиотеки с кнопкой повторного выбора.", - "widget-reorder-drag": "Перетащите для изменения порядка" + "widget-reorder-drag": "Перетащите для изменения порядка", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/tr_TR/rom.json b/frontend/src/locales/tr_TR/rom.json index a17fc00ad5..789c63dd59 100644 --- a/frontend/src/locales/tr_TR/rom.json +++ b/frontend/src/locales/tr_TR/rom.json @@ -455,5 +455,7 @@ "volume-mute": "Sesi kapat", "volume-unmute": "Sesi aç", "your-progress": "Your progress", - "youtube-video-id": "YouTube video ID" + "youtube-video-id": "YouTube video ID", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/tr_TR/settings.json b/frontend/src/locales/tr_TR/settings.json index 2b25b25826..7e54653f99 100644 --- a/frontend/src/locales/tr_TR/settings.json +++ b/frontend/src/locales/tr_TR/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "Kütüphane istatistikleri detayı", "widget-random-pick": "Rastgele seçim", "widget-random-pick-desc": "Kütüphanenizden rastgele bir ROM öner, yeniden çevirmek için düğmeye basın.", - "widget-reorder-drag": "Yeniden sıralamak için sürükleyin" + "widget-reorder-drag": "Yeniden sıralamak için sürükleyin", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json index 631b4e689d..c013e31751 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -455,5 +455,7 @@ "volume-mute": "静音", "volume-unmute": "取消静音", "your-progress": "Your progress", - "youtube-video-id": "YouTube 视频 ID" + "youtube-video-id": "YouTube 视频 ID", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/zh_CN/settings.json b/frontend/src/locales/zh_CN/settings.json index fe064c806b..9c80a1b7ed 100644 --- a/frontend/src/locales/zh_CN/settings.json +++ b/frontend/src/locales/zh_CN/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "库统计详情", "widget-random-pick": "随机推荐", "widget-random-pick-desc": "从你的库中随机推荐一个 ROM,并提供重新抽取按钮。", - "widget-reorder-drag": "拖动以重新排序" + "widget-reorder-drag": "拖动以重新排序", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json index 9118b5a73a..104500f912 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -455,5 +455,7 @@ "volume-mute": "靜音", "volume-unmute": "取消靜音", "your-progress": "Your progress", - "youtube-video-id": "YouTube 影片 ID" + "youtube-video-id": "YouTube 影片 ID", + "downloads": "Downloads", + "last-downloaded": "Last downloaded" } diff --git a/frontend/src/locales/zh_TW/settings.json b/frontend/src/locales/zh_TW/settings.json index 028f453775..ef2f40ebe1 100644 --- a/frontend/src/locales/zh_TW/settings.json +++ b/frontend/src/locales/zh_TW/settings.json @@ -498,5 +498,41 @@ "widget-library-stats-mode": "庫統計詳情", "widget-random-pick": "隨機推薦", "widget-random-pick-desc": "從你的庫中隨機推薦一個 ROM,並提供重新抽取按鈕。", - "widget-reorder-drag": "拖曳以重新排序" + "widget-reorder-drag": "拖曳以重新排序", + "downloads": "Downloads", + "downloads-intro": "Download activity across the server, used to spot content nobody is using.", + "downloads-summary": "Download summary", + "downloads-total": "Total downloads", + "downloads-in-window": "Last {days} days", + "downloads-unique-games": "Games downloaded", + "downloads-of-library": "{percent}% of {total} games", + "downloads-unique-users": "Users downloading", + "downloads-never-downloaded": "Never downloaded", + "downloads-reclaimable": "Reclaimable space", + "downloads-over-time": "Downloads over time", + "downloads-none-in-window": "No downloads recorded in this period", + "downloads-count": "{count} downloads", + "downloads-top": "Most downloaded", + "downloads-top-empty": "Nothing has been downloaded yet", + "downloads-column-count": "Downloads", + "downloads-column-last": "Last download", + "downloads-column-when": "When", + "downloads-column-source": "Source", + "downloads-column-client": "Client", + "downloads-log": "Download log", + "downloads-log-empty": "No downloads match these filters", + "downloads-log-error": "Unable to load the download log", + "downloads-log-range": "{start}-{end} of {total}", + "downloads-refresh": "Refresh", + "downloads-source-all": "All sources", + "downloads-source-webui": "Web UI", + "downloads-source-client-token": "API token", + "downloads-source-oauth": "OAuth app", + "downloads-source-basic-auth": "Basic auth", + "downloads-source-anonymous": "Anonymous", + "downloads-window-all": "All time", + "downloads-window-days": "Last {days} days", + "downloads-filter-period": "Period", + "downloads-kind-file": "Single file", + "downloads-stats-error": "Unable to load download statistics" } diff --git a/frontend/src/services/api/download.ts b/frontend/src/services/api/download.ts new file mode 100644 index 0000000000..e73b051d7e --- /dev/null +++ b/frontend/src/services/api/download.ts @@ -0,0 +1,57 @@ +import type { + DownloadLogPage, + DownloadSource, + DownloadStatsOverview, +} from "@/__generated__"; +import api from "@/services/api"; + +export interface DownloadLogQuery { + limit?: number; + offset?: number; + romId?: number; + userId?: number; + platformId?: number; + source?: DownloadSource; + days?: number; +} + +async function fetchOverview({ + days, + topLimit, +}: { days?: number; topLimit?: number } = {}) { + return api.get("/stats/downloads", { + params: { days, top_limit: topLimit }, + }); +} + +async function fetchLog({ + limit, + offset, + romId, + userId, + platformId, + source, + days, +}: DownloadLogQuery = {}) { + return api.get("/stats/downloads/log", { + params: { + limit, + offset, + rom_id: romId, + user_id: userId, + platform_id: platformId, + source, + days, + }, + }); +} + +async function resyncCounters() { + return api.post<{ roms_with_downloads: number }>("/stats/downloads/resync"); +} + +export default { + fetchOverview, + fetchLog, + resyncCounters, +}; diff --git a/frontend/src/v2/components/GameDetails/OverviewTab.vue b/frontend/src/v2/components/GameDetails/OverviewTab.vue index 97eb30aae5..b409813c37 100644 --- a/frontend/src/v2/components/GameDetails/OverviewTab.vue +++ b/frontend/src/v2/components/GameDetails/OverviewTab.vue @@ -28,6 +28,7 @@ import type { } from "@/__generated__"; import storeCollections from "@/stores/collections"; import type { DetailedRom } from "@/stores/roms"; +import { formatTimestamp } from "@/utils"; import CollectionTile, { type Kind, } from "@/v2/components/Collections/CollectionTile.vue"; @@ -86,7 +87,17 @@ const hasHltb = computed(() => { // route, and carry the "smart" kind so the tile shows its flash badge. // Falls back to a bare entry if the store is empty (e.g. deep-link before // the AppLayout fetch resolves). -const { t } = useI18n(); +const { t, locale } = useI18n(); + +// Lifetime download total, always shown (including zero) so the figure sits +// where you'd look for it rather than appearing only once someone downloads. +// Aggregate only: who downloaded it stays in the admin-only log. +const downloadCount = computed(() => props.rom.download_count ?? 0); +const lastDownloaded = computed(() => + props.rom.last_downloaded_at + ? formatTimestamp(props.rom.last_downloaded_at, locale.value) + : null, +); const collectionsStore = storeCollections(); const { toWebp } = useWebpSupport(); @@ -187,11 +198,21 @@ const coverSource = computed(() => { collections — get a row each so each fact can render its own semantic widget instead of being flattened to a chip list. -->
+
+
{{ t("rom.downloads") }}
+
+ + + {{ downloadCount.toLocaleString() }} + + + {{ t("rom.last-downloaded") }}: {{ lastDownloaded }} + +
+
+
{{ t("rom.revision") }}
{{ revision }}
@@ -381,6 +402,19 @@ const coverSource = computed(() => { (Last played, Players badge, Age rating badges, RomM collections) stay aligned in a column without forcing every row through the InfoGrid chip styling. */ +.overview-tab__downloads { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: var(--r-font-weight-bold); + color: var(--r-color-brand-primary); + font-variant-numeric: tabular-nums; +} +.overview-tab__downloads-meta { + font-size: 12px; + color: var(--r-color-fg-muted); +} + .overview-tab__facts { display: flex; flex-direction: column; diff --git a/frontend/src/v2/components/GameDetails/RelatedGameCard.vue b/frontend/src/v2/components/GameDetails/RelatedGameCard.vue index ef4b99fdd6..e0d46c0793 100644 --- a/frontend/src/v2/components/GameDetails/RelatedGameCard.vue +++ b/frontend/src/v2/components/GameDetails/RelatedGameCard.vue @@ -150,6 +150,8 @@ const syntheticRom = computed(() => ({ updated_at: "", missing_from_fs: false, has_notes: false, + download_count: 0, + last_downloaded_at: null, files: [], sibling_roms: [], rom_user: EMPTY_USER, diff --git a/frontend/src/v2/components/Settings/DownloadLogSection.vue b/frontend/src/v2/components/Settings/DownloadLogSection.vue new file mode 100644 index 0000000000..5e3977e05c --- /dev/null +++ b/frontend/src/v2/components/Settings/DownloadLogSection.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/frontend/src/v2/components/Settings/DownloadStatsSection.vue b/frontend/src/v2/components/Settings/DownloadStatsSection.vue new file mode 100644 index 0000000000..4e59802818 --- /dev/null +++ b/frontend/src/v2/components/Settings/DownloadStatsSection.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/frontend/src/v2/components/Settings/DownloadSummarySection.vue b/frontend/src/v2/components/Settings/DownloadSummarySection.vue new file mode 100644 index 0000000000..8a46aad5d6 --- /dev/null +++ b/frontend/src/v2/components/Settings/DownloadSummarySection.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/frontend/src/v2/components/Settings/DownloadTimelineSection.vue b/frontend/src/v2/components/Settings/DownloadTimelineSection.vue new file mode 100644 index 0000000000..63b7a525b0 --- /dev/null +++ b/frontend/src/v2/components/Settings/DownloadTimelineSection.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/frontend/src/v2/components/Settings/TopDownloadsSection.vue b/frontend/src/v2/components/Settings/TopDownloadsSection.vue new file mode 100644 index 0000000000..944d09fdac --- /dev/null +++ b/frontend/src/v2/components/Settings/TopDownloadsSection.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/frontend/src/v2/views/Settings/Administration.vue b/frontend/src/v2/views/Settings/Administration.vue index 337796cbce..fd87106cfe 100644 --- a/frontend/src/v2/views/Settings/Administration.vue +++ b/frontend/src/v2/views/Settings/Administration.vue @@ -17,6 +17,7 @@ import CreateUserDialog from "@/v2/components/Settings/CreateUserDialog.vue"; import EditUserDialog from "@/v2/components/Settings/EditUserDialog.vue"; import GroupFormDialog from "@/v2/components/Settings/GroupFormDialog.vue"; import InviteLinkDialog from "@/v2/components/Settings/InviteLinkDialog.vue"; +import DownloadStatsSection from "@/v2/components/Settings/DownloadStatsSection.vue"; import PermissionGroupsSection from "@/v2/components/Settings/PermissionGroupsSection.vue"; import TasksSection from "@/v2/components/Settings/TasksSection.vue"; import UsersSection from "@/v2/components/Settings/UsersSection.vue"; @@ -26,8 +27,8 @@ const route = useRoute(); const router = useRouter(); const auth = storeAuth(); -type Tab = "users" | "groups" | "tasks"; -const validTabs: Tab[] = ["users", "groups", "tasks"]; +type Tab = "users" | "groups" | "downloads" | "tasks"; +const validTabs: Tab[] = ["users", "groups", "downloads", "tasks"]; const tab = ref( (validTabs as string[]).includes(route.query.tab as string) @@ -71,6 +72,13 @@ const tabs = computed(() => { icon: "mdi-shield-lock-outline", }); } + if (auth.scopes.includes("users.read")) { + items.push({ + id: "downloads", + label: t("settings.downloads"), + icon: "mdi-download", + }); + } if (auth.scopes.includes("tasks.run")) { items.push({ id: "tasks", @@ -96,6 +104,7 @@ const tabModel = computed({ + diff --git a/runbandit.sh b/runbandit.sh new file mode 100644 index 0000000000..bdbe2fc3d7 --- /dev/null +++ b/runbandit.sh @@ -0,0 +1,12 @@ +#!/bin/bash +cd /app/backend || exit 1 +echo "=== bandit on my files after the suppressions ===" +python -m bandit --ini /app/.trunk/configs/.bandit -r \ + models/download_event.py handler/database/downloads_handler.py \ + endpoints/downloads.py endpoints/responses/downloads.py \ + utils/downloads.py tasks/scheduled/cleanup_download_events.py \ + handler/auth/constants.py handler/auth/hybrid_auth.py \ + endpoints/roms/files.py endpoints/roms/__init__.py \ + models/rom.py endpoints/responses/rom.py \ + main.py startup.py config/__init__.py alembic/versions/0108_download_statistics.py \ + 2>&1 | grep -E "Issue:|Total issues|Low:|Medium:|High:|nosec" | head -15 diff --git a/runmypy.sh b/runmypy.sh new file mode 100644 index 0000000000..67945c3800 --- /dev/null +++ b/runmypy.sh @@ -0,0 +1,11 @@ +#!/bin/bash +cd /app/backend || exit 1 +uv run mypy --config-file /app/.trunk/configs/mypy.ini \ + models/download_event.py handler/database/downloads_handler.py \ + endpoints/downloads.py endpoints/responses/downloads.py \ + utils/downloads.py tasks/scheduled/cleanup_download_events.py 2>&1 > /tmp/out.txt +echo "=== errors grouped by file ===" +grep -oE "^[^:]+\.py" /tmp/out.txt | sort | uniq -c | sort -rn +echo +echo "=== errors in MY new files ===" +grep -E "^(models/download_event|handler/database/downloads_handler|endpoints/downloads|endpoints/responses/downloads|utils/downloads|tasks/scheduled/cleanup_download_events)\.py" /tmp/out.txt || echo " NONE" diff --git a/runtests.sh b/runtests.sh new file mode 100644 index 0000000000..41d6118f77 --- /dev/null +++ b/runtests.sh @@ -0,0 +1,5 @@ +#!/bin/bash +(socat TCP-LISTEN:5432,fork,reuseaddr TCP:romm-postgres-dev:5432 &); sleep 2 +cd /app/backend || exit 1 +export ROMM_DB_DRIVER=postgresql DB_PORT=5432 +uv run pytest tests/handler/database/test_downloads_handler.py tests/endpoints/test_downloads.py tests/endpoints/roms/test_rom.py -q 2>&1 | tail -5