diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index d1cfc842f..80ea2068c 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -73,6 +73,7 @@ class MetadataMediaType(enum.StrEnum): BEZEL = "bezel" BOX2D = "box2d" BOX2D_BACK = "box2d_back" + BOX2D_SIDE = "box2d_side" BOX3D = "box3d" MIXIMAGE = "miximage" MIXIMAGE_V2 = "miximage_v2" diff --git a/backend/handler/metadata/ss_handler.py b/backend/handler/metadata/ss_handler.py index 2047f08cf..1e9f4387c 100644 --- a/backend/handler/metadata/ss_handler.py +++ b/backend/handler/metadata/ss_handler.py @@ -183,6 +183,7 @@ class SSMetadataMedia(TypedDict): # Resources stored in filesystem bezel_path: str | None box2d_back_path: str | None + box2d_side_path: str | None box3d_path: str | None fanart_path: str | None miximage_path: str | None @@ -244,6 +245,7 @@ def extract_media_from_ss_game(rom: Rom, game: SSGame) -> SSMetadataMedia: video_normalized_url=None, bezel_path=None, box2d_back_path=None, + box2d_side_path=None, box3d_path=None, fanart_path=None, miximage_path=None, @@ -358,6 +360,10 @@ def extract_media_from_ss_game(rom: Rom, game: SSGame) -> SSMetadataMedia: ss_media["box2d_side_url"] = strip_sensitive_query_params( media["url"], SENSITIVE_KEYS ) + if MetadataMediaType.BOX2D_SIDE in preferred_media_types: + ss_media["box2d_side_path"] = ( + f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.BOX2D_SIDE)}/box2d_side.png" + ) elif media.get("type") == "steamgrid" and not ss_media["steamgrid_url"]: ss_media["steamgrid_url"] = strip_sensitive_query_params( media["url"], SENSITIVE_KEYS diff --git a/backend/tests/handler/metadata/test_ss_handler.py b/backend/tests/handler/metadata/test_ss_handler.py index f24f995e9..a816553da 100644 --- a/backend/tests/handler/metadata/test_ss_handler.py +++ b/backend/tests/handler/metadata/test_ss_handler.py @@ -208,6 +208,86 @@ class TestExtractMediaFromSsGame: assert result["box2d_url"] is not None assert "box-2D(us)" in result["box2d_url"] + def _make_game_with_box_faces(self) -> SSGame: + """A game exposing all three box faces: front, back and spine.""" + return cast( + SSGame, + { + "medias": [ + { + "type": "box-2D", + "parent": "jeu", + "region": "us", + "url": "https://screenscraper.example.com/box-2D", + "crc": "aabbccdd", + "md5": "deadbeef", + "sha1": "cafebabe", + "size": "12345", + "format": "png", + }, + { + "type": "box-2D-back", + "parent": "jeu", + "region": "us", + "url": "https://screenscraper.example.com/box-2D-back", + "crc": "11223344", + "md5": "feedface", + "sha1": "baadf00d", + "size": "23456", + "format": "png", + }, + { + "type": "box-2D-side", + "parent": "jeu", + "region": "us", + "url": "https://screenscraper.example.com/box-2D-side", + "crc": "55667788", + "md5": "0ddba11", + "sha1": "f00dcafe", + "size": "34567", + "format": "png", + }, + ] + }, + ) + + def test_box2d_side_path_set_when_in_config(self): + """When 'box2d_side' is in SCAN_MEDIA the spine is persisted locally.""" + config = _make_config(scan_media=["box2d", "box2d_back", "box2d_side"]) + rom = self._make_rom() + game = self._make_game_with_box_faces() + + with ( + patch("handler.metadata.ss_handler.cm.get_config", return_value=config), + patch( + "handler.metadata.ss_handler.fs_resource_handler.get_media_resources_path", + side_effect=lambda pid, rid, mt: f"roms/{pid}/{rid}/{mt.value}", + ), + ): + result = extract_media_from_ss_game(rom, game) + + assert result["box2d_side_url"] is not None + assert "box-2D-side" in result["box2d_side_url"] + assert result["box2d_side_path"] == "roms/1/100/box2d_side/box2d_side.png" + + def test_box2d_side_path_not_set_when_absent_from_config(self): + """Without 'box2d_side' in SCAN_MEDIA the spine URL is kept but not stored.""" + config = _make_config(scan_media=["box2d", "box2d_back"]) + rom = self._make_rom() + game = self._make_game_with_box_faces() + + with ( + patch("handler.metadata.ss_handler.cm.get_config", return_value=config), + patch( + "handler.metadata.ss_handler.fs_resource_handler.get_media_resources_path", + side_effect=lambda pid, rid, mt: f"roms/{pid}/{rid}/{mt.value}", + ), + ): + result = extract_media_from_ss_game(rom, game) + + assert result["box2d_side_url"] is not None + assert result["box2d_side_path"] is None + def _make_game_with_both_miximage_versions(self) -> SSGame: """A game that has both mixrbv1 and mixrbv2 (v1 listed first, matching SS API order).""" return cast( diff --git a/examples/config.example.yml b/examples/config.example.yml index 48a896813..8d539c0f0 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -133,6 +133,7 @@ # - video_normalized # Normalized video (smaller file size than video) # # Media used for batocera gamelist.xml export # - box2d_back # Back cover art +# - box2d_side # Box spine (enables the interactive 3D box on detail pages) # - logo # Transparent logo # # Other media assets (might be used in the future) # - marquee # Custom marquee diff --git a/frontend/src/__generated__/models/RomSSMetadata.ts b/frontend/src/__generated__/models/RomSSMetadata.ts index de54834e5..d0377f04b 100644 --- a/frontend/src/__generated__/models/RomSSMetadata.ts +++ b/frontend/src/__generated__/models/RomSSMetadata.ts @@ -24,6 +24,7 @@ export type RomSSMetadata = { video_normalized_url?: (string | null); bezel_path?: (string | null); box2d_back_path?: (string | null); + box2d_side_path?: (string | null); box3d_path?: (string | null); fanart_path?: (string | null); miximage_path?: (string | null); diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json index 85e837638..1f4a6bfc5 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -8,6 +8,7 @@ "age-rating": "Възрастова оценка", "all-styles": "Всички стилове", "backlogged": "В списъка", + "box3d-alt": "Въртяща се 3D кутия за {title}", "by": "от", "cant-copy-link": "Линкът не може да бъде копиран, копирай го ръчно", "collections": "Колекции", diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json index 69c0d8af6..03b8263dc 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -8,6 +8,7 @@ "age-rating": "Hodnocení věku", "all-styles": "Všechny styly", "backlogged": "Odloženo", + "box3d-alt": "Otočná 3D krabice pro {title}", "by": "od", "cant-copy-link": "Nelze zkopírovat odkaz do schránky, zkopírujte ručně", "collections": "Kolekce", diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json index a6c658e85..32cb1c630 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -8,6 +8,7 @@ "age-rating": "Altersfreigabe", "all-styles": "Alle Stile", "backlogged": "Vorgemerkt", + "box3d-alt": "Drehbares 3D-Cover von {title}", "by": "nach", "cant-copy-link": "Link kann nicht in Zwischenablage kopiert werden. Bitte manuell kopieren.", "collections": "Sammlungen", diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json index f5d56bfae..b8f431220 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -8,6 +8,7 @@ "age-rating": "Age rating", "all-styles": "All Styles", "backlogged": "Backlogged", + "box3d-alt": "Rotatable 3D box art for {title}", "by": "by", "cant-copy-link": "Can't copy link to clipboard, copy it manually", "collections": "Collections", diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index c1f33720c..f2532066c 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -8,6 +8,7 @@ "age-rating": "Age rating", "all-styles": "All Styles", "backlogged": "Backlogged", + "box3d-alt": "Rotatable 3D box art for {title}", "by": "by", "cant-copy-link": "Can't copy link to clipboard, copy it manually", "collections": "Collections", diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json index 13eafd443..cd5c79854 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -8,6 +8,7 @@ "age-rating": "Clasificación por edad", "all-styles": "Todos los Estilos", "backlogged": "Pendiente", + "box3d-alt": "Carátula 3D giratoria de {title}", "by": "por", "cant-copy-link": "No se pudo copiar el link al portapapeles, copialo manualmente", "collections": "Colecciones", diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json index 496c05ee4..795c525d6 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -8,6 +8,7 @@ "age-rating": "Classification par âge", "all-styles": "Tous les Styles", "backlogged": "En attente", + "box3d-alt": "Boîtier 3D pivotant de {title}", "by": "par", "cant-copy-link": "Impossible de copier le lien dans le presse-papiers, copiez-le manuellement", "collections": "Collections", diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json index cd1da0afd..d72339915 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -8,6 +8,7 @@ "age-rating": "Korhatár", "all-styles": "Minden stílus", "backlogged": "Függőben", + "box3d-alt": "Forgatható 3D doboz: {title}", "by": "általa", "cant-copy-link": "A linket nem lehet a vágólapra másolni, kézzel kell.", "collections": "Gyűjtemények", diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json index 984e539e5..39e2dfc17 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -8,6 +8,7 @@ "age-rating": "Classificazione età", "all-styles": "Tutti gli Stili", "backlogged": "In attesa", + "box3d-alt": "Confezione 3D ruotabile di {title}", "by": "di", "cant-copy-link": "Impossibile copiare il link negli appunti, copialo manualmente", "collections": "Collezioni", diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json index bc25497e6..3af098cc9 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -8,6 +8,7 @@ "age-rating": "レーティング", "all-styles": "全スタイル", "backlogged": "未処理", + "box3d-alt": "{title} の回転式3Dボックスアート", "by": "by", "cant-copy-link": "クリップボードへのコピー失敗 手動でコピーしてください", "collections": "コレクション", diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json index eede93702..f69330a07 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -8,6 +8,7 @@ "age-rating": "연령 제한", "all-styles": "모든 스타일", "backlogged": "나중에 플레이", + "box3d-alt": "{title}의 회전 가능한 3D 박스 아트", "by": "분류", "cant-copy-link": "링크를 클립보드에 복사할 수 없습니다. 수동으로 복사합니다", "collections": "시리즈", diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json index 2c65a9f39..35acce1ad 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -8,6 +8,7 @@ "age-rating": "Kategoria wiekowa", "all-styles": "Wszystkie Style", "backlogged": "Zaległe", + "box3d-alt": "Obracane pudełko 3D dla {title}", "by": "przez", "cant-copy-link": "Nie można skopiować linku do schowka, skopiuj go ręcznie", "collections": "Kolekcje", diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json index 99c11d60c..7277b3279 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -8,6 +8,7 @@ "age-rating": "Classificação etária", "all-styles": "Todos os Estilos", "backlogged": "Em espera", + "box3d-alt": "Caixa 3D giratória de {title}", "by": "por", "cant-copy-link": "Não é possível copiar o link para a área de transferência, copie manualmente", "collections": "Coleções", diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json index f83330134..b330533ae 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -8,6 +8,7 @@ "age-rating": "Clasificare pe vârstă", "all-styles": "Toate Stilurile", "backlogged": "În așteptare", + "box3d-alt": "Cutie 3D rotativă pentru {title}", "by": "de", "cant-copy-link": "Nu s-a putut copia linkul în clipboard, copiază-l manual", "collections": "Colecții", diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json index 96a70b652..87f10c697 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -8,6 +8,7 @@ "age-rating": "Возрастной рейтинг", "all-styles": "Все Стили", "backlogged": "Отложено", + "box3d-alt": "Вращающаяся 3D-коробка для {title}", "by": "от", "cant-copy-link": "Не удается скопировать ссылку в буфер обмена, скопируйте ее вручную", "collections": "Коллекции", diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json index b480d5468..e312b7628 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -8,6 +8,7 @@ "age-rating": "年龄分级", "all-styles": "所有风格", "backlogged": "待办", + "box3d-alt": "{title} 的可旋转 3D 包装盒", "by": "根据", "cant-copy-link": "无法将链接复制到剪贴板,请手动复制", "collections": "收藏", diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json index a085227e7..a39faedf5 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -8,6 +8,7 @@ "age-rating": "年龄分级", "all-styles": "所有風格", "backlogged": "待遊玩", + "box3d-alt": "{title} 的可旋轉 3D 包裝盒", "by": "依據", "cant-copy-link": "無法複製下載鏈接到剪貼簿,請手動複製", "collections": "收藏庫", diff --git a/frontend/src/v2/components/GameDetails/CoverColumn.vue b/frontend/src/v2/components/GameDetails/CoverColumn.vue index 0f3dc15ee..88328d85e 100644 --- a/frontend/src/v2/components/GameDetails/CoverColumn.vue +++ b/frontend/src/v2/components/GameDetails/CoverColumn.vue @@ -4,20 +4,55 @@ // the procedural placeholder when empty, and is the destination of the // shared-element morph from the GameCard the user clicked through from — // all of that lives in GameCover now; this just sizes the column. +// +// When the gallery boxart style is the 3D box AND the rom has the full set +// of flat scans (front + back + spine, from ScreenScraper), the hero +// upgrades to the interactive RBox3D the user can spin. Anything missing — +// a different style, an incomplete set, or a failed image — falls straight +// back to the flat GameCover. +import { computed, ref } from "vue"; +import { useI18n } from "vue-i18n"; +import { RBox3D } from "@v2/lib"; +import { useUISettings } from "@/composables/useUISettings"; import type { DetailedRom } from "@/stores/roms"; +import { useBoxFaces } from "@/v2/composables/useBoxFaces"; import GameCover from "@/v2/components/shared/GameCover.vue"; defineOptions({ inheritAttrs: false }); -defineProps<{ +const props = defineProps<{ rom: DetailedRom; alt: string; }>(); + +const { t } = useI18n(); +const { boxartStyle } = useUISettings(); +const faces = useBoxFaces(() => props.rom); +const box3dFailed = ref(false); + +// Resolved faces, or null when the interactive box can't / shouldn't render. +// Returning the concrete object keeps the template free of non-null asserts. +const box3d = computed(() => { + if (boxartStyle.value !== "box3d_path" || box3dFailed.value) return null; + const f = faces.value; + if (!f.complete || !f.front || !f.back || !f.spine) return null; + return { front: f.front, back: f.back, spine: f.spine }; +});