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 <noreply@anthropic.com>
This commit is contained in:
cc
2026-03-09 22:15:08 -04:00
parent da09f6c81c
commit 778097f4a0
10 changed files with 392 additions and 41 deletions

View File

@@ -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]]

View File

@@ -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(),
}

View File

@@ -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()
}

View File

@@ -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';

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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<string, Array<MetadataCoverageItem>>;
REGION_BREAKDOWN: Record<string, Array<RegionBreakdownItem>>;
};

View File

@@ -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<string, MetadataCoverageItem[]>;
regionBreakdown: Record<string, RegionBreakdownItem[]>;
}>();
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<string, { name: string; logo_path: string }> = {};
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<number>());
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);
}
</script>
<template>
@@ -63,51 +117,231 @@ function getPlatformPercentage(
label="Order by"
variant="outlined"
hide-details
density="compact"
/>
</v-col>
</v-row>
<v-row no-gutters>
<v-col
<div class="px-4 pb-3">
<v-sheet
v-for="platform in sortedPlatforms"
:key="platform.slug"
cols="12"
class="pa-4"
class="platform-card mb-2"
rounded
>
<v-row no-gutters class="d-flex justify-space-between align-center">
<v-col cols="6">
<PlatformListItem
:key="platform.slug"
:platform="platform"
:show-rom-count="false"
<div class="pa-3">
<div class="platform-grid">
<div class="platform-icon">
<PlatformIcon
:slug="platform.slug"
:name="platform.name"
:fs-slug="platform.fs_slug"
:size="36"
/>
</v-col>
<v-col cols="6" class="text-right">
<v-list-item>
<v-list-item-title class="text-body-2">
{{ formatBytes(Number(platform.fs_size_bytes)) }}
({{
getPlatformPercentage(
platform.fs_size_bytes,
props.totalFilesize,
).toFixed(1)
}}%)
</v-list-item-title>
<v-list-item-subtitle class="text-right mt-1">
{{ platform.rom_count }} roms
</v-list-item-subtitle>
</v-list-item>
</v-col>
</v-row>
<v-progress-linear
:model-value="
getPlatformPercentage(platform.fs_size_bytes, props.totalFilesize)
"
rounded
color="primary"
height="8"
/>
</v-col>
</v-row>
</div>
<div class="platform-content">
<!-- Row 1: Name + stats (primary info) -->
<div class="d-flex justify-space-between align-start">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ platform.display_name }}
</div>
<div class="d-flex align-center ga-1 mt-n1">
<v-chip
size="x-small"
label
variant="text"
class="text-medium-emphasis pa-0"
style="font-size: 0.65rem"
>
{{ platform.fs_slug }}
</v-chip>
<v-icon
:icon="platformCategoryToIcon(platform.category || '')"
size="10"
class="text-medium-emphasis"
:title="platform.category"
/>
<span
v-if="platform.family_name"
class="text-medium-emphasis"
style="font-size: 0.65rem"
>
{{ platform.family_name }}
</span>
</div>
</div>
<div class="text-right flex-shrink-0 ml-4">
<div class="text-subtitle-1 font-weight-bold text-primary">
{{ formatBytes(Number(platform.fs_size_bytes)) }}
</div>
<div class="text-medium-emphasis" style="font-size: 0.7rem">
{{ platform.rom_count }} roms
· {{
getPlatformPercentage(
platform.fs_size_bytes,
props.totalFilesize,
).toFixed(1)
}}%
</div>
</div>
</div>
<!-- Detail table: label | chips -->
<div class="detail-table mt-2">
<div class="detail-row">
<span class="detail-label">
{{ t("common.metadata-coverage") }}
</span>
<div
v-if="getOrderedCoverage(platform.id).length > 0"
class="d-flex flex-wrap ga-1"
>
<v-chip
v-for="item in getOrderedCoverage(platform.id)"
:key="item.source"
:title="`${sourceInfo[item.source]?.name ?? item.source}: ${item.matched} / ${platform.rom_count}`"
class="chip-fixed"
size="x-small"
label
variant="tonal"
>
<v-avatar
v-if="sourceInfo[item.source]?.logo_path"
start
size="12"
rounded
>
<v-img :src="sourceInfo[item.source].logo_path" />
</v-avatar>
{{ getCoveragePercent(item.matched, platform.rom_count) }}%
</v-chip>
</div>
<span v-else class="empty-state"></span>
</div>
<div class="detail-row">
<span class="detail-label">
{{ t("common.region-breakdown") }}
</span>
<div
v-if="getVisibleRegions(platform.id).length > 0"
class="d-flex flex-wrap ga-1"
>
<v-chip
v-for="item in getVisibleRegions(platform.id)"
:key="item.region"
:title="`${item.region}: ${item.count}`"
class="chip-fixed"
size="x-small"
label
variant="tonal"
>
<span class="mr-1">{{ regionToEmoji(item.region) }}</span>
{{ item.count }}
</v-chip>
<v-chip
v-if="
getHiddenRegionCount(platform.id) > 0 ||
expandedRegions.has(platform.id)
"
size="x-small"
label
variant="text"
class="text-medium-emphasis cursor-pointer"
@click="toggleRegions(platform.id)"
>
{{
expandedRegions.has(platform.id)
? ""
: "+" + getHiddenRegionCount(platform.id)
}}
</v-chip>
</div>
<span v-else class="empty-state">—</span>
</div>
</div>
</div>
</div>
</div>
<div class="size-bar-track">
<div
class="size-bar-fill"
:style="{
width:
getPlatformPercentage(
platform.fs_size_bytes,
props.totalFilesize,
) + '%',
}"
/>
</div>
</v-sheet>
</div>
</template>
</RSection>
</template>
<style scoped>
.platform-card {
overflow: hidden;
}
.platform-grid {
display: grid;
grid-template-columns: 36px 1fr;
column-gap: 12px;
}
.platform-icon {
display: flex;
align-items: flex-start;
padding-top: 2px;
}
.detail-table {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.detail-row {
display: grid;
grid-template-columns: 120px 1fr;
align-items: baseline;
gap: 8px;
}
.detail-label {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.5;
line-height: 1.8;
white-space: nowrap;
}
.chip-fixed {
min-width: 52px;
justify-content: center;
}
.empty-state {
font-size: 0.75rem;
opacity: 0.25;
}
.size-bar-track {
height: 3px;
background: rgba(var(--v-border-color), var(--v-border-opacity));
}
.size-bar-fill {
height: 100%;
min-width: 2px;
background: rgb(var(--v-theme-primary));
border-radius: 0 2px 2px 0;
transition: width 0.3s ease;
}
</style>

View File

@@ -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",

View File

@@ -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<string, MetadataCoverageItem[]>,
REGION_BREAKDOWN: {} as Record<string, RegionBreakdownItem[]>,
});
onBeforeMount(() => {
@@ -21,5 +25,10 @@ onBeforeMount(() => {
</script>
<template>
<SummaryStats class="ma-2" :stats="stats" />
<PlatformsStats class="ma-2" :total-filesize="stats.TOTAL_FILESIZE_BYTES" />
<PlatformsStats
class="ma-2"
:total-filesize="stats.TOTAL_FILESIZE_BYTES"
:metadata-coverage="stats.METADATA_COVERAGE"
:region-breakdown="stats.REGION_BREAKDOWN"
/>
</template>