From fe14067667e79e145567e4a925d5ba2521eb5713 Mon Sep 17 00:00:00 2001 From: SaraVieira Date: Fri, 2 Jan 2026 17:29:41 +0000 Subject: [PATCH 1/6] feature: Add addtional editable metadata --- .../alembic/versions/0061_manual_metadata.py | 765 ++++++++++++++++++ backend/endpoints/responses/rom.py | 16 + backend/endpoints/rom.py | 7 +- backend/models/rom.py | 3 + .../src/components/Details/Info/GameInfo.vue | 68 +- .../components/Details/Info/MediaCarousel.vue | 6 +- .../components/common/Game/Dialog/EditRom.vue | 11 +- .../Game/Dialog/EditRom/AdditionalDetails.vue | 175 ++++ frontend/src/plugins/vuetify.ts | 4 + frontend/src/services/api/rom.ts | 20 + 10 files changed, 1062 insertions(+), 13 deletions(-) create mode 100644 backend/alembic/versions/0061_manual_metadata.py create mode 100644 frontend/src/components/common/Game/Dialog/EditRom/AdditionalDetails.vue diff --git a/backend/alembic/versions/0061_manual_metadata.py b/backend/alembic/versions/0061_manual_metadata.py new file mode 100644 index 000000000..d5d01941a --- /dev/null +++ b/backend/alembic/versions/0061_manual_metadata.py @@ -0,0 +1,765 @@ +"""Add manual_metadata column and prioritize it in roms_metadata view + +Revision ID: 0061_manual_metadata +Revises: 0060_user_ui_settings +Create Date: 2026-01-02 14:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +from utils.database import is_postgresql + +# revision identifiers, used by Alembic. +revision = "0061_manual_metadata" +down_revision = "0060_user_ui_settings" +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "manual_metadata", + sa.JSON().with_variant( + postgresql.JSONB(astext_type=sa.Text()), "postgresql" + ), + nullable=True, + ) + ) + + if is_postgresql(connection): + connection.execute( + sa.text( + """ + CREATE OR REPLACE VIEW roms_metadata AS + SELECT + r.id AS rom_id, + NOW() AS created_at, + NOW() AS updated_at, + COALESCE( + (r.manual_metadata -> 'genres'), + (r.igdb_metadata -> 'genres'), + (r.moby_metadata -> 'genres'), + (r.ss_metadata -> 'genres'), + (r.launchbox_metadata -> 'genres'), + (r.ra_metadata -> 'genres'), + (r.flashpoint_metadata -> 'genres'), + (r.gamelist_metadata -> 'genres'), + '[]'::jsonb + ) AS genres, + + COALESCE( + (r.manual_metadata -> 'franchises'), + (r.igdb_metadata -> 'franchises'), + (r.ss_metadata -> 'franchises'), + (r.flashpoint_metadata -> 'franchises'), + (r.gamelist_metadata -> 'franchises'), + '[]'::jsonb + ) AS franchises, + + COALESCE( + (r.manual_metadata -> 'collections'), + (r.igdb_metadata -> 'collections'), + '[]'::jsonb + ) AS collections, + + COALESCE( + (r.manual_metadata -> 'companies'), + (r.igdb_metadata -> 'companies'), + (r.ss_metadata -> 'companies'), + (r.ra_metadata -> 'companies'), + (r.launchbox_metadata -> 'companies'), + (r.flashpoint_metadata -> 'companies'), + (r.gamelist_metadata -> 'companies'), + '[]'::jsonb + ) AS companies, + + COALESCE( + (r.manual_metadata -> 'game_modes'), + (r.igdb_metadata -> 'game_modes'), + (r.ss_metadata -> 'game_modes'), + (r.flashpoint_metadata -> 'game_modes'), + '[]'::jsonb + ) AS game_modes, + + COALESCE( + (r.manual_metadata -> 'age_ratings'), + CASE + WHEN r.igdb_metadata IS NOT NULL + AND r.igdb_metadata ? 'age_ratings' + AND jsonb_array_length(r.igdb_metadata -> 'age_ratings') > 0 + THEN + jsonb_path_query_array(r.igdb_metadata, '$.age_ratings[*].rating') + ELSE + '[]'::jsonb + END, + CASE + WHEN r.launchbox_metadata IS NOT NULL + AND r.launchbox_metadata ? 'esrb' + AND r.launchbox_metadata ->> 'esrb' IS NOT NULL + AND r.launchbox_metadata ->> 'esrb' != '' + THEN + jsonb_build_array(r.launchbox_metadata ->> 'esrb') + ELSE + '[]'::jsonb + END, + '[]'::jsonb + ) AS age_ratings, + + CASE + WHEN r.manual_metadata IS NOT NULL AND r.manual_metadata ? 'first_release_date' AND + r.manual_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.manual_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.manual_metadata ->> 'first_release_date')::bigint + + WHEN r.igdb_metadata IS NOT NULL AND r.igdb_metadata ? 'first_release_date' AND + r.igdb_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.igdb_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.igdb_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.ss_metadata IS NOT NULL AND r.ss_metadata ? 'first_release_date' AND + r.ss_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.ss_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.ss_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.ra_metadata IS NOT NULL AND r.ra_metadata ? 'first_release_date' AND + r.ra_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.ra_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.ra_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.launchbox_metadata IS NOT NULL AND r.launchbox_metadata ? 'first_release_date' AND + r.launchbox_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.launchbox_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.launchbox_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.flashpoint_metadata IS NOT NULL AND r.flashpoint_metadata ? 'first_release_date' AND + r.flashpoint_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.flashpoint_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.flashpoint_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.gamelist_metadata IS NOT NULL + AND r.gamelist_metadata ? 'first_release_date' + AND r.gamelist_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') + AND r.gamelist_metadata ->> 'first_release_date' ~ '^[0-9]{8}T[0-9]{6}$' + THEN (extract(epoch FROM to_timestamp(r.gamelist_metadata ->> 'first_release_date', 'YYYYMMDD"T"HH24MISS')) * 1000)::bigint + + ELSE NULL + END AS first_release_date, + + CASE + WHEN manual_rating IS NOT NULL THEN manual_rating + WHEN (igdb_rating IS NOT NULL OR moby_rating IS NOT NULL OR ss_rating IS NOT NULL OR launchbox_rating IS NOT NULL OR gamelist_rating IS NOT NULL) THEN + (COALESCE(igdb_rating, 0) + COALESCE(moby_rating, 0) + COALESCE(ss_rating, 0) + COALESCE(launchbox_rating, 0) + COALESCE(gamelist_rating, 0)) / + (CASE WHEN igdb_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN moby_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN ss_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN launchbox_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN gamelist_rating IS NOT NULL THEN 1 ELSE 0 END) + ELSE NULL + END AS average_rating + FROM ( + SELECT + r.id, + r.manual_metadata, + r.igdb_metadata, + r.moby_metadata, + r.ss_metadata, + r.ra_metadata, + r.launchbox_metadata, + r.flashpoint_metadata, + r.gamelist_metadata, + CASE + WHEN r.manual_metadata IS NOT NULL AND r.manual_metadata ? 'average_rating' AND + r.manual_metadata ->> 'average_rating' NOT IN ('null', 'None', '0', '0.0') AND + r.manual_metadata ->> 'average_rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.manual_metadata ->> 'average_rating')::float + ELSE NULL + END AS manual_rating, + CASE + WHEN r.igdb_metadata IS NOT NULL AND r.igdb_metadata ? 'total_rating' AND + r.igdb_metadata ->> 'total_rating' NOT IN ('null', 'None', '0', '0.0') AND + r.igdb_metadata ->> 'total_rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.igdb_metadata ->> 'total_rating')::float + ELSE NULL + END AS igdb_rating, + CASE + WHEN r.moby_metadata IS NOT NULL AND r.moby_metadata ? 'moby_score' AND + r.moby_metadata ->> 'moby_score' NOT IN ('null', 'None', '0', '0.0') AND + r.moby_metadata ->> 'moby_score' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.moby_metadata ->> 'moby_score')::float * 10 + ELSE NULL + END AS moby_rating, + CASE + WHEN r.ss_metadata IS NOT NULL AND r.ss_metadata ? 'ss_score' AND + r.ss_metadata ->> 'ss_score' NOT IN ('null', 'None', '0', '0.0') AND + r.ss_metadata ->> 'ss_score' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.ss_metadata ->> 'ss_score')::float * 10 + ELSE NULL + END AS ss_rating, + CASE + WHEN r.launchbox_metadata IS NOT NULL AND r.launchbox_metadata ? 'community_rating' AND + r.launchbox_metadata ->> 'community_rating' NOT IN ('null', 'None', '0', '0.0') AND + r.launchbox_metadata ->> 'community_rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.launchbox_metadata ->> 'community_rating')::float * 20 + ELSE NULL + END AS launchbox_rating, + CASE + WHEN r.gamelist_metadata IS NOT NULL AND r.gamelist_metadata ? 'rating' AND + r.gamelist_metadata ->> 'rating' NOT IN ('null', 'None', '0', '0.0') AND + r.gamelist_metadata ->> 'rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.gamelist_metadata ->> 'rating')::float * 100 + ELSE NULL + END AS gamelist_rating + FROM roms r + ) AS r; + """ + ) + ) + else: + connection.execute( + sa.text( + """CREATE OR REPLACE VIEW roms_metadata AS + SELECT + r.id as rom_id, + NOW() AS created_at, + NOW() AS updated_at, + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.genres'), + JSON_EXTRACT(r.igdb_metadata, '$.genres'), + JSON_EXTRACT(r.moby_metadata, '$.genres'), + JSON_EXTRACT(r.ss_metadata, '$.genres'), + JSON_EXTRACT(r.launchbox_metadata, '$.genres'), + JSON_EXTRACT(r.ra_metadata, '$.genres'), + JSON_EXTRACT(r.flashpoint_metadata, '$.genres'), + JSON_EXTRACT(r.gamelist_metadata, '$.genres'), + JSON_ARRAY() + ) AS genres, + + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.franchises'), + JSON_EXTRACT(r.igdb_metadata, '$.franchises'), + JSON_EXTRACT(r.ss_metadata, '$.franchises'), + JSON_EXTRACT(r.flashpoint_metadata, '$.franchises'), + JSON_EXTRACT(r.gamelist_metadata, '$.franchises'), + JSON_ARRAY() + ) AS franchises, + + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.collections'), + JSON_EXTRACT(r.igdb_metadata, '$.collections'), + JSON_ARRAY() + ) AS collections, + + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.companies'), + JSON_EXTRACT(r.igdb_metadata, '$.companies'), + JSON_EXTRACT(r.ss_metadata, '$.companies'), + JSON_EXTRACT(r.ra_metadata, '$.companies'), + JSON_EXTRACT(r.launchbox_metadata, '$.companies'), + JSON_EXTRACT(r.flashpoint_metadata, '$.companies'), + JSON_EXTRACT(r.gamelist_metadata, '$.companies'), + JSON_ARRAY() + ) AS companies, + + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.game_modes'), + JSON_EXTRACT(r.igdb_metadata, '$.game_modes'), + JSON_EXTRACT(r.ss_metadata, '$.game_modes'), + JSON_EXTRACT(r.flashpoint_metadata, '$.game_modes'), + JSON_ARRAY() + ) AS game_modes, + + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.age_ratings'), + CASE + WHEN JSON_CONTAINS_PATH(r.igdb_metadata, 'one', '$.age_ratings') + AND JSON_LENGTH(JSON_EXTRACT(r.igdb_metadata, '$.age_ratings')) > 0 + THEN + JSON_EXTRACT(r.igdb_metadata, '$.age_ratings[*].rating') + ELSE + JSON_ARRAY() + END, + CASE + WHEN JSON_CONTAINS_PATH(r.launchbox_metadata, 'one', '$.esrb') + AND JSON_EXTRACT(r.launchbox_metadata, '$.esrb') IS NOT NULL + AND JSON_EXTRACT(r.launchbox_metadata, '$.esrb') != '' + THEN + JSON_ARRAY(JSON_EXTRACT(r.launchbox_metadata, '$.esrb')) + ELSE + JSON_ARRAY() + END, + JSON_ARRAY() + ) AS age_ratings, + + CASE + WHEN JSON_CONTAINS_PATH(r.manual_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.manual_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.manual_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.manual_metadata, '$.first_release_date') AS SIGNED) + + WHEN JSON_CONTAINS_PATH(r.igdb_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.ss_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ss_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ss_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.ss_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.ra_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ra_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ra_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.ra_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.launchbox_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.flashpoint_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.gamelist_metadata, 'one', '$.first_release_date') + AND JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') + AND JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')) REGEXP '^[0-9]{8}T[0-9]{6}$' + THEN + CAST( + UNIX_TIMESTAMP( + STR_TO_DATE( + JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')), + '%Y%m%dT%H%i%s' + ) + ) * 1000 AS SIGNED + ) + + ELSE NULL + END AS first_release_date, + + CASE + WHEN manual_rating IS NOT NULL THEN manual_rating + WHEN (igdb_rating IS NOT NULL OR moby_rating IS NOT NULL OR ss_rating IS NOT NULL OR launchbox_rating IS NOT NULL OR gamelist_rating IS NOT NULL) THEN + (COALESCE(igdb_rating, 0) + COALESCE(moby_rating, 0) + COALESCE(ss_rating, 0) + COALESCE(launchbox_rating, 0) + COALESCE(gamelist_rating, 0)) / + (CASE WHEN igdb_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN moby_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN ss_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN launchbox_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN gamelist_rating IS NOT NULL THEN 1 ELSE 0 END) + ELSE NULL + END AS average_rating + + FROM ( + SELECT + id, + manual_metadata, + igdb_metadata, + moby_metadata, + ss_metadata, + ra_metadata, + launchbox_metadata, + flashpoint_metadata, + gamelist_metadata, + CASE + WHEN JSON_CONTAINS_PATH(manual_metadata, 'one', '$.average_rating') AND + JSON_UNQUOTE(JSON_EXTRACT(manual_metadata, '$.average_rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(manual_metadata, '$.average_rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(manual_metadata, '$.average_rating') AS DECIMAL(10,2)) + ELSE NULL + END AS manual_rating, + CASE + WHEN JSON_CONTAINS_PATH(igdb_metadata, 'one', '$.total_rating') AND + JSON_UNQUOTE(JSON_EXTRACT(igdb_metadata, '$.total_rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(igdb_metadata, '$.total_rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(igdb_metadata, '$.total_rating') AS DECIMAL(10,2)) + ELSE NULL + END AS igdb_rating, + CASE + WHEN JSON_CONTAINS_PATH(moby_metadata, 'one', '$.moby_score') AND + JSON_UNQUOTE(JSON_EXTRACT(moby_metadata, '$.moby_score')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(moby_metadata, '$.moby_score')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(moby_metadata, '$.moby_score') AS DECIMAL(10,2)) * 10 + ELSE NULL + END AS moby_rating, + CASE + WHEN JSON_CONTAINS_PATH(ss_metadata, 'one', '$.ss_score') AND + JSON_UNQUOTE(JSON_EXTRACT(ss_metadata, '$.ss_score')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(ss_metadata, '$.ss_score')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(ss_metadata, '$.ss_score') AS DECIMAL(10,2)) * 10 + ELSE NULL + END AS ss_rating, + CASE + WHEN JSON_CONTAINS_PATH(launchbox_metadata, 'one', '$.community_rating') AND + JSON_UNQUOTE(JSON_EXTRACT(launchbox_metadata, '$.community_rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(launchbox_metadata, '$.community_rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(launchbox_metadata, '$.community_rating') AS DECIMAL(10,2)) * 20 + ELSE NULL + END AS launchbox_rating, + CASE + WHEN JSON_CONTAINS_PATH(gamelist_metadata, 'one', '$.rating') AND + JSON_UNQUOTE(JSON_EXTRACT(gamelist_metadata, '$.rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(gamelist_metadata, '$.rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(gamelist_metadata, '$.rating') AS DECIMAL(10,2)) * 100 + ELSE NULL + END AS gamelist_rating + FROM roms + ) AS r; + """ + ) + ) + + +def downgrade(): + connection = op.get_bind() + + if is_postgresql(connection): + connection.execute( + sa.text( + """ + CREATE OR REPLACE VIEW roms_metadata AS + SELECT + r.id AS rom_id, + NOW() AS created_at, + NOW() AS updated_at, + COALESCE( + (r.igdb_metadata -> 'genres'), + (r.moby_metadata -> 'genres'), + (r.ss_metadata -> 'genres'), + (r.launchbox_metadata -> 'genres'), + (r.ra_metadata -> 'genres'), + (r.flashpoint_metadata -> 'genres'), + (r.gamelist_metadata -> 'genres'), + '[]'::jsonb + ) AS genres, + + COALESCE( + (r.igdb_metadata -> 'franchises'), + (r.ss_metadata -> 'franchises'), + (r.flashpoint_metadata -> 'franchises'), + (r.gamelist_metadata -> 'franchises'), + '[]'::jsonb + ) AS franchises, + + COALESCE( + (r.igdb_metadata -> 'collections'), + '[]'::jsonb + ) AS collections, + + COALESCE( + (r.igdb_metadata -> 'companies'), + (r.ss_metadata -> 'companies'), + (r.ra_metadata -> 'companies'), + (r.launchbox_metadata -> 'companies'), + (r.flashpoint_metadata -> 'companies'), + (r.gamelist_metadata -> 'companies'), + '[]'::jsonb + ) AS companies, + + COALESCE( + (r.igdb_metadata -> 'game_modes'), + (r.ss_metadata -> 'game_modes'), + (r.flashpoint_metadata -> 'game_modes'), + '[]'::jsonb + ) AS game_modes, + + COALESCE( + CASE + WHEN r.igdb_metadata IS NOT NULL + AND r.igdb_metadata ? 'age_ratings' + AND jsonb_array_length(r.igdb_metadata -> 'age_ratings') > 0 + THEN + jsonb_path_query_array(r.igdb_metadata, '$.age_ratings[*].rating') + ELSE + '[]'::jsonb + END, + CASE + WHEN r.launchbox_metadata IS NOT NULL + AND r.launchbox_metadata ? 'esrb' + AND r.launchbox_metadata ->> 'esrb' IS NOT NULL + AND r.launchbox_metadata ->> 'esrb' != '' + THEN + jsonb_build_array(r.launchbox_metadata ->> 'esrb') + ELSE + '[]'::jsonb + END, + '[]'::jsonb + ) AS age_ratings, + + CASE + WHEN r.igdb_metadata IS NOT NULL AND r.igdb_metadata ? 'first_release_date' AND + r.igdb_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.igdb_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.igdb_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.ss_metadata IS NOT NULL AND r.ss_metadata ? 'first_release_date' AND + r.ss_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.ss_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.ss_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.ra_metadata IS NOT NULL AND r.ra_metadata ? 'first_release_date' AND + r.ra_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.ra_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.ra_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.launchbox_metadata IS NOT NULL AND r.launchbox_metadata ? 'first_release_date' AND + r.launchbox_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.launchbox_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.launchbox_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.flashpoint_metadata IS NOT NULL AND r.flashpoint_metadata ? 'first_release_date' AND + r.flashpoint_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.flashpoint_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.flashpoint_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.gamelist_metadata IS NOT NULL + AND r.gamelist_metadata ? 'first_release_date' + AND r.gamelist_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') + AND r.gamelist_metadata ->> 'first_release_date' ~ '^[0-9]{8}T[0-9]{6}$' + THEN (extract(epoch FROM to_timestamp(r.gamelist_metadata ->> 'first_release_date', 'YYYYMMDD"T"HH24MISS')) * 1000)::bigint + + ELSE NULL + END AS first_release_date, + + CASE + WHEN (igdb_rating IS NOT NULL OR moby_rating IS NOT NULL OR ss_rating IS NOT NULL OR launchbox_rating IS NOT NULL OR gamelist_rating IS NOT NULL) THEN + (COALESCE(igdb_rating, 0) + COALESCE(moby_rating, 0) + COALESCE(ss_rating, 0) + COALESCE(launchbox_rating, 0) + COALESCE(gamelist_rating, 0)) / + (CASE WHEN igdb_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN moby_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN ss_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN launchbox_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN gamelist_rating IS NOT NULL THEN 1 ELSE 0 END) + ELSE NULL + END AS average_rating + FROM ( + SELECT + r.id, + r.igdb_metadata, + r.moby_metadata, + r.ss_metadata, + r.ra_metadata, + r.launchbox_metadata, + r.flashpoint_metadata, + r.gamelist_metadata, + CASE + WHEN r.igdb_metadata IS NOT NULL AND r.igdb_metadata ? 'total_rating' AND + r.igdb_metadata ->> 'total_rating' NOT IN ('null', 'None', '0', '0.0') AND + r.igdb_metadata ->> 'total_rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.igdb_metadata ->> 'total_rating')::float + ELSE NULL + END AS igdb_rating, + CASE + WHEN r.moby_metadata IS NOT NULL AND r.moby_metadata ? 'moby_score' AND + r.moby_metadata ->> 'moby_score' NOT IN ('null', 'None', '0', '0.0') AND + r.moby_metadata ->> 'moby_score' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.moby_metadata ->> 'moby_score')::float * 10 + ELSE NULL + END AS moby_rating, + CASE + WHEN r.ss_metadata IS NOT NULL AND r.ss_metadata ? 'ss_score' AND + r.ss_metadata ->> 'ss_score' NOT IN ('null', 'None', '0', '0.0') AND + r.ss_metadata ->> 'ss_score' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.ss_metadata ->> 'ss_score')::float * 10 + ELSE NULL + END AS ss_rating, + CASE + WHEN r.launchbox_metadata IS NOT NULL AND r.launchbox_metadata ? 'community_rating' AND + r.launchbox_metadata ->> 'community_rating' NOT IN ('null', 'None', '0', '0.0') AND + r.launchbox_metadata ->> 'community_rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.launchbox_metadata ->> 'community_rating')::float * 20 + ELSE NULL + END AS launchbox_rating, + CASE + WHEN r.gamelist_metadata IS NOT NULL AND r.gamelist_metadata ? 'rating' AND + r.gamelist_metadata ->> 'rating' NOT IN ('null', 'None', '0', '0.0') AND + r.gamelist_metadata ->> 'rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.gamelist_metadata ->> 'rating')::float * 100 + ELSE NULL + END AS gamelist_rating + FROM roms r + ) AS r; + """ + ) + ) + else: + connection.execute( + sa.text( + """CREATE OR REPLACE VIEW roms_metadata AS + SELECT + r.id as rom_id, + NOW() AS created_at, + NOW() AS updated_at, + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.genres'), + JSON_EXTRACT(r.moby_metadata, '$.genres'), + JSON_EXTRACT(r.ss_metadata, '$.genres'), + JSON_EXTRACT(r.launchbox_metadata, '$.genres'), + JSON_EXTRACT(r.ra_metadata, '$.genres'), + JSON_EXTRACT(r.flashpoint_metadata, '$.genres'), + JSON_EXTRACT(r.gamelist_metadata, '$.genres'), + JSON_ARRAY() + ) AS genres, + + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.franchises'), + JSON_EXTRACT(r.ss_metadata, '$.franchises'), + JSON_EXTRACT(r.flashpoint_metadata, '$.franchises'), + JSON_EXTRACT(r.gamelist_metadata, '$.franchises'), + JSON_ARRAY() + ) AS franchises, + + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.collections'), + JSON_ARRAY() + ) AS collections, + + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.companies'), + JSON_EXTRACT(r.ss_metadata, '$.companies'), + JSON_EXTRACT(r.ra_metadata, '$.companies'), + JSON_EXTRACT(r.launchbox_metadata, '$.companies'), + JSON_EXTRACT(r.flashpoint_metadata, '$.companies'), + JSON_EXTRACT(r.gamelist_metadata, '$.companies'), + JSON_ARRAY() + ) AS companies, + + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.game_modes'), + JSON_EXTRACT(r.ss_metadata, '$.game_modes'), + JSON_EXTRACT(r.flashpoint_metadata, '$.game_modes'), + JSON_ARRAY() + ) AS game_modes, + + COALESCE( + CASE + WHEN JSON_CONTAINS_PATH(r.igdb_metadata, 'one', '$.age_ratings') + AND JSON_LENGTH(JSON_EXTRACT(r.igdb_metadata, '$.age_ratings')) > 0 + THEN + JSON_EXTRACT(r.igdb_metadata, '$.age_ratings[*].rating') + ELSE + JSON_ARRAY() + END, + CASE + WHEN JSON_CONTAINS_PATH(r.launchbox_metadata, 'one', '$.esrb') + AND JSON_EXTRACT(r.launchbox_metadata, '$.esrb') IS NOT NULL + AND JSON_EXTRACT(r.launchbox_metadata, '$.esrb') != '' + THEN + JSON_ARRAY(JSON_EXTRACT(r.launchbox_metadata, '$.esrb')) + ELSE + JSON_ARRAY() + END, + JSON_ARRAY() + ) AS age_ratings, + + CASE + WHEN JSON_CONTAINS_PATH(r.igdb_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.ss_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ss_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ss_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.ss_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.ra_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ra_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ra_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.ra_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.launchbox_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.flashpoint_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.gamelist_metadata, 'one', '$.first_release_date') + AND JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') + AND JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')) REGEXP '^[0-9]{8}T[0-9]{6}$' + THEN + CAST( + UNIX_TIMESTAMP( + STR_TO_DATE( + JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')), + '%Y%m%dT%H%i%s' + ) + ) * 1000 AS SIGNED + ) + + ELSE NULL + END AS first_release_date, + + CASE + WHEN (igdb_rating IS NOT NULL OR moby_rating IS NOT NULL OR ss_rating IS NOT NULL OR launchbox_rating IS NOT NULL OR gamelist_rating IS NOT NULL) THEN + (COALESCE(igdb_rating, 0) + COALESCE(moby_rating, 0) + COALESCE(ss_rating, 0) + COALESCE(launchbox_rating, 0) + COALESCE(gamelist_rating, 0)) / + (CASE WHEN igdb_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN moby_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN ss_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN launchbox_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN gamelist_rating IS NOT NULL THEN 1 ELSE 0 END) + ELSE NULL + END AS average_rating + + FROM ( + SELECT + id, + igdb_metadata, + moby_metadata, + ss_metadata, + ra_metadata, + launchbox_metadata, + flashpoint_metadata, + gamelist_metadata, + CASE + WHEN JSON_CONTAINS_PATH(igdb_metadata, 'one', '$.total_rating') AND + JSON_UNQUOTE(JSON_EXTRACT(igdb_metadata, '$.total_rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(igdb_metadata, '$.total_rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(igdb_metadata, '$.total_rating') AS DECIMAL(10,2)) + ELSE NULL + END AS igdb_rating, + CASE + WHEN JSON_CONTAINS_PATH(moby_metadata, 'one', '$.moby_score') AND + JSON_UNQUOTE(JSON_EXTRACT(moby_metadata, '$.moby_score')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(moby_metadata, '$.moby_score')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(moby_metadata, '$.moby_score') AS DECIMAL(10,2)) * 10 + ELSE NULL + END AS moby_rating, + CASE + WHEN JSON_CONTAINS_PATH(ss_metadata, 'one', '$.ss_score') AND + JSON_UNQUOTE(JSON_EXTRACT(ss_metadata, '$.ss_score')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(ss_metadata, '$.ss_score')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(ss_metadata, '$.ss_score') AS DECIMAL(10,2)) * 10 + ELSE NULL + END AS ss_rating, + CASE + WHEN JSON_CONTAINS_PATH(launchbox_metadata, 'one', '$.community_rating') AND + JSON_UNQUOTE(JSON_EXTRACT(launchbox_metadata, '$.community_rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(launchbox_metadata, '$.community_rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(launchbox_metadata, '$.community_rating') AS DECIMAL(10,2)) * 20 + ELSE NULL + END AS launchbox_rating, + CASE + WHEN JSON_CONTAINS_PATH(gamelist_metadata, 'one', '$.rating') AND + JSON_UNQUOTE(JSON_EXTRACT(gamelist_metadata, '$.rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(gamelist_metadata, '$.rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(gamelist_metadata, '$.rating') AS DECIMAL(10,2)) * 100 + ELSE NULL + END AS gamelist_rating + FROM roms + ) AS r; + """ + ) + ) + + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.drop_column("manual_metadata") diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 561728e12..1363a3e32 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -85,6 +85,21 @@ class Config: {k: NotRequired[v] for k, v in get_type_hints(GamelistMetadata).items()}, # type: ignore[misc] total=False, ) +ManualMetadata = TypedDict( + "ManualMetadata", + { + "genres": list[str] | None, + "franchises": list[str] | None, + "collections": list[str] | None, + "companies": list[str] | None, + "game_modes": list[str] | None, + "age_ratings": list[str] | None, + "first_release_date": int | None, + "average_rating": float | None, + "youtube_video_id": str | None, + }, + total=False, +) def rom_user_schema_factory() -> RomUserSchema: @@ -239,6 +254,7 @@ class RomSchema(BaseModel): flashpoint_metadata: RomFlashpointMetadata | None hltb_metadata: RomHLTBMetadata | None gamelist_metadata: RomGamelistMetadata | None + manual_metadata: ManualMetadata | None path_cover_small: str | None path_cover_large: str | None diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 0311ccc1b..a237be558 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -956,6 +956,7 @@ async def update_rom( "flashpoint_metadata": {}, "hltb_metadata": {}, "revision": "", + "gamelist_metadata": {}, }, ) @@ -1006,7 +1007,9 @@ async def update_rom( raw_hasheous_metadata = parse_raw_metadata(data, "raw_hasheous_metadata") raw_flashpoint_metadata = parse_raw_metadata(data, "raw_flashpoint_metadata") raw_hltb_metadata = parse_raw_metadata(data, "raw_hltb_metadata") - + raw_manual_metadata = parse_raw_metadata(data, "raw_manual_metadata") + if raw_manual_metadata is None: + raw_manual_metadata = parse_raw_metadata(data, "raw_metadatum") if cleaned_data["igdb_id"] and raw_igdb_metadata is not None: cleaned_data["igdb_metadata"] = raw_igdb_metadata if cleaned_data["moby_id"] and raw_moby_metadata is not None: @@ -1021,6 +1024,8 @@ async def update_rom( cleaned_data["flashpoint_metadata"] = raw_flashpoint_metadata if cleaned_data["hltb_id"] and raw_hltb_metadata is not None: cleaned_data["hltb_metadata"] = raw_hltb_metadata + if raw_manual_metadata is not None: + cleaned_data["manual_metadata"] = raw_manual_metadata # Fetch metadata from external sources if ( diff --git a/backend/models/rom.py b/backend/models/rom.py index 2dcdefd7b..f0337ab6b 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -210,6 +210,9 @@ class Rom(BaseModel): gamelist_metadata: Mapped[dict[str, Any] | None] = mapped_column( CustomJSON(), default=dict ) + manual_metadata: Mapped[dict[str, Any] | None] = mapped_column( + CustomJSON(), default=dict + ) path_cover_s: Mapped[str | None] = mapped_column(Text, default="") path_cover_l: Mapped[str | None] = mapped_column(Text, default="") diff --git a/frontend/src/components/Details/Info/GameInfo.vue b/frontend/src/components/Details/Info/GameInfo.vue index 3412c6044..ed5c2757f 100644 --- a/frontend/src/components/Details/Info/GameInfo.vue +++ b/frontend/src/components/Details/Info/GameInfo.vue @@ -115,6 +115,57 @@ const coverImageSource = computed(() => { } }); +const ageRatingBadges = computed(() => { + const ratings = props.rom.metadatum?.age_ratings || []; + const igdbRatings = (props.rom.igdb_metadata as any)?.age_ratings || []; + const igdbByRating = new Map( + igdbRatings.map((r: any) => [String(r?.rating || "").trim(), r]), + ); + const categorySlug: Record = { + ESRB: "esrb", + PEGI: "pegi", + CERO: "cero", + USK: "usk", + GRAC: "grac", + CLASS_IND: "class_ind", + ACB: "acb", + }; + const normalizeRatingCode = (rating: string) => + rating.toString().toLowerCase().replace("+", ""); + + return ratings.map((entry: any) => { + if (typeof entry === "object" && entry?.rating) { + return entry; + } + + const raw = String(entry); + if (raw.includes(":")) { + const [categoryRaw, ratingRaw] = raw.split(":"); + const category = categoryRaw?.trim() || ""; + const rating = ratingRaw?.trim() || ""; + const slug = categorySlug[category]; + + const rating_cover_url = + slug && rating + ? `https://www.igdb.com/icons/rating_icons/${slug}/${slug}_${normalizeRatingCode(rating)}.png` + : undefined; + + return { + rating, + category, + rating_cover_url, + }; + } + + const igdbMatch = igdbByRating.get(raw.trim()); + if (igdbMatch) { + return igdbMatch; + } + + return { rating: raw, category: "", rating_cover_url: undefined }; + }); +}); + function onFilterClick(filter: FilterType, value: string) { router.push({ name: "search", @@ -181,20 +232,15 @@ function onFilterClick(filter: FilterType, value: string) { -