mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
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:
@@ -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]]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
2
frontend/src/__generated__/index.ts
generated
2
frontend/src/__generated__/index.ts
generated
@@ -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';
|
||||
|
||||
9
frontend/src/__generated__/models/MetadataCoverageItem.ts
generated
Normal file
9
frontend/src/__generated__/models/MetadataCoverageItem.ts
generated
Normal 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;
|
||||
};
|
||||
|
||||
9
frontend/src/__generated__/models/RegionBreakdownItem.ts
generated
Normal file
9
frontend/src/__generated__/models/RegionBreakdownItem.ts
generated
Normal 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;
|
||||
};
|
||||
|
||||
4
frontend/src/__generated__/models/StatsReturn.ts
generated
4
frontend/src/__generated__/models/StatsReturn.ts
generated
@@ -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>>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user