From f2d04a2069f300590bd2f3546d89dd5be4c9f053 Mon Sep 17 00:00:00 2001 From: zurdi Date: Mon, 29 Dec 2025 11:38:47 +0000 Subject: [PATCH 01/21] feat: Refactor Library Management settings and add Missing Games component - Removed unused imports and code from Excluded.vue and FolderMappings.vue. - Added MissingGames.vue component to handle missing ROMs display and management. - Updated localization files to include new strings for missing games functionality. - Improved search and filter handling in Excluded.vue. - Enhanced user experience by providing clearer feedback for missing ROMs. --- .../Config/Dialog/CreateExclusion.vue | 4 + .../LibraryManagement/Config/Excluded.vue | 34 + .../Config/FolderMappings.vue | 972 ++++++++++-------- .../LibraryManagement/Config/MissingGames.vue | 387 +++---- .../src/views/Settings/LibraryManagement.vue | 172 ++-- 5 files changed, 869 insertions(+), 700 deletions(-) diff --git a/frontend/src/components/Settings/LibraryManagement/Config/Dialog/CreateExclusion.vue b/frontend/src/components/Settings/LibraryManagement/Config/Dialog/CreateExclusion.vue index 61612db70..4797da5fe 100644 --- a/frontend/src/components/Settings/LibraryManagement/Config/Dialog/CreateExclusion.vue +++ b/frontend/src/components/Settings/LibraryManagement/Config/Dialog/CreateExclusion.vue @@ -163,7 +163,10 @@ function closeDialog() { {{ t("settings.add-exclusion-for") }} {{ exclusionTitle }}

>>>>>> b2f995ec7 (feat: Refactor Library Management settings and add Missing Games component) v-model="exclusionValue" :label="t('settings.exclusion-value')" :placeholder="t('settings.exclusion-placeholder')" @@ -189,6 +192,7 @@ function closeDialog() { {{ t("common.cancel") }} +======= + prepend-icon="mdi-plus" + variant="outlined" + class="text-primary" + @click="addExclusion()" + > + {{ t("common.add") }} + + + + + diff --git a/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue b/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue index 6dc35db29..1d27faf6a 100644 --- a/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue +++ b/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue @@ -29,227 +29,236 @@ const loading = ref(false); let timeout: ReturnType = setTimeout(() => {}, 400); const allPlatforms = computed(() => - [ - ...new Map( - filteredRoms.value - .map((rom) => platformsStore.get(rom.platform_id)) - .filter((platform) => !!platform) - .map((platform) => [platform!.id, platform]), - ).values(), - ].sort((a, b) => a!.name.localeCompare(b!.name)), + [ + ...new Map( + filteredRoms.value + .map((rom) => platformsStore.get(rom.platform_id)) + .filter((platform) => !!platform) + .map((platform) => [platform!.id, platform]), + ).values(), + ].sort((a, b) => a!.name.localeCompare(b!.name)), ); const onFilterChange = debounce( - () => { - romsStore.resetPagination(); - galleryFilterStore.setFilterMissing(true); - romsStore.fetchRoms({ - galleryFilter: galleryFilterStore, - platformsStore: platformsStore, - concat: false, - }); + () => { + romsStore.resetPagination(); + galleryFilterStore.setFilterMissing(true); + romsStore.fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + concat: false, + }); - const url = new URL(window.location.href); - // Update URL with filters - Object.entries({ - platform: selectedPlatform.value - ? String(selectedPlatform.value.id) - : null, - }).forEach(([key, value]) => { - if (value) { - url.searchParams.set(key, value); - } else { - url.searchParams.delete(key); - } - }); - galleryFilterStore.setFilterMissing(false); - }, - 500, - { leading: false, trailing: true }, + const url = new URL(window.location.href); + // Update URL with filters + Object.entries({ + platform: selectedPlatform.value + ? String(selectedPlatform.value.id) + : null, + }).forEach(([key, value]) => { + if (value) { + url.searchParams.set(key, value); + } else { + url.searchParams.delete(key); + } + }); + galleryFilterStore.setFilterMissing(false); + }, + 500, + { leading: false, trailing: true }, ); async function fetchRoms() { - if (fetchingRoms.value) return; + if (fetchingRoms.value) return; - loading.value = true; + loading.value = true; - galleryFilterStore.setFilterMissing(true); - romsStore - .fetchRoms({ - galleryFilter: galleryFilterStore, - platformsStore: platformsStore, - }) - .catch((error) => { - console.error("Error fetching missing games:", error); - emitter?.emit("snackbarShow", { - msg: t("settings.couldnt-fetch-missing-roms", { error }), - icon: "mdi-close-circle", - color: "red", - timeout: 4000, - }); - }) - .finally(() => { - galleryFilterStore.setFilterMissing(false); - loading.value = false; - }); + galleryFilterStore.setFilterMissing(true); + romsStore + .fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + }) + .catch((error) => { + console.error("Error fetching missing games:", error); + emitter?.emit("snackbarShow", { + msg: t("settings.couldnt-fetch-missing-roms", { error }), + icon: "mdi-close-circle", + color: "red", + timeout: 4000, + }); + }) + .finally(() => { + galleryFilterStore.setFilterMissing(false); + loading.value = false; + }); } function cleanupAll() { - romsStore.setLimit(10000); - galleryFilterStore.setFilterMissing(true); - romsStore - .fetchRoms({ - galleryFilter: galleryFilterStore, - platformsStore: platformsStore, - }) - .then(() => { - emitter?.emit("showLoadingDialog", { - loading: false, - scrim: false, - }); - if (filteredRoms.value.length > 0) { - emitter?.emit("showDeleteRomDialog", filteredRoms.value); - } else { - emitter?.emit("snackbarShow", { - msg: t("settings.no-missing-roms-to-delete"), - icon: "mdi-close-circle", - color: "red", - timeout: 4000, + romsStore.setLimit(10000); + galleryFilterStore.setFilterMissing(true); + romsStore + .fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + }) + .then(() => { + emitter?.emit("showLoadingDialog", { + loading: false, + scrim: false, + }); + if (filteredRoms.value.length > 0) { + emitter?.emit("showDeleteRomDialog", filteredRoms.value); + } else { + emitter?.emit("snackbarShow", { + msg: t("settings.no-missing-roms-to-delete"), + icon: "mdi-close-circle", + color: "red", + timeout: 4000, + }); + } + }) + .catch((error) => { + console.error("Error fetching missing games:", error); + emitter?.emit("snackbarShow", { + msg: t("settings.couldnt-fetch-missing-roms", { error }), + icon: "mdi-close-circle", + color: "red", + timeout: 4000, + }); + }) + .finally(() => { + galleryFilterStore.setFilterMissing(false); }); - } - }) - .catch((error) => { - console.error("Error fetching missing games:", error); - emitter?.emit("snackbarShow", { - msg: t("settings.couldnt-fetch-missing-roms", { error }), - icon: "mdi-close-circle", - color: "red", - timeout: 4000, - }); - }) - .finally(() => { - galleryFilterStore.setFilterMissing(false); - }); } function resetMissingRoms() { - romsStore.reset(); - galleryFilterStore.resetFilters(); + romsStore.reset(); + galleryFilterStore.resetFilters(); } const { y: windowY } = useScroll(window, { throttle: 500 }); watch(windowY, () => { - clearTimeout(timeout); + clearTimeout(timeout); - window.setTimeout(async () => { - scrolledToTop.value = windowY.value === 0; - if ( - windowY.value + window.innerHeight >= document.body.scrollHeight - 300 && - fetchTotalRoms.value > filteredRoms.value.length - ) { - await fetchRoms(); - } - }, 100); + window.setTimeout(async () => { + scrolledToTop.value = windowY.value === 0; + if ( + windowY.value + window.innerHeight >= + document.body.scrollHeight - 300 && + fetchTotalRoms.value > filteredRoms.value.length + ) { + await fetchRoms(); + } + }, 100); }); onMounted(() => { - resetMissingRoms(); - fetchRoms(); + resetMissingRoms(); + fetchRoms(); }); onUnmounted(() => { - resetMissingRoms(); + resetMissingRoms(); }); diff --git a/frontend/src/views/Settings/LibraryManagement.vue b/frontend/src/views/Settings/LibraryManagement.vue index 61288d07e..b0184ae1c 100644 --- a/frontend/src/views/Settings/LibraryManagement.vue +++ b/frontend/src/views/Settings/LibraryManagement.vue @@ -17,97 +17,109 @@ const validTabs = ["mapping", "excluded", "missing"] as const; // Initialize tab from query parameter or default to "config" const tab = ref<"mapping" | "excluded" | "missing">( - validTabs.includes(route.query.tab as any) - ? (route.query.tab as "mapping" | "excluded" | "missing") - : "mapping", + validTabs.includes(route.query.tab as any) + ? (route.query.tab as "mapping" | "excluded" | "missing") + : "mapping", ); const configStore = storeConfig(); const { config } = storeToRefs(configStore); // Watch for tab changes and update URL watch(tab, (newTab) => { - router.replace({ - path: route.path, - query: { - ...route.query, - tab: newTab, - }, - }); + router.replace({ + path: route.path, + query: { + ...route.query, + tab: newTab, + }, + }); }); // Watch for URL changes and update tab watch( - () => route.query.tab, - (newTab) => { - if (newTab && validTabs.includes(newTab as any) && tab.value !== newTab) { - tab.value = newTab as "mapping" | "excluded" | "missing"; - } - }, - { immediate: true }, + () => route.query.tab, + (newTab) => { + if ( + newTab && + validTabs.includes(newTab as any) && + tab.value !== newTab + ) { + tab.value = newTab as "mapping" | "excluded" | "missing"; + } + }, + { immediate: true }, ); From 163738ffa31ff81c2da81e472ca54a90c80e3323 Mon Sep 17 00:00:00 2001 From: zurdi Date: Tue, 23 Dec 2025 14:55:10 +0000 Subject: [PATCH 02/21] chore: update linter versions and adjust ignore paths in trunk.yaml --- .trunk/trunk.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index d90f0818a..45960dd88 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -48,6 +48,7 @@ lint: ignore: - linters: [ALL] paths: + - frontend/assets/patcherjs/** - frontend/src/__generated__/** - docker/Dockerfile - docker/nginx/js/** From a0462ed2b8127d0982af1ca6a2081a317b1d6269 Mon Sep 17 00:00:00 2001 From: zurdi Date: Tue, 23 Dec 2025 12:41:53 +0000 Subject: [PATCH 03/21] feat: enhance LanguageSelector component and update layout in Auth.vue --- .../UserInterface/LanguageSelector.vue | 10 ++- frontend/src/layouts/Auth.vue | 63 ++++++++++----- frontend/src/styles/common.css | 78 +++++++++---------- 3 files changed, 90 insertions(+), 61 deletions(-) diff --git a/frontend/src/components/Settings/UserInterface/LanguageSelector.vue b/frontend/src/components/Settings/UserInterface/LanguageSelector.vue index 92c8716b1..f837cd881 100644 --- a/frontend/src/components/Settings/UserInterface/LanguageSelector.vue +++ b/frontend/src/components/Settings/UserInterface/LanguageSelector.vue @@ -7,8 +7,16 @@ import storeLanguage from "@/stores/language"; const { locale } = useI18n(); const languageStore = storeLanguage(); const { languages, selectedLanguage } = storeToRefs(languageStore); +const localeStorage = useLocalStorage("settings.locale", ""); -const { locale: localeStorage } = useUISettings(); +withDefaults( + defineProps<{ + density: "comfortable" | "compact" | "default"; + }>(), + { + density: "default", + }, +); withDefaults( defineProps<{ diff --git a/frontend/src/layouts/Auth.vue b/frontend/src/layouts/Auth.vue index c8533607b..eec2a12c0 100644 --- a/frontend/src/layouts/Auth.vue +++ b/frontend/src/layouts/Auth.vue @@ -7,34 +7,55 @@ const heartbeatStore = storeHeartbeat(); diff --git a/frontend/src/styles/common.css b/frontend/src/styles/common.css index 1b2742514..88f19a891 100644 --- a/frontend/src/styles/common.css +++ b/frontend/src/styles/common.css @@ -1,88 +1,88 @@ html, body { - background-color: rgba(var(--v-theme-background)) !important; - margin: 0 !important; + background-color: rgba(var(--v-theme-background)) !important; + margin: 0 !important; } .main-layout { - z-index: 1010 !important; + z-index: 1010 !important; } .text-shadow { - text-shadow: - 1px 1px 3px #000000, - 0 0 3px #000000 !important; + text-shadow: + 1px 1px 3px #000000, + 0 0 3px #000000 !important; } .translucent { - background: rgba(0, 0, 0, 0.5) !important; - font-weight: 500 !important; + background: rgba(0, 0, 0, 0.5) !important; + font-weight: 500 !important; } -.translucent-light { - background: rgba(0, 0, 0, 0.2) !important; - font-weight: 500 !important; +.transparent { + background: transparent !important; + box-shadow: none !important; } .transparent { background: transparent !important; box-shadow: none !important; } .tooltip :deep(.v-overlay__content) { - background: rgba(255, 255, 255, 1) !important; - color: rgb(41, 41, 41) !important; + background: rgba(255, 255, 255, 1) !important; + color: rgb(41, 41, 41) !important; } .scroll { - overflow-x: visible !important; - overflow-y: auto !important; + overflow-x: visible !important; + overflow-y: auto !important; } .scroll-hidden { - overflow: hidden; + overflow: hidden; } .emoji-collection { - mask-image: linear-gradient( - to right, - black 0%, - black 70%, - transparent 100% - ) !important; + mask-image: linear-gradient( + to right, + black 0%, + black 70%, + transparent 100% + ) !important; } .emoji { - margin: 0 2px !important; + margin: 0 2px !important; } .file-input { - display: none !important; + display: none !important; } .transform-scale { - transition-property: all !important; - transition-duration: 0.1s !important; + transition-property: all !important; + transition-duration: 0.1s !important; } .transform-scale:hover, .transform-scale:focus { - transform: scale(1.07) !important; + transform: scale(1.07) !important; } .pointer { - cursor: pointer !important; + cursor: pointer !important; } .border-selected { - border: 1px solid rgba(var(--v-theme-primary)) !important; - transform: scale(1.04); + border: 1px solid rgba(var(--v-theme-primary)) !important; + transform: scale(1.04); } .greyscale { - filter: grayscale(100%); + filter: grayscale(100%); } .unset-height { - height: unset !important; + height: unset !important; } .drawer-mobile { - width: calc(100% - 16px) !important; + width: calc(100% - 16px) !important; } .bottom-0 { - bottom: 0 !important; + bottom: 0 !important; } .bottom-50 { - bottom: 50px !important; + bottom: 50px !important; } .max-h-50 { - height: auto !important; - max-height: 50dvh !important; + height: auto !important; + max-height: 50dvh !important; } .max-h-70 { - height: auto !important; - max-height: 70dvh !important; + height: auto !important; + max-height: 70dvh !important; } From b25acc714eb4a35c22b008911c488112816d86b5 Mon Sep 17 00:00:00 2001 From: SaraVieira Date: Thu, 8 Jan 2026 18:55:03 +0000 Subject: [PATCH 04/21] feat/walkthroughs --- backend/alembic/versions/0063_walkthroughs.py | 58 ++ .../versions/0064_walkthrough_uploads.py | 41 ++ backend/endpoints/responses/rom.py | 20 + backend/endpoints/walkthrough.py | 285 +++++++++ backend/handler/auth/base_handler.py | 2 +- backend/handler/database/__init__.py | 2 + backend/handler/database/roms_handler.py | 1 + .../handler/database/walkthroughs_handler.py | 54 ++ .../handler/filesystem/resources_handler.py | 28 + backend/handler/walkthrough_handler.py | 268 +++++++++ backend/main.py | 2 + backend/models/rom.py | 4 + backend/models/walkthrough.py | 30 + backend/pytest.ini | 8 +- backend/tests/endpoints/test_walkthroughs.py | 138 +++++ .../tests/handler/test_walkthrough_handler.py | 83 +++ frontend/src/__generated__/index.ts | 7 + .../__generated__/models/DetailedRomSchema.ts | 2 + .../__generated__/models/SimpleRomSchema.ts | 2 + .../models/StoredWalkthroughResponse.ts | 19 + .../models/WalkthroughCreateRequest.ts | 11 + .../__generated__/models/WalkthroughFormat.ts | 5 + .../models/WalkthroughRequest.ts | 11 + .../models/WalkthroughResponse.ts | 15 + .../__generated__/models/WalkthroughSchema.ts | 19 + .../__generated__/models/WalkthroughSource.ts | 5 + .../src/components/Details/Walkthroughs.vue | 279 +++++++++ .../components/common/Game/Dialog/EditRom.vue | 5 + .../Game/Dialog/EditRom/WalkthroughPanel.vue | 557 ++++++++++++++++++ frontend/src/services/api/rom.ts | 1 + frontend/src/services/api/walkthrough.ts | 83 +++ frontend/src/views/GameDetails.vue | 28 +- pyproject.toml | 1 + uv.lock | 24 + 34 files changed, 2090 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/0063_walkthroughs.py create mode 100644 backend/alembic/versions/0064_walkthrough_uploads.py create mode 100644 backend/endpoints/walkthrough.py create mode 100644 backend/handler/database/walkthroughs_handler.py create mode 100644 backend/handler/walkthrough_handler.py create mode 100644 backend/models/walkthrough.py create mode 100644 backend/tests/endpoints/test_walkthroughs.py create mode 100644 backend/tests/handler/test_walkthrough_handler.py create mode 100644 frontend/src/__generated__/models/StoredWalkthroughResponse.ts create mode 100644 frontend/src/__generated__/models/WalkthroughCreateRequest.ts create mode 100644 frontend/src/__generated__/models/WalkthroughFormat.ts create mode 100644 frontend/src/__generated__/models/WalkthroughRequest.ts create mode 100644 frontend/src/__generated__/models/WalkthroughResponse.ts create mode 100644 frontend/src/__generated__/models/WalkthroughSchema.ts create mode 100644 frontend/src/__generated__/models/WalkthroughSource.ts create mode 100644 frontend/src/components/Details/Walkthroughs.vue create mode 100644 frontend/src/components/common/Game/Dialog/EditRom/WalkthroughPanel.vue create mode 100644 frontend/src/services/api/walkthrough.ts diff --git a/backend/alembic/versions/0063_walkthroughs.py b/backend/alembic/versions/0063_walkthroughs.py new file mode 100644 index 000000000..85725da21 --- /dev/null +++ b/backend/alembic/versions/0063_walkthroughs.py @@ -0,0 +1,58 @@ +"""Add walkthroughs table + +Revision ID: 0063_walkthroughs +Revises: 0062_rom_file_category_enum +Create Date: 2026-01-04 18:40:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "0063_walkthroughs" +down_revision: Union[str, None] = "0062_rom_file_category_enum" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "walkthroughs", + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("rom_id", sa.Integer(), nullable=False), + sa.Column("url", sa.String(length=1000), nullable=False), + sa.Column("title", sa.String(length=500), nullable=True), + sa.Column("author", sa.String(length=250), nullable=True), + sa.Column( + "source", + sa.Enum("GAMEFAQS", "STEAM", name="walkthroughsource"), + nullable=False, + ), + sa.Column( + "format", + sa.Enum("html", "text", name="walkthroughformat"), + nullable=False, + ), + sa.Column( + "content", + sa.Text().with_variant(mysql.MEDIUMTEXT(), "mysql"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["rom_id"], + ["roms.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_walkthroughs_rom_id"), "walkthroughs", ["rom_id"]) + + +def downgrade() -> None: + op.drop_index(op.f("ix_walkthroughs_rom_id"), table_name="walkthroughs") + op.drop_table("walkthroughs") diff --git a/backend/alembic/versions/0064_walkthrough_uploads.py b/backend/alembic/versions/0064_walkthrough_uploads.py new file mode 100644 index 000000000..dc47981af --- /dev/null +++ b/backend/alembic/versions/0064_walkthrough_uploads.py @@ -0,0 +1,41 @@ +"""Add upload support for walkthroughs + +Revision ID: 0064_walkthrough_uploads +Revises: 0063_walkthroughs +Create Date: 2026-01-05 15:10:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0064_walkthrough_uploads" +down_revision: Union[str, None] = "0063_walkthroughs" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "walkthroughs", sa.Column("file_path", sa.String(length=1000), nullable=True) + ) + + # Expand enums to support uploads and PDFs + op.execute( + "ALTER TABLE walkthroughs MODIFY COLUMN source ENUM('GAMEFAQS','STEAM','UPLOAD') NOT NULL" + ) + op.execute( + "ALTER TABLE walkthroughs MODIFY COLUMN format ENUM('html','text','pdf') NOT NULL" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE walkthroughs MODIFY COLUMN source ENUM('GAMEFAQS','STEAM') NOT NULL" + ) + op.execute( + "ALTER TABLE walkthroughs MODIFY COLUMN format ENUM('html','text') NOT NULL" + ) + op.drop_column("walkthroughs", "file_path") diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 143551787..bc4a2c434 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -17,6 +17,7 @@ from handler.metadata.moby_handler import MobyMetadata from handler.metadata.ra_handler import RAMetadata from handler.metadata.ss_handler import SSMetadata +from handler.walkthrough_handler import WalkthroughFormat, WalkthroughSource from models.collection import Collection from models.rom import Rom, RomFileCategory, RomUserStatus @@ -100,6 +101,24 @@ class Config: ) +class WalkthroughSchema(BaseModel): + id: int + rom_id: int + url: str + title: str | None + author: str | None + source: WalkthroughSource + format: WalkthroughFormat + file_path: str | None + content: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + use_enum_values = True + + def rom_user_schema_factory() -> RomUserSchema: now = datetime.now(timezone.utc) return RomUserSchema( @@ -290,6 +309,7 @@ class RomSchema(BaseModel): rom_user: RomUserSchema merged_screenshots: list[str] merged_ra_metadata: RomRAMetadata | None + walkthroughs: list[WalkthroughSchema] class Config: from_attributes = True diff --git a/backend/endpoints/walkthrough.py b/backend/endpoints/walkthrough.py new file mode 100644 index 000000000..f79648a42 --- /dev/null +++ b/backend/endpoints/walkthrough.py @@ -0,0 +1,285 @@ +from datetime import datetime +from pathlib import Path + +from fastapi import HTTPException, Request, UploadFile, status +from pydantic import BaseModel, ConfigDict, Field + +from decorators.auth import protected_route +from handler.auth.constants import Scope +from handler.database import db_rom_handler, db_walkthrough_handler +from handler.filesystem import fs_resource_handler +from handler.walkthrough_handler import ( + ALLOWED_MIME_TYPES, + MAX_UPLOAD_BYTES, + InvalidWalkthroughURLError, + WalkthroughContentNotFound, + WalkthroughError, + WalkthroughFetchFailed, + WalkthroughFormat, + WalkthroughResult, + WalkthroughSource, + fetch_walkthrough, + sanitize_html_fragment, +) +from logger.logger import log +from models.walkthrough import Walkthrough +from utils.router import APIRouter + +router = APIRouter( + prefix="/walkthroughs", + tags=["walkthroughs"], +) + + +class WalkthroughRequest(BaseModel): + url: str = Field(..., description="Walkthrough URL from GameFAQs") + + model_config = ConfigDict(use_enum_values=True) + + +class WalkthroughResponse(BaseModel): + url: str + title: str | None = None + author: str | None = None + source: WalkthroughSource + format: WalkthroughFormat + file_path: str | None = None + content: str + + model_config = ConfigDict(use_enum_values=True) + + +class WalkthroughCreateRequest(BaseModel): + url: str = Field(..., description="Walkthrough URL from GameFAQs") + + +class StoredWalkthroughResponse(BaseModel): + id: int + rom_id: int + url: str + title: str | None + author: str | None + source: WalkthroughSource + format: WalkthroughFormat + file_path: str | None + content: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(use_enum_values=True, from_attributes=True) + + +@protected_route( + router.post, "/fetch", [Scope.ROMS_READ], status_code=status.HTTP_200_OK +) +async def get_walkthrough( + request: Request, # noqa: ARG001 - required for authentication decorator + payload: WalkthroughRequest, +) -> WalkthroughResponse: + try: + result: WalkthroughResult = await fetch_walkthrough(payload.url) + except WalkthroughFetchFailed as exc: + log.error("Walkthrough fetch failed", exc_info=True) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc) + ) from exc + except InvalidWalkthroughURLError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) + ) from exc + except WalkthroughContentNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + except WalkthroughError as exc: + log.error("Walkthrough fetch failed", exc_info=True) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc) + ) from exc + return WalkthroughResponse(**result) + + +def _detect_format_from_extension(filename: str) -> WalkthroughFormat: + ext = Path(filename).suffix.lower() + if ext == ".pdf": + return WalkthroughFormat.PDF + if ext in {".html", ".htm"}: + return WalkthroughFormat.HTML + if ext in {".txt", ".text", ".md"}: + return WalkthroughFormat.TEXT + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported walkthrough file type. Use PDF, HTML, or TXT.", + ) + + +@protected_route(router.get, "/roms/{rom_id}", [Scope.ROMS_READ]) +def list_walkthroughs_for_rom( + request: Request, # noqa: ARG001 - required for authentication decorator + rom_id: int, +) -> list[StoredWalkthroughResponse]: + rom = db_rom_handler.get_rom(rom_id) + if not rom: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Rom not found" + ) + walkthroughs = db_walkthrough_handler.get_walkthroughs_for_rom(rom_id) + return [StoredWalkthroughResponse.model_validate(wt) for wt in walkthroughs] + + +@protected_route( + router.post, + "/roms/{rom_id}", + [Scope.ROMS_WRITE], + status_code=status.HTTP_201_CREATED, +) +async def create_walkthrough_for_rom( + request: Request, # noqa: ARG001 - required for authentication decorator + rom_id: int, + payload: WalkthroughCreateRequest, +) -> StoredWalkthroughResponse: + rom = db_rom_handler.get_rom(rom_id) + if not rom: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Rom not found" + ) + try: + result: WalkthroughResult = await fetch_walkthrough(payload.url) + except WalkthroughFetchFailed as exc: + log.error("Walkthrough fetch failed", exc_info=True) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc) + ) from exc + except InvalidWalkthroughURLError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) + ) from exc + except WalkthroughContentNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + except WalkthroughError as exc: + log.error("Walkthrough fetch failed", exc_info=True) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc) + ) from exc + + walkthrough = Walkthrough( + rom_id=rom_id, + url=payload.url, + title=result.get("title"), + author=result.get("author"), + source=result["source"], + format=result["format"], + file_path=None, + content=result["content"], + ) + saved = db_walkthrough_handler.add_walkthrough(walkthrough) + return StoredWalkthroughResponse.model_validate(saved) + + +@protected_route( + router.post, + "/roms/{rom_id}/upload", + [Scope.ROMS_WRITE], + status_code=status.HTTP_201_CREATED, +) +async def upload_walkthrough_for_rom( + request: Request, # noqa: ARG001 - required for authentication decorator + rom_id: int, + file: UploadFile, + title: str | None = None, + author: str | None = None, +) -> StoredWalkthroughResponse: + rom = db_rom_handler.get_rom(rom_id) + if not rom: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Rom not found" + ) + + filename = file.filename or "walkthrough" + fmt = _detect_format_from_extension(filename) + + raw_bytes = await file.read() + if not raw_bytes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Empty walkthrough file" + ) + if len(raw_bytes) > MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Walkthrough file is too large (max 15MB)", + ) + + if file.content_type: + content_type = file.content_type.split(";")[0].strip().lower() + if content_type not in ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported file type. Use PDF, HTML, or TXT.", + ) + + content = "" + if fmt == WalkthroughFormat.PDF: + content = "" + elif fmt == WalkthroughFormat.HTML: + content = sanitize_html_fragment( + raw_bytes.decode("utf-8", errors="ignore") + ).strip() + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unable to parse HTML walkthrough content", + ) + else: + content = raw_bytes.decode("utf-8", errors="ignore").strip() + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Walkthrough text file is empty", + ) + + walkthrough = Walkthrough( + rom_id=rom_id, + url=filename, + title=title or Path(filename).stem, + author=author, + source=WalkthroughSource.UPLOAD, + format=fmt, + content=content, + file_path=None, + ) + saved = db_walkthrough_handler.add_walkthrough(walkthrough) + + if fmt == WalkthroughFormat.PDF: + stored_path = await fs_resource_handler.store_walkthrough_file( + rom=rom, + walkthrough_id=saved.id, + data=raw_bytes, + extension="pdf", + ) + saved.file_path = stored_path + saved = db_walkthrough_handler.add_walkthrough(saved) + + return StoredWalkthroughResponse.model_validate(saved) + + +@protected_route(router.delete, "/{walkthrough_id}", [Scope.ROMS_WRITE]) +def delete_walkthrough( + request: Request, # noqa: ARG001 - required for authentication decorator + walkthrough_id: int, +) -> dict[str, bool]: + walkthrough = db_walkthrough_handler.get_walkthrough(walkthrough_id) + if not walkthrough: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Walkthrough not found" + ) + + if ( + walkthrough.source == WalkthroughSource.UPLOAD + and walkthrough.file_path is not None + ): + fs_resource_handler.remove_walkthrough_file_sync(walkthrough.file_path) + + db_walkthrough_handler.delete_walkthrough(walkthrough_id) + return {"success": True} diff --git a/backend/handler/auth/base_handler.py b/backend/handler/auth/base_handler.py index e774b1352..cecfa5a39 100644 --- a/backend/handler/auth/base_handler.py +++ b/backend/handler/auth/base_handler.py @@ -19,7 +19,6 @@ ROMM_AUTH_SECRET_KEY, ROMM_BASE_URL, ) -from decorators.auth import oauth from exceptions.auth_exceptions import OAuthCredentialsException, UserDisabledException from handler.auth.constants import ALGORITHM, DEFAULT_OAUTH_TOKEN_EXPIRY, TokenPurpose from handler.auth.middleware.redis_session_middleware import RedisSessionMiddleware @@ -288,6 +287,7 @@ async def get_current_active_user_from_bearer_token(self, token: str): class OpenIDHandler: async def get_current_active_user_from_openid_token(self, token: Any): + from decorators.auth import oauth from handler.database import db_user_handler from models.user import Role, User diff --git a/backend/handler/database/__init__.py b/backend/handler/database/__init__.py index 65816f359..2884aa186 100644 --- a/backend/handler/database/__init__.py +++ b/backend/handler/database/__init__.py @@ -7,6 +7,7 @@ from .states_handler import DBStatesHandler from .stats_handler import DBStatsHandler from .users_handler import DBUsersHandler +from .walkthroughs_handler import DBWalkthroughsHandler db_firmware_handler = DBFirmwareHandler() db_platform_handler = DBPlatformsHandler() @@ -17,3 +18,4 @@ db_stats_handler = DBStatsHandler() db_user_handler = DBUsersHandler() db_collection_handler = DBCollectionsHandler() +db_walkthrough_handler = DBWalkthroughsHandler() diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 6b1320d84..d2bac6e5e 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -137,6 +137,7 @@ def wrapper(*args, **kwargs): selectinload(Rom.files).options( joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name) ), + selectinload(Rom.walkthroughs), selectinload(Rom.sibling_roms).options( noload(Rom.platform), noload(Rom.metadatum) ), diff --git a/backend/handler/database/walkthroughs_handler.py b/backend/handler/database/walkthroughs_handler.py new file mode 100644 index 000000000..32b3d2287 --- /dev/null +++ b/backend/handler/database/walkthroughs_handler.py @@ -0,0 +1,54 @@ +from typing import Sequence + +from sqlalchemy import select + +from decorators.database import begin_session +from handler.database.base_handler import DBBaseHandler +from models.walkthrough import Walkthrough + + +class DBWalkthroughsHandler(DBBaseHandler): + @begin_session + def add_walkthrough( + self, + walkthrough: Walkthrough, + *, + session=None, # type: ignore + ) -> Walkthrough: + walkthrough = session.merge(walkthrough) + session.flush() + return walkthrough + + @begin_session + def get_walkthroughs_for_rom( + self, + rom_id: int, + *, + session=None, # type: ignore + ) -> Sequence[Walkthrough]: + return session.scalars( + select(Walkthrough).where(Walkthrough.rom_id == rom_id) + ).all() + + @begin_session + def delete_walkthrough( + self, + walkthrough_id: int, + *, + session=None, # type: ignore + ) -> bool: + walkthrough = session.get(Walkthrough, walkthrough_id) + if not walkthrough: + return False + session.delete(walkthrough) + session.flush() + return True + + @begin_session + def get_walkthrough( + self, + walkthrough_id: int, + *, + session=None, # type: ignore + ) -> Walkthrough | None: + return session.get(Walkthrough, walkthrough_id) diff --git a/backend/handler/filesystem/resources_handler.py b/backend/handler/filesystem/resources_handler.py index 3a8b016e3..64748235d 100644 --- a/backend/handler/filesystem/resources_handler.py +++ b/backend/handler/filesystem/resources_handler.py @@ -510,3 +510,31 @@ async def remove_media_resources_path( await self.remove_directory( self.get_media_resources_path(platform_id, rom_id, media_type) ) + + # Walkthrough uploads + async def store_walkthrough_file( + self, rom: Rom, walkthrough_id: int, data: bytes, extension: str + ) -> str: + base_path = f"{rom.fs_resources_path}/walkthroughs" + await self.make_directory(base_path) + + filename = f"{walkthrough_id}.{extension}" + async with await self.write_file_streamed( + path=base_path, filename=filename + ) as f: + await f.write(data) + + stored = self.validate_path(f"{base_path}/{filename}") + return str(stored.relative_to(self.base_path)) + + async def remove_walkthrough_file(self, file_path: str) -> None: + full_path = self.validate_path(file_path) + lock = await self._get_file_lock(str(full_path)) + async with lock: + if full_path.exists(): + full_path.unlink(missing_ok=True) + + def remove_walkthrough_file_sync(self, file_path: str) -> None: + full_path = self.validate_path(file_path) + if full_path.exists(): + full_path.unlink(missing_ok=True) diff --git a/backend/handler/walkthrough_handler.py b/backend/handler/walkthrough_handler.py new file mode 100644 index 000000000..63101fd27 --- /dev/null +++ b/backend/handler/walkthrough_handler.py @@ -0,0 +1,268 @@ +import enum +import re +from typing import Final, TypedDict + +import httpx +from bs4 import BeautifulSoup, Tag + +from logger.logger import log +from utils.context import ctx_httpx_client + + +class WalkthroughSource(enum.StrEnum): + GAMEFAQS = "GAMEFAQS" + STEAM = "STEAM" + UPLOAD = "UPLOAD" + + +class WalkthroughFormat(enum.StrEnum): + HTML = "html" + TEXT = "text" + PDF = "pdf" + + +class WalkthroughResult(TypedDict): + url: str + title: str | None + author: str | None + source: WalkthroughSource + format: WalkthroughFormat + content: str + + +class WalkthroughError(Exception): + """Base exception for walkthrough errors.""" + + +class WalkthroughFetchFailed(WalkthroughError): + """Raised when the walkthrough fetch fails.""" + + +class InvalidWalkthroughURLError(WalkthroughError): + """Raised when the provided URL does not match a supported source.""" + + +class WalkthroughContentNotFound(WalkthroughError): + """Raised when the walkthrough content cannot be extracted.""" + + +GAMEFAQS_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^https://gamefaqs\.gamespot\.com/[^/]+/[^/]+/faqs/.+$" +) + +ALLOWED_ATTRIBUTES: Final[set[str]] = {"href", "src", "alt", "title"} +ALLOWED_TAGS: Final[set[str]] = { + "a", + "b", + "blockquote", + "br", + "code", + "div", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "i", + "img", + "li", + "ol", + "p", + "pre", + "span", + "strong", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "ul", +} +ALLOWED_SCHEMES: Final[set[str]] = {"http", "https"} +UNWANTED_SELECTORS: Final[tuple[str, ...]] = (".ach-panel", ".lazyYT", ".disclaimer") +DEFAULT_HEADERS: Final[dict[str, str]] = { + "User-Agent": "RomM/0.0.1 (+https://romm.app)", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.8", +} +MAX_UPLOAD_BYTES: Final[int] = 15 * 1024 * 1024 # 15 MB +ALLOWED_MIME_TYPES: Final[set[str]] = { + "text/plain", + "text/html", + "application/pdf", + "application/xhtml+xml", +} + + +def is_gamefaqs_url(url: str) -> bool: + return bool(GAMEFAQS_PATTERN.match(url)) + + +def match_url(url: str) -> WalkthroughSource | None: + if is_gamefaqs_url(url): + return WalkthroughSource.GAMEFAQS + return None + + +def _strip_unwanted_attributes(node: Tag) -> None: + for tag in node.find_all(True): + for attr in list(tag.attrs): + if attr not in ALLOWED_ATTRIBUTES: + tag.attrs.pop(attr, None) + + +def _remove_unwanted_elements(node: Tag) -> None: + for selector in UNWANTED_SELECTORS: + for match in node.select(selector): + match.decompose() + + for tag in node.find_all(["script", "style", "noscript"]): + tag.decompose() + + +def _remove_empty_elements(node: Tag) -> None: + for tag_name in ("div", "span", "p"): + for tag in list(node.find_all(tag_name)): + if not tag.get_text(strip=True) and not tag.find("img"): + tag.decompose() + + +def _is_allowed_url(value: str) -> bool: + value = value.strip() + if not value: + return False + if value.startswith("//"): + return True + match = re.match(r"^(?P[a-zA-Z][a-zA-Z0-9+.-]*):", value) + if not match: + return True + return match.group("scheme").lower() in ALLOWED_SCHEMES + + +def _remove_disallowed_tags(node: Tag) -> None: + for tag in list(node.find_all(True)): + if tag.name not in ALLOWED_TAGS: + tag.decompose() + continue + + if tag.name == "a": + href = tag.get("href") + if href and not _is_allowed_url(href): + tag.attrs.pop("href", None) + if tag.name == "img": + src = tag.get("src") + if src and not _is_allowed_url(src): + tag.decompose() + + +def _sanitize_node(node: Tag) -> None: + _remove_unwanted_elements(node) + _remove_empty_elements(node) + _remove_disallowed_tags(node) + _strip_unwanted_attributes(node) + + +def _serialize_html(nodes: list[Tag]) -> str: + """Serialize cleaned HTML fragments into a single string.""" + return "\n\n".join(node.decode(formatter="html") for node in nodes).strip() + + +def sanitize_html_fragment(html: str) -> str: + """Clean arbitrary HTML input to avoid XSS.""" + soup = BeautifulSoup(html, "html.parser") + _sanitize_node(soup) + return _serialize_html([soup]) + + +def _build_client(client: httpx.AsyncClient | None) -> tuple[httpx.AsyncClient, bool]: + """Return an httpx client and whether it should be closed by the caller.""" + if client: + return client, False + + try: + ctx_client = ctx_httpx_client.get() + except LookupError: + ctx_client = None + + if ctx_client: + return ctx_client, False + + return httpx.AsyncClient(), True + + +async def _fetch_html(url: str, client: httpx.AsyncClient | None = None) -> str: + http_client, should_close = _build_client(client) + try: + response = await http_client.get(url, timeout=30, headers=DEFAULT_HEADERS) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + log.error("Failed to fetch walkthrough", exc_info=True) + raise WalkthroughFetchFailed( + f"Failed to fetch walkthrough content (status {exc.response.status_code})" + ) from exc + except httpx.HTTPError as exc: + log.error("Failed to fetch walkthrough", exc_info=True) + raise WalkthroughFetchFailed("Failed to fetch walkthrough content") from exc + finally: + if should_close: + await http_client.aclose() + + return response.text + + +def _parse_gamefaqs( + html: str, fmt: WalkthroughFormat +) -> tuple[str | None, str | None, str]: + soup = BeautifulSoup(html, "html.parser") + pre_tags = soup.select("#faqtext pre") + if not pre_tags: + raise WalkthroughContentNotFound("Guide content not found") + + title = None + author = None + + title_tag = soup.select_one("h2.title, h2.text") + if title_tag: + title = title_tag.get_text(strip=True) + + author_tag = soup.select_one(".contrib1") + if author_tag: + author = author_tag.get_text(strip=True) + + if fmt == WalkthroughFormat.HTML: + serialized = [] + for pre in pre_tags: + clean_pre = soup.new_tag("pre") + clean_pre.string = pre.get_text() + serialized.append(str(clean_pre)) + return title, author, "\n\n".join(serialized).strip() + + return title, author, "\n\n".join(pre.get_text() for pre in pre_tags).strip() + + +async def fetch_walkthrough( + url: str, + client: httpx.AsyncClient | None = None, +) -> WalkthroughResult: + source = match_url(url) + if not source: + raise InvalidWalkthroughURLError( + "URL is not a supported walkthrough source (GameFAQs only)" + ) + + html = await _fetch_html(url, client) + + title, author, content = _parse_gamefaqs(html, WalkthroughFormat.TEXT) + fmt = WalkthroughFormat.TEXT + + return WalkthroughResult( + url=url, + title=title, + author=author, + source=source, + format=fmt, + content=content, + ) diff --git a/backend/main.py b/backend/main.py index 1ea55e139..96b56df5c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -43,6 +43,7 @@ stats, tasks, user, + walkthrough, ) from handler.auth.hybrid_auth import HybridAuthBackend from handler.auth.middleware.csrf_middleware import CSRFMiddleware @@ -137,6 +138,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.include_router(collections.router, prefix="/api") app.include_router(gamelist.router, prefix="/api") app.include_router(netplay.router, prefix="/api") +app.include_router(walkthrough.router, prefix="/api") app.mount("/ws", socket_handler.socket_app) app.mount("/netplay", netplay_socket_handler.socket_app) diff --git a/backend/models/rom.py b/backend/models/rom.py index 7fe40aedd..76e608103 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -34,6 +34,7 @@ from models.collection import Collection from models.platform import Platform from models.user import User + from models.walkthrough import Walkthrough class RomFileCategory(enum.StrEnum): @@ -263,6 +264,9 @@ class Rom(BaseModel): lazy="raise", back_populates="rom" ) rom_users: Mapped[list[RomUser]] = relationship(lazy="raise", back_populates="rom") + walkthroughs: Mapped[list["Walkthrough"]] = relationship( + "Walkthrough", lazy="raise", back_populates="rom", cascade="all, delete-orphan" + ) notes: Mapped[list[RomNote]] = relationship(lazy="raise", back_populates="rom") metadatum: Mapped[RomMetadata] = relationship( lazy="joined", back_populates="rom", uselist=False diff --git a/backend/models/walkthrough.py b/backend/models/walkthrough.py new file mode 100644 index 000000000..f7037bd3d --- /dev/null +++ b/backend/models/walkthrough.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from sqlalchemy import Enum, ForeignKey, Integer, String, Text +from sqlalchemy.dialects import mysql +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from handler.walkthrough_handler import WalkthroughFormat, WalkthroughSource +from models.base import BaseModel + + +class Walkthrough(BaseModel): + __tablename__ = "walkthroughs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + rom_id: Mapped[int] = mapped_column(ForeignKey("roms.id", ondelete="CASCADE")) + url: Mapped[str] = mapped_column(String(length=1000)) + title: Mapped[str | None] = mapped_column(String(length=500), default=None) + author: Mapped[str | None] = mapped_column(String(length=250), default=None) + source: Mapped[WalkthroughSource] = mapped_column( + Enum(WalkthroughSource, values_callable=lambda e: [item.value for item in e]) + ) + format: Mapped[WalkthroughFormat] = mapped_column( + Enum(WalkthroughFormat, values_callable=lambda e: [item.value for item in e]) + ) + file_path: Mapped[str | None] = mapped_column(String(length=1000), default=None) + content: Mapped[str] = mapped_column( + Text().with_variant(mysql.MEDIUMTEXT(), "mysql") + ) + + rom: Mapped["Rom"] = relationship(back_populates="walkthroughs") diff --git a/backend/pytest.ini b/backend/pytest.ini index 98b3089fb..1398b4252 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -3,10 +3,10 @@ asyncio_mode = auto testpaths = tests env = ROMM_BASE_PATH=romm_test - DB_HOST=127.0.0.1 - DB_NAME=romm_test - DB_USER=romm_test - DB_PASSWD=passwd + D:DB_HOST=127.0.0.1 + D:DB_NAME=romm_test + D:DB_USER=romm_test + D:DB_PASSWD=passwd ROMM_DB_DRIVER=mariadb IGDB_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx IGDB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/backend/tests/endpoints/test_walkthroughs.py b/backend/tests/endpoints/test_walkthroughs.py new file mode 100644 index 000000000..dff6dc976 --- /dev/null +++ b/backend/tests/endpoints/test_walkthroughs.py @@ -0,0 +1,138 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from main import app + +from handler.walkthrough_handler import ( + InvalidWalkthroughURLError, + WalkthroughContentNotFound, + WalkthroughFormat, + WalkthroughSource, +) + + +@pytest.fixture +def client(): + with TestClient(app) as client: + yield client + + +def test_walkthrough_requires_auth(client): + response = client.post( + "/api/walkthroughs/fetch", + json={ + "url": "https://gamefaqs.gamespot.com/snes/guide/faqs/1", + "format": "html", + }, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_walkthrough_success(client, access_token): + fake_result = { + "url": "https://gamefaqs.gamespot.com/snes/guide/faqs/1", + "title": "Guide title", + "author": "Guide author", + "source": WalkthroughSource.GAMEFAQS, + "format": WalkthroughFormat.HTML, + "content": "
content
", + } + + with patch( + "endpoints.walkthrough.fetch_walkthrough", + AsyncMock(return_value=fake_result), + ): + response = client.post( + "/api/walkthroughs/fetch", + headers={"Authorization": f"Bearer {access_token}"}, + json={"url": fake_result["url"], "format": "html"}, + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["title"] == fake_result["title"] + assert payload["author"] == fake_result["author"] + assert payload["source"] == WalkthroughSource.GAMEFAQS + assert payload["format"] == WalkthroughFormat.HTML + assert payload["content"] == fake_result["content"] + + +def test_walkthrough_invalid_url(client, access_token): + with patch( + "endpoints.walkthrough.fetch_walkthrough", + AsyncMock(side_effect=InvalidWalkthroughURLError("bad url")), + ): + response = client.post( + "/api/walkthroughs/fetch", + headers={"Authorization": f"Bearer {access_token}"}, + json={"url": "https://example.com", "format": "html"}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_walkthrough_not_found(client, access_token): + with patch( + "endpoints.walkthrough.fetch_walkthrough", + AsyncMock(side_effect=WalkthroughContentNotFound("missing content")), + ): + response = client.post( + "/api/walkthroughs/fetch", + headers={"Authorization": f"Bearer {access_token}"}, + json={ + "url": "https://gamefaqs.gamespot.com/snes/563504-secret-of-mana/faqs/55474", + "format": "text", + }, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_create_walkthrough_for_rom(client, access_token, rom): + fake_result = { + "url": "https://gamefaqs.gamespot.com/snes/guide/faqs/1", + "title": "Guide title", + "author": "Guide author", + "source": WalkthroughSource.GAMEFAQS, + "format": WalkthroughFormat.HTML, + "content": "
content
", + } + with patch( + "endpoints.walkthrough.fetch_walkthrough", + AsyncMock(return_value=fake_result), + ): + response = client.post( + f"/api/walkthroughs/roms/{rom.id}", + headers={"Authorization": f"Bearer {access_token}"}, + json={"url": fake_result["url"]}, + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["rom_id"] == rom.id + assert data["url"] == fake_result["url"] + assert data["title"] == fake_result["title"] + assert data["author"] == fake_result["author"] + assert data["source"] == WalkthroughSource.GAMEFAQS + + # Fetch list + list_response = client.get( + f"/api/walkthroughs/roms/{rom.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert list_response.status_code == status.HTTP_200_OK + items = list_response.json() + assert len(items) == 1 + assert items[0]["id"] == data["id"] + assert items[0]["title"] == fake_result["title"] + assert items[0]["author"] == fake_result["author"] + + # Delete + delete_response = client.delete( + f"/api/walkthroughs/{data['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert delete_response.status_code == status.HTTP_200_OK diff --git a/backend/tests/handler/test_walkthrough_handler.py b/backend/tests/handler/test_walkthrough_handler.py new file mode 100644 index 000000000..c5a9d347f --- /dev/null +++ b/backend/tests/handler/test_walkthrough_handler.py @@ -0,0 +1,83 @@ +from contextlib import asynccontextmanager + +import httpx +import pytest + +from handler.walkthrough_handler import ( + InvalidWalkthroughURLError, + WalkthroughContentNotFound, + WalkthroughFormat, + WalkthroughSource, + fetch_walkthrough, + is_gamefaqs_url, +) +from utils.context import ctx_httpx_client + + +@asynccontextmanager +async def mock_http_client(responses: dict[str, str]): + async def handler(request: httpx.Request) -> httpx.Response: + body = responses.get(str(request.url)) + if body is None: + return httpx.Response(status_code=404) + return httpx.Response(status_code=200, text=body) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + token = ctx_httpx_client.set(client) + try: + yield + finally: + ctx_httpx_client.reset(token) + await client.aclose() + + +def test_url_matchers(): + assert is_gamefaqs_url( + "https://gamefaqs.gamespot.com/snes/563504-secret-of-mana/faqs/55474" + ) + assert not is_gamefaqs_url("https://example.com/guide") + assert not is_gamefaqs_url( + "https://steamcommunity.com/sharedfiles/filedetails/?id=3579263600" + ) + + +@pytest.mark.asyncio +async def test_fetch_gamefaqs_html_format(): + url = "https://gamefaqs.gamespot.com/snes/563504-secret-of-mana/faqs/55474" + html = """ +

Secret of Mana Walkthrough

+
Jane Doe
+
First section
Second section
+ """ + + async with mock_http_client({url: html}): + result = await fetch_walkthrough(url) + + assert result["source"] == WalkthroughSource.GAMEFAQS + assert result["format"] == WalkthroughFormat.TEXT + assert result["title"] == "Secret of Mana Walkthrough" + assert result["author"] == "Jane Doe" + assert "First section" in result["content"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "https://example.com/guide", + "https://steamcommunity.com/sharedfiles/filedetails/?id=3579263600", + ], +) +async def test_invalid_url_raises(url: str): + with pytest.raises(InvalidWalkthroughURLError): + await fetch_walkthrough(url) + + +@pytest.mark.asyncio +async def test_missing_content_raises(): + url = "https://gamefaqs.gamespot.com/snes/563504-secret-of-mana/faqs/55474" + html = "
" + + async with mock_http_client({url: html}): + with pytest.raises(WalkthroughContentNotFound): + await fetch_walkthrough(url) diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index baabd29be..03d3505fa 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -89,6 +89,7 @@ export type { SimpleRomSchema } from './models/SimpleRomSchema'; export type { SmartCollectionSchema } from './models/SmartCollectionSchema'; export type { StateSchema } from './models/StateSchema'; export type { StatsReturn } from './models/StatsReturn'; +export type { StoredWalkthroughResponse } from './models/StoredWalkthroughResponse'; export type { SystemDict } from './models/SystemDict'; export type { TaskExecutionResponse } from './models/TaskExecutionResponse'; export type { TaskInfo } from './models/TaskInfo'; @@ -106,6 +107,12 @@ export type { UserNoteSchema } from './models/UserNoteSchema'; export type { UserSchema } from './models/UserSchema'; export type { ValidationError } from './models/ValidationError'; export type { VirtualCollectionSchema } from './models/VirtualCollectionSchema'; +export type { WalkthroughCreateRequest } from './models/WalkthroughCreateRequest'; +export type { WalkthroughFormat } from './models/WalkthroughFormat'; +export type { WalkthroughRequest } from './models/WalkthroughRequest'; +export type { WalkthroughResponse } from './models/WalkthroughResponse'; +export type { WalkthroughSchema } from './models/WalkthroughSchema'; +export type { WalkthroughSource } from './models/WalkthroughSource'; export type { WatcherTaskMeta } from './models/WatcherTaskMeta'; export type { WatcherTaskStatusResponse } from './models/WatcherTaskStatusResponse'; export type { WebrcadeFeedCategorySchema } from './models/WebrcadeFeedCategorySchema'; diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index 3e6166011..75fa8f7ff 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -21,6 +21,7 @@ import type { SiblingRomSchema } from './SiblingRomSchema'; import type { StateSchema } from './StateSchema'; import type { UserCollectionSchema } from './UserCollectionSchema'; import type { UserNoteSchema } from './UserNoteSchema'; +import type { WalkthroughSchema } from './WalkthroughSchema'; export type DetailedRomSchema = { id: number; igdb_id: (number | null); @@ -89,6 +90,7 @@ export type DetailedRomSchema = { rom_user: RomUserSchema; merged_screenshots: Array; merged_ra_metadata: (RomRAMetadata | null); + walkthroughs: Array; user_saves: Array; user_states: Array; user_screenshots: Array; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index 146b7805e..654b29d32 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -16,6 +16,7 @@ import type { RomRAMetadata } from './RomRAMetadata'; import type { RomSSMetadata } from './RomSSMetadata'; import type { RomUserSchema } from './RomUserSchema'; import type { SiblingRomSchema } from './SiblingRomSchema'; +import type { WalkthroughSchema } from './WalkthroughSchema'; export type SimpleRomSchema = { id: number; igdb_id: (number | null); @@ -84,5 +85,6 @@ export type SimpleRomSchema = { rom_user: RomUserSchema; merged_screenshots: Array; merged_ra_metadata: (RomRAMetadata | null); + walkthroughs: Array; }; diff --git a/frontend/src/__generated__/models/StoredWalkthroughResponse.ts b/frontend/src/__generated__/models/StoredWalkthroughResponse.ts new file mode 100644 index 000000000..ed88156a4 --- /dev/null +++ b/frontend/src/__generated__/models/StoredWalkthroughResponse.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { WalkthroughFormat } from './WalkthroughFormat'; +import type { WalkthroughSource } from './WalkthroughSource'; +export type StoredWalkthroughResponse = { + id: number; + rom_id: number; + url: string; + title: (string | null); + author: (string | null); + source: WalkthroughSource; + format: WalkthroughFormat; + content: string; + created_at: string; + updated_at: string; +}; + diff --git a/frontend/src/__generated__/models/WalkthroughCreateRequest.ts b/frontend/src/__generated__/models/WalkthroughCreateRequest.ts new file mode 100644 index 000000000..5e906f0f7 --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughCreateRequest.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WalkthroughCreateRequest = { + /** + * Walkthrough URL from GameFAQs or Steam + */ + url: string; +}; + diff --git a/frontend/src/__generated__/models/WalkthroughFormat.ts b/frontend/src/__generated__/models/WalkthroughFormat.ts new file mode 100644 index 000000000..cc17a3bf0 --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughFormat.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WalkthroughFormat = 'html' | 'text'; diff --git a/frontend/src/__generated__/models/WalkthroughRequest.ts b/frontend/src/__generated__/models/WalkthroughRequest.ts new file mode 100644 index 000000000..c33a16cfd --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughRequest.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WalkthroughRequest = { + /** + * Walkthrough URL from GameFAQs or Steam + */ + url: string; +}; + diff --git a/frontend/src/__generated__/models/WalkthroughResponse.ts b/frontend/src/__generated__/models/WalkthroughResponse.ts new file mode 100644 index 000000000..8e81ca9d0 --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughResponse.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { WalkthroughFormat } from './WalkthroughFormat'; +import type { WalkthroughSource } from './WalkthroughSource'; +export type WalkthroughResponse = { + url: string; + title?: (string | null); + author?: (string | null); + source: WalkthroughSource; + format: WalkthroughFormat; + content: string; +}; + diff --git a/frontend/src/__generated__/models/WalkthroughSchema.ts b/frontend/src/__generated__/models/WalkthroughSchema.ts new file mode 100644 index 000000000..f7f8aa7ae --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughSchema.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { WalkthroughFormat } from './WalkthroughFormat'; +import type { WalkthroughSource } from './WalkthroughSource'; +export type WalkthroughSchema = { + id: number; + rom_id: number; + url: string; + title: (string | null); + author: (string | null); + source: WalkthroughSource; + format: WalkthroughFormat; + content: string; + created_at: string; + updated_at: string; +}; + diff --git a/frontend/src/__generated__/models/WalkthroughSource.ts b/frontend/src/__generated__/models/WalkthroughSource.ts new file mode 100644 index 000000000..ff3fcd854 --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughSource.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WalkthroughSource = 'GAMEFAQS' | 'STEAM'; diff --git a/frontend/src/components/Details/Walkthroughs.vue b/frontend/src/components/Details/Walkthroughs.vue new file mode 100644 index 000000000..bdc633bbd --- /dev/null +++ b/frontend/src/components/Details/Walkthroughs.vue @@ -0,0 +1,279 @@ + + +