From 778097f4a08ff93ed69a610a7ae79f3ee2ea47d0 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 9 Mar 2026 22:15:08 -0400 Subject: [PATCH] feat: add per-platform metadata coverage and region breakdown to server stats Enhances the server stats page with two new per-platform statistics: - Metadata coverage: shows which sources matched ROMs (ordered by user's scan priority config) - Region breakdown: shows ROM counts per region with flag emojis Backend adds two new efficient queries (single GROUP BY for metadata, Python-side aggregation for regions). Frontend redesigns platform cards with a tabular detail layout, size bar visualization, and expandable region chips. > This PR was developed with AI assistance (Claude Code) per CONTRIBUTING.md disclosure requirements. Co-Authored-By: Claude Opus 4.6 --- backend/endpoints/responses/stats.py | 12 + backend/endpoints/stats.py | 2 + backend/handler/database/stats_handler.py | 68 ++++ frontend/src/__generated__/index.ts | 2 + .../models/MetadataCoverageItem.ts | 9 + .../models/RegionBreakdownItem.ts | 9 + .../src/__generated__/models/StatsReturn.ts | 4 + .../Settings/ServerStats/PlatformsStats.vue | 314 +++++++++++++++--- frontend/src/locales/en_US/common.json | 2 + frontend/src/views/Settings/ServerStats.vue | 11 +- 10 files changed, 392 insertions(+), 41 deletions(-) create mode 100644 frontend/src/__generated__/models/MetadataCoverageItem.ts create mode 100644 frontend/src/__generated__/models/RegionBreakdownItem.ts diff --git a/backend/endpoints/responses/stats.py b/backend/endpoints/responses/stats.py index 270cf6877..5873526a6 100644 --- a/backend/endpoints/responses/stats.py +++ b/backend/endpoints/responses/stats.py @@ -1,6 +1,16 @@ from typing import TypedDict +class MetadataCoverageItem(TypedDict): + source: str + matched: int + + +class RegionBreakdownItem(TypedDict): + region: str + count: int + + class StatsReturn(TypedDict): PLATFORMS: int ROMS: int @@ -8,3 +18,5 @@ class StatsReturn(TypedDict): STATES: int SCREENSHOTS: int TOTAL_FILESIZE_BYTES: int + METADATA_COVERAGE: dict[int, list[MetadataCoverageItem]] + REGION_BREAKDOWN: dict[int, list[RegionBreakdownItem]] diff --git a/backend/endpoints/stats.py b/backend/endpoints/stats.py index d7a3dc8e9..1d77766b4 100644 --- a/backend/endpoints/stats.py +++ b/backend/endpoints/stats.py @@ -23,4 +23,6 @@ def stats() -> StatsReturn: "STATES": db_stats_handler.get_states_count(), "SCREENSHOTS": db_stats_handler.get_screenshots_count(), "TOTAL_FILESIZE_BYTES": db_stats_handler.get_total_filesize(), + "METADATA_COVERAGE": db_stats_handler.get_metadata_coverage_by_platform(), + "REGION_BREAKDOWN": db_stats_handler.get_region_breakdown_by_platform(), } diff --git a/backend/handler/database/stats_handler.py b/backend/handler/database/stats_handler.py index 1d7105f7a..beda76b9d 100644 --- a/backend/handler/database/stats_handler.py +++ b/backend/handler/database/stats_handler.py @@ -79,3 +79,71 @@ class DBStatsHandler(DBBaseHandler): ) or 0 ) + + @begin_session + def get_metadata_coverage_by_platform( + self, + session: Session = None, # type: ignore + ) -> dict[int, list[dict]]: + """Get the count of ROMs matched per metadata source, grouped by platform.""" + source_keys = [ + "igdb", "ss", "moby", "launchbox", "ra", + "hasheous", "tgdb", "flashpoint", "hltb", "gamelist", + ] + + rows = session.execute( + select( + Rom.platform_id, + func.count(Rom.igdb_id).label("igdb"), + func.count(Rom.ss_id).label("ss"), + func.count(Rom.moby_id).label("moby"), + func.count(Rom.launchbox_id).label("launchbox"), + func.count(Rom.ra_id).label("ra"), + func.count(Rom.hasheous_id).label("hasheous"), + func.count(Rom.tgdb_id).label("tgdb"), + func.count(Rom.flashpoint_id).label("flashpoint"), + func.count(Rom.hltb_id).label("hltb"), + func.count(Rom.gamelist_id).label("gamelist"), + ) + .select_from(Rom) + .group_by(Rom.platform_id) + ).all() + + result: dict[int, list[dict]] = {} + for row in rows: + result[row.platform_id] = [ + {"source": key, "matched": getattr(row, key)} + for key in source_keys + if getattr(row, key) > 0 + ] + return result + + @begin_session + def get_region_breakdown_by_platform( + self, + session: Session = None, # type: ignore + ) -> dict[int, list[dict]]: + """Get the count of ROMs per region, grouped by platform.""" + rows = session.execute( + select(Rom.platform_id, Rom.regions).where( + Rom.regions.is_not(None) + ) + ).all() + + counter: dict[int, dict[str, int]] = {} + for platform_id, regions_list in rows: + if regions_list: + if platform_id not in counter: + counter[platform_id] = {} + for region in regions_list: + counter[platform_id][region] = ( + counter[platform_id].get(region, 0) + 1 + ) + + return { + pid: [ + {"region": r, "count": c} + for r, c in sorted(regions.items(), key=lambda x: -x[1]) + ] + for pid, regions in counter.items() + } diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 283103549..2421e20f8 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -66,6 +66,7 @@ export type { InviteLinkSchema } from './models/InviteLinkSchema'; export type { JobStatus } from './models/JobStatus'; export type { LaunchboxImage } from './models/LaunchboxImage'; export type { ManualMetadata } from './models/ManualMetadata'; +export type { MetadataCoverageItem } from './models/MetadataCoverageItem'; export type { MetadataSourcesDict } from './models/MetadataSourcesDict'; export type { MobyMetadataPlatform } from './models/MobyMetadataPlatform'; export type { NetplayICEServer } from './models/NetplayICEServer'; @@ -75,6 +76,7 @@ export type { PlatformSchema } from './models/PlatformSchema'; export type { RAGameRomAchievement } from './models/RAGameRomAchievement'; export type { RAProgression } from './models/RAProgression'; export type { RAUserGameProgression } from './models/RAUserGameProgression'; +export type { RegionBreakdownItem } from './models/RegionBreakdownItem'; export type { Role } from './models/Role'; export type { RomFileCategory } from './models/RomFileCategory'; export type { RomFileSchema } from './models/RomFileSchema'; diff --git a/frontend/src/__generated__/models/MetadataCoverageItem.ts b/frontend/src/__generated__/models/MetadataCoverageItem.ts new file mode 100644 index 000000000..1ad9e3393 --- /dev/null +++ b/frontend/src/__generated__/models/MetadataCoverageItem.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type MetadataCoverageItem = { + source: string; + matched: number; +}; + diff --git a/frontend/src/__generated__/models/RegionBreakdownItem.ts b/frontend/src/__generated__/models/RegionBreakdownItem.ts new file mode 100644 index 000000000..35276523b --- /dev/null +++ b/frontend/src/__generated__/models/RegionBreakdownItem.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type RegionBreakdownItem = { + region: string; + count: number; +}; + diff --git a/frontend/src/__generated__/models/StatsReturn.ts b/frontend/src/__generated__/models/StatsReturn.ts index 260cddc69..497561f76 100644 --- a/frontend/src/__generated__/models/StatsReturn.ts +++ b/frontend/src/__generated__/models/StatsReturn.ts @@ -2,6 +2,8 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { MetadataCoverageItem } from './MetadataCoverageItem'; +import type { RegionBreakdownItem } from './RegionBreakdownItem'; export type StatsReturn = { PLATFORMS: number; ROMS: number; @@ -9,5 +11,7 @@ export type StatsReturn = { STATES: number; SCREENSHOTS: number; TOTAL_FILESIZE_BYTES: number; + METADATA_COVERAGE: Record>; + REGION_BREAKDOWN: Record>; }; diff --git a/frontend/src/components/Settings/ServerStats/PlatformsStats.vue b/frontend/src/components/Settings/ServerStats/PlatformsStats.vue index 42a3657bd..88dd5154e 100644 --- a/frontend/src/components/Settings/ServerStats/PlatformsStats.vue +++ b/frontend/src/components/Settings/ServerStats/PlatformsStats.vue @@ -2,17 +2,23 @@ import { storeToRefs } from "pinia"; import { ref, computed } from "vue"; import { useI18n } from "vue-i18n"; -import PlatformListItem from "@/components/common/Platform/ListItem.vue"; +import PlatformIcon from "@/components/common/Platform/PlatformIcon.vue"; import RSection from "@/components/common/RSection.vue"; import storePlatforms from "@/stores/platforms"; -import { formatBytes } from "@/utils"; +import storeHeartbeat from "@/stores/heartbeat"; +import { formatBytes, platformCategoryToIcon, regionToEmoji } from "@/utils"; +import type { MetadataCoverageItem } from "@/__generated__/models/MetadataCoverageItem"; +import type { RegionBreakdownItem } from "@/__generated__/models/RegionBreakdownItem"; const props = defineProps<{ totalFilesize: number; + metadataCoverage: Record; + regionBreakdown: Record; }>(); const { t } = useI18n(); const platformsStore = storePlatforms(); const { allPlatforms } = storeToRefs(platformsStore); +const heartbeat = storeHeartbeat(); const orderBy = ref<"name" | "size" | "count">("name"); const sortedPlatforms = computed(() => { @@ -31,6 +37,25 @@ const sortedPlatforms = computed(() => { ); }); +const metadataOptions = computed(() => heartbeat.getMetadataOptionsByPriority()); + +const sourceInfo = computed(() => { + const map: Record = {}; + for (const opt of metadataOptions.value) { + map[opt.value] = { name: opt.name, logo_path: opt.logo_path }; + } + return map; +}); + +function getOrderedCoverage(platformId: number): MetadataCoverageItem[] { + const items = props.metadataCoverage[platformId]; + if (!items) return []; + const priority = metadataOptions.value.map((o) => o.value); + return [...items].sort( + (a, b) => priority.indexOf(a.source) - priority.indexOf(b.source), + ); +} + function getPlatformPercentage( filesize: number | string, total: number, @@ -39,6 +64,35 @@ function getPlatformPercentage( if (!total || isNaN(size)) return 0; return (size / total) * 100; } + +const MAX_VISIBLE_REGIONS = 5; +const expandedRegions = ref(new Set()); + +function getVisibleRegions(platformId: number): RegionBreakdownItem[] { + const items = props.regionBreakdown[platformId]; + if (!items) return []; + if (expandedRegions.value.has(platformId)) return items; + return items.slice(0, MAX_VISIBLE_REGIONS); +} + +function getHiddenRegionCount(platformId: number): number { + if (expandedRegions.value.has(platformId)) return 0; + const items = props.regionBreakdown[platformId]; + if (!items) return 0; + return Math.max(0, items.length - MAX_VISIBLE_REGIONS); +} + +function toggleRegions(platformId: number): void { + const s = new Set(expandedRegions.value); + if (s.has(platformId)) s.delete(platformId); + else s.add(platformId); + expandedRegions.value = s; +} + +function getCoveragePercent(matched: number, total: number): string { + if (!total) return "0"; + return ((matched / total) * 100).toFixed(0); +} + + diff --git a/frontend/src/locales/en_US/common.json b/frontend/src/locales/en_US/common.json index 309857f9b..e73e81481 100644 --- a/frontend/src/locales/en_US/common.json +++ b/frontend/src/locales/en_US/common.json @@ -32,7 +32,9 @@ "platform": "Platform", "platforms": "Platforms", "platforms-n": "{n} Platform | {n} Platforms", + "metadata-coverage": "Metadata coverage", "platforms-size": "Size per platform", + "region-breakdown": "Region breakdown", "profile": "Profile", "random": "Random", "removing-from-filesystem": "Removing from filesystem", diff --git a/frontend/src/views/Settings/ServerStats.vue b/frontend/src/views/Settings/ServerStats.vue index 758bbf0f9..7e488d566 100644 --- a/frontend/src/views/Settings/ServerStats.vue +++ b/frontend/src/views/Settings/ServerStats.vue @@ -3,6 +3,8 @@ import { onBeforeMount, ref } from "vue"; import PlatformsStats from "@/components/Settings/ServerStats/PlatformsStats.vue"; import SummaryStats from "@/components/Settings/ServerStats/SummaryStats.vue"; import api from "@/services/api"; +import type { MetadataCoverageItem } from "@/__generated__/models/MetadataCoverageItem"; +import type { RegionBreakdownItem } from "@/__generated__/models/RegionBreakdownItem"; const stats = ref({ PLATFORMS: 0, @@ -11,6 +13,8 @@ const stats = ref({ STATES: 0, SCREENSHOTS: 0, TOTAL_FILESIZE_BYTES: 0, + METADATA_COVERAGE: {} as Record, + REGION_BREAKDOWN: {} as Record, }); onBeforeMount(() => { @@ -21,5 +25,10 @@ onBeforeMount(() => {