Merge pull request #3110 from cciollaro/feat/stats-metadata-coverage-regions

Per-platform metadata coverage and region breakdown to server stats
This commit is contained in:
Georges-Antoine Assi
2026-03-10 21:58:32 -04:00
committed by GitHub
25 changed files with 385 additions and 63 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

@@ -1,5 +1,8 @@
from __future__ import annotations
from sqlalchemy import distinct, func, select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import InstrumentedAttribute
from decorators.database import begin_session
from models.assets import Save, Screenshot, State
@@ -7,6 +10,20 @@ from models.rom import Rom, RomFile
from .base_handler import DBBaseHandler
# Metadata source columns on the Rom model, keyed by source identifier.
_METADATA_SOURCE_COLUMNS: dict[str, InstrumentedAttribute] = {
"igdb": Rom.igdb_id,
"ss": Rom.ss_id,
"moby": Rom.moby_id,
"launchbox": Rom.launchbox_id,
"ra": Rom.ra_id,
"hasheous": Rom.hasheous_id,
"tgdb": Rom.tgdb_id,
"flashpoint": Rom.flashpoint_id,
"hltb": Rom.hltb_id,
"gamelist": Rom.gamelist_id,
}
class DBStatsHandler(DBBaseHandler):
@begin_session
@@ -79,3 +96,58 @@ 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."""
rows = session.execute(
select(
Rom.platform_id,
*(
func.count(col).label(key)
for key, col in _METADATA_SOURCE_COLUMNS.items()
),
)
.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 _METADATA_SOURCE_COLUMNS
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';
@@ -76,6 +77,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,35 +2,64 @@
import { storeToRefs } from "pinia";
import { ref, computed } from "vue";
import { useI18n } from "vue-i18n";
import PlatformListItem from "@/components/common/Platform/ListItem.vue";
import type { MetadataCoverageItem } from "@/__generated__/models/MetadataCoverageItem";
import type { RegionBreakdownItem } from "@/__generated__/models/RegionBreakdownItem";
import PlatformIcon from "@/components/common/Platform/PlatformIcon.vue";
import RSection from "@/components/common/RSection.vue";
import storeHeartbeat from "@/stores/heartbeat";
import storePlatforms from "@/stores/platforms";
import { formatBytes } from "@/utils";
import { formatBytes, platformCategoryToIcon, regionToEmoji } from "@/utils";
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(() => {
if (orderBy.value === "size") {
return allPlatforms.value.sort(
return [...allPlatforms.value].sort(
(a, b) => Number(b.fs_size_bytes) - Number(a.fs_size_bytes),
);
}
if (orderBy.value === "count") {
return allPlatforms.value.sort((a, b) => b.rom_count - a.rom_count);
return [...allPlatforms.value].sort((a, b) => b.rom_count - a.rom_count);
}
return allPlatforms.value.sort((a, b) =>
return [...allPlatforms.value].sort((a, b) =>
a.display_name.localeCompare(b.display_name, undefined, {
sensitivity: "base",
}),
);
});
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;
});
const orderedCoverageByPlatform = computed(() => {
const priority = metadataOptions.value.map((o) => o.value);
const result: Record<string, MetadataCoverageItem[]> = {};
for (const [id, items] of Object.entries(props.metadataCoverage)) {
result[id] = [...items].sort(
(a, b) => priority.indexOf(a.source) - priority.indexOf(b.source),
);
}
return result;
});
function getPlatformPercentage(
filesize: number | string,
total: number,
@@ -39,12 +68,41 @@ 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[String(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[String(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>
<RSection
icon="mdi-harddisk"
:title="t('common.platforms-size')"
icon="mdi-controller"
:title="t('common.platforms')"
elevation="0"
title-divider
bg-color="bg-background"
@@ -63,51 +121,212 @@ 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="overflow-hidden mb-3"
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"
/>
</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 class="pa-3">
<div class="platform-layout grid gap-3">
<div class="flex items-start pt-1">
<PlatformIcon
:slug="platform.slug"
:name="platform.name"
:fs-slug="platform.fs_slug"
:size="36"
/>
</div>
<div class="platform-content">
<!-- Header: Name + size -->
<div class="d-flex justify-space-between align-center">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ platform.display_name }}
</div>
<div class="d-flex align-center ga-1">
<v-chip size="x-small" label class="text-grey">
{{ 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 shrink-0 ml-4">
<div class="text-subtitle-2 font-weight-bold text-primary">
{{ formatBytes(Number(platform.fs_size_bytes)) }}
</div>
<div class="text-medium-emphasis" style="font-size: 0.7rem">
{{
getPlatformPercentage(
platform.fs_size_bytes,
props.totalFilesize,
).toFixed(1)
}}%
</div>
</div>
</div>
<!-- Detail table: label | value -->
<div class="detail-table mt-2 flex flex-col gap-2">
<div class="detail-row grid items-baseline gap-2">
<span
class="detail-label whitespace-nowrap font-semibold uppercase opacity-50"
>
{{ t("setup.games") }}
</span>
<div>
<v-chip size="x-small" label>
{{ platform.rom_count }}
</v-chip>
</div>
</div>
<div class="detail-row grid items-baseline gap-2">
<span
class="detail-label whitespace-nowrap font-semibold uppercase opacity-50"
>
{{ t("rom.metadata") }}
</span>
<div
v-if="
orderedCoverageByPlatform[String(platform.id)]?.length >
0
"
class="d-flex flex-wrap ga-1"
>
<v-chip
v-for="item in orderedCoverageByPlatform[
String(platform.id)
]"
:key="item.source"
:title="`${sourceInfo[item.source]?.name ?? item.source}: ${item.matched} / ${platform.rom_count}`"
class="min-w-13 justify-center"
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="text-xs opacity-25">—</span>
</div>
<div class="detail-row grid items-baseline gap-2">
<span
class="detail-label whitespace-nowrap font-semibold uppercase opacity-50"
>
{{ t("platform.region") }}
</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="min-w-13 justify-center"
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="text-xs opacity-25">—</span>
</div>
</div>
</div>
</div>
</div>
<div class="size-bar-track h-0.75">
<div
class="size-bar-fill h-full min-w-0 rounded-r-xs duration-300 ease-in-out"
:style="{
width:
getPlatformPercentage(
platform.fs_size_bytes,
props.totalFilesize,
) + '%',
}"
/>
</div>
</v-sheet>
</div>
</template>
</RSection>
</template>
<style scoped>
.platform-layout {
grid-template-columns: 36px 1fr;
}
.detail-table {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.detail-row {
grid-template-columns: 120px 1fr;
}
.detail-label {
font-size: 0.625rem;
line-height: 1.8;
letter-spacing: 0.04em;
}
.size-bar-track {
background: rgba(var(--v-border-color), var(--v-border-opacity));
}
.size-bar-fill {
background: rgb(var(--v-theme-primary));
transition-property: width;
}
</style>

View File

@@ -32,7 +32,6 @@
"platform": "Platforma",
"platforms": "Platformy",
"platforms-n": "{n} platforem | {n} platforma | {n} platformy | {n} platforem",
"platforms-size": "Velikost podle platforem",
"profile": "Profil",
"random": "Náhodně",
"removing-from-filesystem": "Odstraňování ze systému",

View File

@@ -32,7 +32,6 @@
"platform": "Plattform",
"platforms": "Plattformen",
"platforms-n": "{n} Plattform | {n} Plattformen",
"platforms-size": "Größe pro Plattform",
"profile": "Profil",
"random": "Zufällig",
"removing-from-filesystem": "Entfernen vom System",

View File

@@ -32,7 +32,6 @@
"platform": "Platform",
"platforms": "Platforms",
"platforms-n": "{n} Platform | {n} Platforms",
"platforms-size": "Size per platform",
"profile": "Profile",
"random": "Random",
"removing-from-filesystem": "Removing from filesystem",

View File

@@ -32,7 +32,6 @@
"platform": "Platform",
"platforms": "Platforms",
"platforms-n": "{n} Platform | {n} Platforms",
"platforms-size": "Size per platform",
"profile": "Profile",
"random": "Random",
"removing-from-filesystem": "Removing from filesystem",

View File

@@ -32,7 +32,6 @@
"platform": "Plataforma",
"platforms": "Plataformas",
"platforms-n": "{n} Plataforma | {n} Plataformas",
"platforms-size": "Tamaño por plataforma",
"profile": "Perfil",
"random": "Aleatorio",
"removing-from-filesystem": "Eliminando del sistema",

View File

@@ -32,7 +32,6 @@
"platform": "Plateforme",
"platforms": "Plateformes",
"platforms-n": "{n} Plateforme | {n} Plateformes",
"platforms-size": "Taille par plateforme",
"profile": "Profil",
"random": "Aléatoire",
"removing-from-filesystem": "Suppression du système",

View File

@@ -32,7 +32,6 @@
"platform": "Platform",
"platforms": "Platformok",
"platforms-n": "{n} Platform | {n} Platformok",
"platforms-size": "Méret platformonként",
"profile": "Profil",
"random": "Véletlenszerű",
"removing-from-filesystem": "Eltávolítás a fájlrendszerből",

View File

@@ -32,7 +32,6 @@
"platform": "Piattaforma",
"platforms": "Piattaforme",
"platforms-n": "{n} Piattaforma | {n} Piattaforme",
"platforms-size": "Dimensione per piattaforma",
"profile": "Profilo",
"random": "Casuale",
"removing-from-filesystem": "Rimozione dal sistema",

View File

@@ -32,7 +32,6 @@
"platform": "プラットフォーム",
"platforms": "プラットフォーム",
"platforms-n": "{n} プラットフォーム | {n} プラットフォームs",
"platforms-size": "プラットフォームごとのサイズ",
"profile": "プロファイル",
"random": "ランダム",
"removing-from-filesystem": "システムから削除中",

View File

@@ -32,7 +32,6 @@
"platform": "플랫폼",
"platforms": "플랫폼",
"platforms-n": "{n}가지 플랫폼 | {n}가지 플랫폼",
"platforms-size": "플랫폼별 크기",
"profile": "프로필",
"random": "무작위",
"removing-from-filesystem": "시스템에서 제거 중",

View File

@@ -32,7 +32,6 @@
"platform": "Platforma",
"platforms": "Platformy",
"platforms-n": "{n} Platforma | {n} Platformy | {n} Platform",
"platforms-size": "Rozmiar na platformę",
"profile": "Profil",
"random": "Losowo",
"removing-from-filesystem": "Usuwanie z systemu",

View File

@@ -32,7 +32,6 @@
"platform": "Plataforma",
"platforms": "Plataformas",
"platforms-n": "{n} Plataforma | {n} Plataformas",
"platforms-size": "Tamanho por plataforma",
"profile": "Perfil",
"random": "Aleatório",
"removing-from-filesystem": "Removendo do sistema",

View File

@@ -32,7 +32,6 @@
"platform": "Platformă",
"platforms": "Platforme",
"platforms-n": "{n} Platformă | {n} Platforme",
"platforms-size": "Dimensiune per platformă",
"profile": "Profil",
"random": "Aleatoriu",
"removing-from-filesystem": "Ștergere din sistem",

View File

@@ -32,7 +32,6 @@
"platform": "Платформа",
"platforms": "Платформы",
"platforms-n": "{n} Платформа | {n} Платформы",
"platforms-size": "Размер по платформе",
"profile": "Профиль",
"random": "Случайный",
"removing-from-filesystem": "Удаление из системы",

View File

@@ -32,7 +32,6 @@
"platform": "平台",
"platforms": "平台",
"platforms-n": "{n} 平台 | {n} 平台",
"platforms-size": "每个平台的大小",
"profile": "简介",
"random": "随机",
"removing-from-filesystem": "正在从系统删除",

View File

@@ -32,7 +32,6 @@
"platform": "平台",
"platforms": "平台",
"platforms-n": "{n} 平台 | {n} 平台",
"platforms-size": "每個平台的大小",
"profile": "用戶資料",
"random": "隨機",
"removing-from-filesystem": "正在從系統移除",

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { onBeforeMount, ref } from "vue";
import type { MetadataCoverageItem } from "@/__generated__/models/MetadataCoverageItem";
import type { RegionBreakdownItem } from "@/__generated__/models/RegionBreakdownItem";
import PlatformsStats from "@/components/Settings/ServerStats/PlatformsStats.vue";
import SummaryStats from "@/components/Settings/ServerStats/SummaryStats.vue";
import api from "@/services/api";
@@ -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>