mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
Merge branch 'master' into claude/server-side-rom-patching-EaQck
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: backend-development
|
||||
description: Working on the RomM Python backend (backend/) — a FastAPI app with SQLAlchemy 2.0, Alembic, RQ/Redis, and Socket.IO. Use when adding or changing API endpoints, handlers, ORM models, response schemas, metadata-provider adapters, background tasks, database migrations, or backend tests. Covers the layered architecture, conventions, auth/scopes, the OpenAPI→frontend type pipeline, and the uv/pytest/alembic/trunk workflow. Trigger on any work under backend/.
|
||||
---
|
||||
|
||||
# RomM Backend — FastAPI / SQLAlchemy
|
||||
|
||||
Python 3.13+, FastAPI, SQLAlchemy 2.0 (MariaDB default; MySQL/PostgreSQL supported), Alembic, Redis + RQ for jobs/cache/sessions, Socket.IO for real-time. Managed with **uv**.
|
||||
|
||||
Full reference: **`docs/BACKEND_ARCHITECTURE.md`** (directory map, ER diagram, every endpoint, auth flows). Read it before non-trivial changes.
|
||||
|
||||
---
|
||||
|
||||
## Layered architecture — where code goes
|
||||
|
||||
```txt
|
||||
endpoints/ FastAPI routers: request validation, response schemas, @protected_route scopes
|
||||
endpoints/responses/ Pydantic response schemas (these shape the OpenAPI → frontend types)
|
||||
endpoints/sockets/ Socket.IO event handlers
|
||||
handler/ Business logic, decoupled from HTTP
|
||||
├ auth/ HybridAuthBackend (session/basic/bearer/OIDC/client-token), scopes, CSRF/session middleware
|
||||
├ database/ Per-entity CRUD handlers (db_rom_handler, db_user_handler, …), engine/session factory
|
||||
├ metadata/ One handler per provider; normalizes + ranks by priority
|
||||
└ filesystem/ ROM/asset/firmware file I/O, hashing, archive extraction
|
||||
adapters/services/ Typed external API clients (igdb.py + igdb_types.py, screenscraper.py, …)
|
||||
models/ SQLAlchemy ORM models (BaseModel adds created_at/updated_at)
|
||||
tasks/ RQ jobs — scheduled/ (cron) and manual/ (on-demand); base classes in tasks.py
|
||||
config/ Env-var loading (__init__.py) + YAML config manager (singleton)
|
||||
decorators/ @begin_session (DB session), @protected_route (auth + scopes)
|
||||
exceptions/ Custom exception hierarchy
|
||||
utils/ logger/ Shared helpers, structured logging
|
||||
alembic/ Migrations (env.py + versions/)
|
||||
```
|
||||
|
||||
**Endpoint → handler → (database | metadata | filesystem) → models/adapters.** Endpoints stay thin: validate, enforce scopes, call handlers, serialize via a response schema. Don't put business logic or raw queries in endpoints.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Naming:** Classes `PascalCase`; functions/vars `snake_case`; constants `UPPER_SNAKE_CASE`; private `_prefixed`.
|
||||
- **DB sessions:** decorate handler methods with `@begin_session`; it injects and manages the SQLAlchemy session/transaction. Don't open sessions ad hoc.
|
||||
- **Async:** I/O-bound endpoints and tasks use `async/await`. Per-request `httpx`/`aiohttp` clients come from context vars (`utils/context.py`), not new clients per call.
|
||||
- **Imports:** stdlib → third-party → local; explicit (no wildcards); `TYPE_CHECKING` blocks to break circular imports.
|
||||
- **Errors:** raise the custom exceptions in `exceptions/` (e.g. `RomNotFoundInDatabaseException`), not bare `HTTPException`, where a typed one exists.
|
||||
- **Validation/SSRF:** sanitize filenames/paths before filesystem use (`utils/`); paths are rooted at `LIBRARY_BASE_PATH`/`RESOURCES_BASE_PATH`/`ASSETS_BASE_PATH` from config.
|
||||
|
||||
## Auth & scopes
|
||||
|
||||
- Roles: `VIEWER` (read), `EDITOR` (+write roms/platforms/assets), `ADMIN` (+users/tasks/logs). Defined on `models/user.py`; scope tiers in `handler/auth/constants.py`.
|
||||
- Granular scopes: `me.read/write`, `roms.read/write`, `platforms.*`, `assets.*`, `devices.*`, `firmware.*`, `collections.*`, `users.*`, `tasks.run`, `logs.read`.
|
||||
- Protect routes with `@protected_route(router.<method>, "<path>", [Scope.X])`. The frontend mirrors these scopes — keep them aligned.
|
||||
|
||||
## Adding things
|
||||
|
||||
- **Endpoint:** add the route in the right `endpoints/*` router, a response schema in `endpoints/responses/`, enforce scopes, delegate to a handler. If the response shape changes, the frontend must regenerate types (below).
|
||||
- **Model / schema change:** edit `models/`, then create a migration (below). Update the matching response schema so OpenAPI stays accurate.
|
||||
- **Metadata provider:** add a typed client in `adapters/services/<name>.py` (+ `<name>_types.py`) and a `handler/metadata/<name>_handler.py` that normalizes into the common shape and slots into the priority order.
|
||||
- **Background job:** subclass `Task`/`PeriodicTask` in `tasks/scheduled/` or `tasks/manual/`; register scheduled jobs in `startup.py`.
|
||||
|
||||
## Database migrations (Alembic)
|
||||
|
||||
Migrations must work on **MariaDB, MySQL, and PostgreSQL** (CI runs `alembic upgrade head` on Postgres and MariaDB — `.github/workflows/migrations.yml`). Use batch mode / DB-specific SQL where needed; mirror existing migrations in `alembic/versions/`.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run alembic revision --autogenerate -m "short description" # generate, then HAND-REVIEW the file
|
||||
uv run alembic upgrade head # apply
|
||||
uv run alembic downgrade -1 # verify the downgrade works
|
||||
```
|
||||
|
||||
Always review autogenerated migrations — they miss server-default/enum/index nuances and cross-dialect differences. The `virtual_collections` DB view is excluded from migrations.
|
||||
|
||||
## OpenAPI → frontend types
|
||||
|
||||
FastAPI serves the schema at `GET /openapi.json`. The frontend regenerates its TypeScript types from it:
|
||||
|
||||
```bash
|
||||
# backend running on :3000, then in frontend/
|
||||
npm run generate # writes src/__generated__/ via openapi-typescript-codegen
|
||||
```
|
||||
|
||||
**Any change to a response schema or route signature should be followed by `npm run generate` + a frontend typecheck.**
|
||||
|
||||
## Run, test, lint
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run python3 main.py # run (migrations auto-apply on startup)
|
||||
uv run pytest [path/file] # tests (subset by path); uv run pytest -vv for all
|
||||
```
|
||||
|
||||
- Tests: pytest + pytest-asyncio, isolated per `pytest-xdist` worker (per-worker DBs); `fakeredis`; `pytest-recording` VCR cassettes mock external APIs; Hypothesis for property tests. Mirror the `backend/<area>/` layout under `backend/tests/`. First-time test DB setup: `docker exec -i romm-db-dev mariadb -uroot -p<pw> < backend/romm_test/setup.sql`.
|
||||
- **Lint / format / type-check run through Trunk** (ruff, black, isort, mypy, bandit): `trunk fmt && trunk check`. CI enforces Trunk on every PR. Never bypass with `--no-verify`.
|
||||
- New/changed logic needs a test; new endpoints need endpoint tests.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: frontend-i18n
|
||||
description: Internationalization for the RomM frontend (both v1 and v2). Use whenever adding, renaming, or removing any user-visible string / translation key under frontend/src/locales/. Covers the en_US-is-source rule, the requirement to add every new key to ALL locale directories in the same change, namespace layout, and the check_i18n_locales.py validator enforced in CI. Trigger on any change to frontend/src/locales/**.
|
||||
---
|
||||
|
||||
# RomM Frontend — i18n / Localization
|
||||
|
||||
User-visible strings are **never hard-coded** in components — they come from locale files via `vue-i18n` (`$t(...)` in templates/composites; utils may call `i18n.global.t(...)`; **v2 lib primitives must not call `$t` at all** — text passes via props/slots).
|
||||
|
||||
## Structure
|
||||
|
||||
- Locales live in `frontend/src/locales/<locale>/<namespace>.json`, loaded by dynamic glob import in `src/locales/index.ts`.
|
||||
- **18 locales**: `en_US` (default + fallback), `en_GB`, `bg_BG`, `cs_CZ`, `de_DE`, `es_ES`, `fr_FR`, `hu_HU`, `it_IT`, `ja_JP`, `ko_KR`, `pl_PL`, `pt_BR`, `ro_RO`, `ru_RU`, `zh_CN`, `zh_TW`.
|
||||
- Namespaces are per-feature files (e.g. `collection`, `common`, `console`, `detail`, `emulator`, `gallery`, `home`, `library`, `login`, `navigation`, `patcher`, `platform`, `scan`, `settings`, `task`).
|
||||
|
||||
## The rule (enforced in CI)
|
||||
|
||||
- **`en_US` is the source of truth**, but **every key added to `en_US` must be added to all other locale directories in the same change.** Never leave a key English-only.
|
||||
- Translate where you can; otherwise copy the English value as a placeholder so the key exists.
|
||||
- Removing or renaming a key means doing it across **every** locale.
|
||||
|
||||
## Verify before handoff
|
||||
|
||||
```bash
|
||||
python3 frontend/src/locales/check_i18n_locales.py
|
||||
```
|
||||
|
||||
It compares every non-English locale against `en_US` and **fails on any missing file, missing key, or extra key**. CI runs the same script (`.github/workflows/i18n.yml`) on any change under `frontend/src/locales/**`. It must pass with zero missing/extra keys.
|
||||
|
||||
## Adding a new language
|
||||
|
||||
Create a new folder under `frontend/src/locales/` mirroring `en_US/`'s files, then translate. Open the PR against `master` (see the docs/Contributing flow). This is the one i18n change where a new locale directory is expected.
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
name: frontend-v2-components
|
||||
description: Building or modifying components in the RomM v2 frontend (frontend/src/v2/). Use when creating/editing v2 primitives (R* components in src/v2/lib/), shared composites, or feature composites — covers the three-tier model, file/folder conventions, SFC structure, import order, barrels, Storybook requirements, and v2 anti-patterns. Trigger on any work under frontend/src/v2/.
|
||||
---
|
||||
|
||||
# RomM Frontend v2 — Component Constitution
|
||||
|
||||
This governs work inside `frontend/src/v2/`. **v1 is frozen** (`src/views/`, `src/components/`, `src/console/`, `src/layouts/`) — never refactor it; it will be deleted wholesale in a final wave. v2 is gated by `user.ui_settings.uiVersion`.
|
||||
|
||||
> Official language for all code, comments, identifiers, `.md`, and commit/PR messages: **English**.
|
||||
|
||||
Related skills: `frontend-v2-theming` (tokens/colors), `frontend-v2-input` (focus/gamepad/responsive), `frontend-v2-patterns` (errors/loading/forms/permissions/confirmations), `frontend-i18n`, `pre-pr-verification`.
|
||||
|
||||
---
|
||||
|
||||
## Premises (stable)
|
||||
|
||||
1. **v1 is frozen.** Don't touch `src/views/`, `src/components/`, `src/console/`, `src/layouts/`. When coexistence forces a v2 fork of a store/composable/util, annotate the v1 export with `@deprecated` pointing at the v2 replacement.
|
||||
2. **Three component tiers** (below).
|
||||
3. **Shared resources are canonical.** Pinia stores, API services, OpenAPI types (`src/__generated__/`), locales, utils — v2 _imports_ them, never forks them. Additive changes to shared resources are allowed; changing a shared store API to work around a v2 call-site issue is not.
|
||||
4. **TypeScript strict.** Zero `any` (justify with a comment if unavoidable). No `as unknown as ...`; fix the source or define an intermediate type.
|
||||
5. **Universal substitution.** When an `R*` primitive exists, use it. If it doesn't, create or extend it. Never drop to raw HTML or raw Vuetify when a primitive applies.
|
||||
6. **Wrapper contract.** Wrappers around Vuetify use `defineOptions({ inheritAttrs: false })` + `v-bind="$attrs"` + slot passthrough, and accept every prop/slot of the wrapped component.
|
||||
7. **Layout via Vuetify utility classes + our wrappers, not plain CSS.** Use `d-flex`, `pa-4`, `align-center` directly. Vuetify layout components (`v-row`, `v-col`, `v-container`, `v-spacer`, `v-app`, `v-main`) get wrapped as `R*` on first use (lazy).
|
||||
8. **Accessibility & performance** are requirements: semantic HTML, focus management, contrast, ARIA on icon-only controls; lazy-load heavy views, virtualize large lists, stable `:key` on every `v-for`.
|
||||
|
||||
---
|
||||
|
||||
## The three tiers
|
||||
|
||||
| Tier | Path | Prefix | Stores/services/router/emitter/i18n | Story | Domain knowledge |
|
||||
| --------------------- | ------------------------------ | -------------- | ----------------------------------- | --------- | --------------------------------- |
|
||||
| **Primitive** | `src/v2/lib/` | `R*` mandatory | **No** | Mandatory | None |
|
||||
| **Shared composite** | `src/v2/components/shared/` | no prefix | Yes | Optional | Cross-feature, no specific domain |
|
||||
| **Feature composite** | `src/v2/components/<feature>/` | no prefix | Yes | Optional | Feature-specific |
|
||||
|
||||
### A component is a primitive only if all three hold
|
||||
|
||||
1. Does not depend on stores, services, router, or emitter.
|
||||
2. No knowledge of a product domain (ROM, Platform, Collection, User…). `RAvatar` yes, `UserAvatar` no.
|
||||
3. Its API can be described without naming features — generic props/slots/events.
|
||||
|
||||
If any fails: **shared composite** if generic across features, **feature composite** if owned by one feature. Edge cases get raised to the user, not auto-decided. Consumer count never demotes a primitive.
|
||||
|
||||
### Primitive boundaries
|
||||
|
||||
- **Can use**: tokens, other primitives, Vue/Vuetify, generic composables (`useInput*`, `useFocus*`).
|
||||
- **Cannot use**: Pinia stores, API services, `emitter`, `router` (a `RouterLink` may be accepted as a prop), `i18n` directly. **No `$t()` in primitives** — text comes via props or slots.
|
||||
|
||||
---
|
||||
|
||||
## File & folder conventions
|
||||
|
||||
- **Primitive**: one per folder — `RFoo/RFoo.vue`, `RFoo/RFoo.stories.ts`, `RFoo/index.ts`, optional `RFoo/types.ts`.
|
||||
- **Composite**: flat `.vue` if one file suffices; a folder with the same internal structure (no story required) if it has sub-pieces.
|
||||
- **Barrel**: `src/v2/lib/index.ts` re-exports every primitive — update it when a new primitive ships. Composites are imported directly by path; no barrel. No single-file `index.ts` that just re-exports to shorten a path.
|
||||
|
||||
### SFC structure
|
||||
|
||||
- `<script setup lang="ts">` always.
|
||||
- `defineOptions({ inheritAttrs: false })` on every wrapper, paired with `v-bind="$attrs"` and slot passthrough (without the bind, attrs vanish silently).
|
||||
- Props via `defineProps<Props>()` (interface), never runtime declarations. Emits via `defineEmits<{...}>()`. Slots with payload via `defineSlots<{}>()`.
|
||||
- Order: `<script setup>` → `<template>` → `<style scoped>`. Unscoped `<style>` (teleport overrides only) goes after the scoped block.
|
||||
|
||||
### Import order & aliases
|
||||
|
||||
```ts
|
||||
// 1. External
|
||||
// 2. v2 primitives
|
||||
import { RBtn, RDialog } from "@v2/lib";
|
||||
import { computed, ref } from "vue";
|
||||
import type { SimpleRom } from "@/__generated__";
|
||||
// 5. Canonical shared resources
|
||||
import storeAuth from "@/stores/auth";
|
||||
// 4. v2 feature siblings
|
||||
import GameCard from "@/v2/components/GameCard.vue";
|
||||
// 3. v2 composables / shared
|
||||
import { useCan } from "@/v2/composables/useCan";
|
||||
```
|
||||
|
||||
- `@v2/lib` — primitives barrel. `@/v2/...` — anything else under v2. `@/...` — canonical shared resources. Never relative paths (`../../foo`) when an alias exists.
|
||||
- Shared v2 types live in `src/v2/types/`; backend types come from `src/__generated__/`; not `src/types/` (legacy).
|
||||
|
||||
### Composables
|
||||
|
||||
- `use` prefix; single named export from `composables/useFoo/index.ts`; fully typed args/return; no side effects on module load (init on first call). Creating a v2-only composable when a v1 equivalent exists is allowed.
|
||||
|
||||
### Console logging
|
||||
|
||||
- `console.error` allowed for production-visible errors. `console.log`/`console.warn` must not ship. `console.debug` is dev-only — remove before PR.
|
||||
|
||||
---
|
||||
|
||||
## Storybook (mandatory for `/lib`)
|
||||
|
||||
- Every primitive ships at least one story with controls and at least one variant per theme.
|
||||
- A new interactive primitive that warrants gamepad navigation ships a `play()` interaction.
|
||||
- Modified primitive: existing story must still render and its interactions still pass.
|
||||
- `npm run test` runs Vitest **and** every `/lib` story's `play()` via `composeStories`. Don't duplicate coverage between Vitest (pure logic) and Storybook `play()` (components).
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns (beyond what the premises already say)
|
||||
|
||||
1. Don't change shared store APIs to work around a v2 call-site issue. (Fix the call site; the Gallery lesson was calling `romsStore.reset()` from the view, not adding `_fetchSeq` to the store.)
|
||||
2. Don't drop to inline role checks — always go through `useCan` (see `frontend-v2-patterns`).
|
||||
3. Don't reinvent a surface — dialog/menu/popover/card all go through their primitive; special cases become a new prop, not a parallel surface.
|
||||
4. Don't use `v-form` directly — use `RForm`.
|
||||
5. Don't add backwards-compat shims inside v2: delete removed code; no `// removed`, no renamed-but-unused exports, no deprecated wrappers that just call the new function.
|
||||
6. Don't write redundant tests; don't touch v1; never `--no-verify` on commits.
|
||||
|
||||
**Allowed (often misread):** modifying shared stores/services/utils _additively_; creating v2-only composables; importing from `src/__generated__/`.
|
||||
|
||||
---
|
||||
|
||||
## Known debt (focused follow-ups)
|
||||
|
||||
- **Virtualisation migration** — `RVirtualScroller` (`src/v2/lib/structural/`) needs to absorb `GameGrid`/`LetterGroupedGrid` (structural refactor of `Platform.vue`/`Search.vue`/`Collection.vue`); `useLetterGroups` must become index-based for AlphaStrip scroll-spy.
|
||||
- **`useGalleryFilterUrl`** — sync `galleryFilter` store fields to URL query params for bookmarkable links; mark v1 store usage `@deprecated`.
|
||||
- **Vue Router scroll restoration** — galleries scroll custom containers (`.r-v2-plat__scroll`), not window; add a Pinia `routeFullPath → offsetTop` map with per-view hooks. Bundle with the virtualisation migration.
|
||||
- **`useSocketEvent` composable** — typed socket subscriptions with mount/unmount cleanup (consumers currently wire `socket.on/off` by hand).
|
||||
- **When v1 dies**: move `uiVersion` into `UI_SETTINGS_KEYS`; drop `.r-v2-*` scope classes (tokens move to `:root`); simplify `useUISettings` sync; delete `useGameAnimation`; drop the color-string→tone collapser in `NotificationHost`; remove the Vuetify rule arrays in `stores/users.ts`.
|
||||
|
||||
Full reference: `docs/FRONTEND_ARCHITECTURE.md`.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: frontend-v2-input
|
||||
description: Universal input (mouse, touch, keyboard, gamepad) and responsive/universal-viewport layout in the RomM v2 frontend. Use when adding interactive v2 components, focus management, spatial navigation, gamepad/keyboard handling, modality-gated focus rings, breakpoints, or responsive layout. Covers useInput, focus geometry primitives, the overlay scope stack, useBreakpoint, and the data-bp/data-input attributes. Trigger on interactive or responsive work under frontend/src/v2/.
|
||||
---
|
||||
|
||||
# RomM v2 — Universal Input & Universal Viewport
|
||||
|
||||
**Premise:** all v2 UI works with mouse, touch, keyboard, **and** gamepad; and every surface reads cleanly from a 320px phone to a 4K display. Both mechanisms are fixed — don't invent a parallel one.
|
||||
|
||||
The input system lives in `src/v2/composables/useInput/` (bus, keyboard, gamepad, actions, scope). It generalises the original gamepad-only `src/console/` system. There are **no `/console/*` routes in v2**.
|
||||
|
||||
---
|
||||
|
||||
## Modality
|
||||
|
||||
`useInputModality` sets `data-input="mouse|touch|key|pad"` on `<html>` from the most recent input.
|
||||
|
||||
- **Focus rings appear only with `key` and `pad`** (CSS in `global.css`). **Never use bare `:focus` in styles** — use the modality-gated selectors, or focus rings flash on mouse click.
|
||||
- Hit targets may scale up for `touch` and `pad`.
|
||||
|
||||
## Coverage — every interactive primitive participates
|
||||
|
||||
Buttons, list items, tabs, menu items, focusable cards, toggleable chips — all participate in spatial navigation (not optional). A new interactive primitive must:
|
||||
|
||||
- be focusable (proper `tabindex`, or already so via a wrapped Vuetify component);
|
||||
- react to logical actions (confirm/cancel) from `useInput`, in addition to native click;
|
||||
- show a modality-gated focus state.
|
||||
|
||||
Storybook `play()` covering gamepad input is required **only when applicable** (the primitive is interactive enough that gamepad navigation matters).
|
||||
|
||||
## Focus geometry
|
||||
|
||||
Each view declares its layout with focus primitives: `RFocusZone`, `RFocusGrid`, `RFocusRow`, `RFocusColumn`. Multiple regions = multiple zones. Predictable up/down/left/right movement is the view's responsibility.
|
||||
|
||||
## Element-level global shortcuts
|
||||
|
||||
Above per-view geometry, some elements bind globally regardless of focus location:
|
||||
|
||||
- `UserMenu` opens on **Start**.
|
||||
- Navbar tabs cycle with **LB/RB** (plus D-pad).
|
||||
- Context menu opens on **X or Y**.
|
||||
|
||||
## Scope (overlay stack)
|
||||
|
||||
When a dialog opens, push a scope; when it closes, pop. This stops Escape from closing two things at once and stops `confirm` leaking to controls beneath an overlay. `RDialog` and `RMenu` manage their scope automatically — custom overlays are an anti-pattern; go through the primitives.
|
||||
|
||||
---
|
||||
|
||||
## Responsive layout (universal viewport)
|
||||
|
||||
- **Single breakpoint source: `useBreakpoint`** (`src/v2/composables/useBreakpoint/`). Material thresholds — `xs <600`, `sm 600–959`, `md 960–1279`, `lg 1280–1919`, `xl ≥1920`. `installBreakpointAttribute()` (mounted once in `AppLayout`) mirrors the active set onto `<html data-bp="…">`.
|
||||
- **Layout switches live in CSS** via the attribute selector: `html[data-bp~="xs"] .foo { … }`, `html[data-bp~="sm-and-down"] .foo { … }`. **No raw `@media` for layout** — the only allowed `@media` are `prefers-reduced-motion` and print. The attribute is on `<html>` so it reaches teleported overlays.
|
||||
- **Conditional rendering** (mount/unmount a different component per tier) uses the `useBreakpoint()` refs in `<script>` — e.g. `v-if="xs"`. Prefer mount-gating over `display:none` for focusable chrome so hidden controls never sit in the tab/spatial-nav order.
|
||||
- **`--r-row-pad` is the global horizontal gutter**, already re-scoped responsive in `global.css` (36 → 20 → 14px). Consume `var(--r-row-pad)`; don't hard-code a smaller `xs` padding per component.
|
||||
- **Touch targets** ≥ `--r-touch-target` (44px) on `xs`/touch. Gate the bump to touch/pad where desktop sizing would bloat.
|
||||
- **Overlays go full-bleed on `xs`.** `RDialog` renders full-screen / bottom-sheet on phones (`fullscreenOnMobile`, default on); `RMenu` bottom-sheets large menus. Never float a 600px dialog on a 360px screen.
|
||||
- **Label→icon collapse** (the AppNav precedent) is the canonical way to compress chrome; the four primary destinations relocate to `BottomNav` on `sm-and-down`.
|
||||
- **Grids** size via `useResponsiveColumns` (ResizeObserver), never a fixed column count.
|
||||
|
||||
Verification adds a breakpoint sweep — see `pre-pr-verification`.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: frontend-v2-patterns
|
||||
description: Cross-cutting feature patterns for the RomM v2 frontend — error/snackbar handling, loading & skeleton states, real-time Socket.IO updates, UI state persistence (URL vs localStorage vs ephemeral), pagination/infinite scroll, forms & validation, permissions (useCan), and destructive confirmations. Use when wiring up a v2 feature's behavior (not just its markup). Trigger when implementing data flows, dialogs, forms, toggles, or permission gating under frontend/src/v2/.
|
||||
---
|
||||
|
||||
# RomM v2 — Architecture Patterns
|
||||
|
||||
How v2 features behave. Each pattern has one canonical mechanism — don't invent a parallel one.
|
||||
|
||||
---
|
||||
|
||||
## A. Errors & snackbars
|
||||
|
||||
- Single channel: `useSnackbar()` (`src/v2/composables/useSnackbar/`) with `success | error | warning | info` methods. It emits `snackbarShow`; `NotificationHost` stacks toasts.
|
||||
- The **call site** decides what's significant — no global "wrap-every-promise" magic.
|
||||
- Field validation errors render **in-place**, never as a snackbar.
|
||||
- Auth (401/403) is handled by the axios interceptor; no per-call-site checks.
|
||||
- Successful critical actions → `success` snackbar. Routine optimistic toggles → silent on success, `error` on failure.
|
||||
- Don't snackbar every rejected promise.
|
||||
|
||||
## B. Loading states
|
||||
|
||||
- **Skeleton** (`RSkeletonBlock`) for first load of a view with known layout — mimic the real shape so the layout doesn't jump.
|
||||
- **Inline `:loading` on the control itself** for in-flight actions (`RBtn`, `RTextField`, `RSelect`). Never put an external `RSpinner` next to a button that has its own `loading`.
|
||||
- **`RSpinner` inline** when what's loading isn't a control with native `loading`.
|
||||
- **Determinate progress (%)**: use `RProgressLinear` — no raw `v-progress-linear`.
|
||||
- **Empty state ≠ loading state.** Zero items is its own UX (message, illustration, optional CTA).
|
||||
- **Optimistic toggles show no spinner**: flip immediately; on failure, revert + snackbar.
|
||||
- `RBtn` ships `loadingDebounce={200}` — actions resolving under 200ms never paint a spinner; loading→not-loading is immediate.
|
||||
|
||||
## C. Real-time updates (Socket.IO)
|
||||
|
||||
- One instance: `src/services/socket.ts`. Never `new io()`.
|
||||
- New consumers go through (or build) a `useSocketEvent(event, handler)` composable for typed subscriptions with automatic mount/unmount cleanup (this composable is still debt — today consumers wire `socket.on/off` by hand).
|
||||
- **Ownership rule:** state living only while a view is open → subscribe in the view; state that must outlive a view (e.g. scan badge in navbar) → a Pinia store subscribes globally and views just read.
|
||||
- Reconnection is socket.io's job — don't roll your own.
|
||||
|
||||
## D. UI state persistence — three layers
|
||||
|
||||
1. **Persistent preferences** (theme, language, gallery defaults like `groupRoms`/`boxartStyle`, Home panels) → `useUISettings` (localStorage + backend `user.ui_settings` two-way sync). Add a key to `UI_SETTINGS_KEYS`.
|
||||
2. **Bookmarkable session state** (active filters, search query, sort, current tab in detail views) → **URL query params**. Anyone copying the link reproduces what they see. **Active gallery filter must be in URL.**
|
||||
3. **Ephemeral session state** (open dialog, hover, expansion) → `ref` if local, Pinia store if cross-component within the session.
|
||||
|
||||
Don't push state into `useUISettings` "so it persists" — follow the rule above.
|
||||
|
||||
## E. Pagination & infinite scroll
|
||||
|
||||
- `LoadMore` (`RBtn` + `RSpinner` + IntersectionObserver) is the canonical fallback when virtualization stalls.
|
||||
- `RVirtualScroller` (`src/v2/lib/structural/`, wrapping `v-virtual-scroll`) is the substrate for large lists/grids.
|
||||
- Page size lives in the store (`fetchLimit`); not user-configurable for now.
|
||||
- **Scroll restoration** on back-nav: Vue Router `scrollBehavior` + Pinia in-session offset. URL holds filters/sort/search but **not** scroll offset.
|
||||
|
||||
## F. Forms & validation
|
||||
|
||||
- Use the **`RForm` primitive** (wraps `v-form`: Enter-to-submit when valid, scroll-to-first-error after a failed `validate()`). **Never use `v-form` directly.**
|
||||
- **Native Vuetify rules** — no Zod/Yup. Rules are arrays of `(v) => true | string`.
|
||||
- **Reusable rules** in `src/v2/utils/validation.ts` (`required(msg?)`, `email`, `asciiOnly`, `lengthBetween`, `usernameLength/Chars`, `passwordLength`). Utility code _may_ call `i18n.global.t(...)` (the no-i18n rule covers lib primitives, not utils).
|
||||
- **Submit pattern:** `await formRef.value?.validate()` before the API call; submit button uses `:loading="submitting"`; errors → snackbar; field errors stay in-place via `:error-messages`.
|
||||
|
||||
## G. Permissions
|
||||
|
||||
- Action vocabulary `domain.action` (`rom.upload`, `rom.delete`, `library.scan`, `user.create`, `app.admin`) in `src/v2/composables/useCan/actions.ts`.
|
||||
- Scope vocabulary:
|
||||
```ts
|
||||
type PermissionScope =
|
||||
| { kind: "global" }
|
||||
| { kind: "platform"; id: number }
|
||||
| { kind: "collection"; id: number }
|
||||
| { kind: "rom"; id: number };
|
||||
```
|
||||
- **`useCan(action, scope?)`** returns `ComputedRef<boolean>`, reactive to `permissionsStore.grants`. Without scope: "can do this anywhere."
|
||||
- `stores/permissions.ts` holds normalised grants, hydrated from `authStore.user.role` via the role-map (`installPermissionsHydration()` in `AppLayout`); a future `/permissions/me` will replace it.
|
||||
- **`v-if`** to hide options a user shouldn't see; **`:disabled`** with tooltip when the option must be visible but blocked.
|
||||
- **Backend is source of truth** — frontend is a UX hint. Never bypass with inline `user.role === "..."`. All grants are pre-loaded (no `useCanAsync`).
|
||||
|
||||
## H. Destructive confirmations
|
||||
|
||||
Three friction levels:
|
||||
|
||||
- **Low / High** → shared composite `ConfirmDialog` (`components/shared/`) opened via `useConfirm({ title, body, confirmText, tone, requireTyped }) => Promise<boolean>` (mounted once in `GlobalDialogs`).
|
||||
- **Medium** → a feature composite when the flow needs extra options (e.g. `DeleteRomDialog` with per-item filesystem checkboxes).
|
||||
|
||||
Common rules:
|
||||
|
||||
- All destruction goes through a dialog — no silent destructive action.
|
||||
- Confirm button is danger-toned; **focus starts on Cancel**; Enter cancels.
|
||||
- Success → success snackbar or navigate away, dialog closes. Error → error snackbar, dialog stays open. During action → confirm shows `:loading`, cancel disabled.
|
||||
- The destructive control respects `useCan(action, scope)`.
|
||||
- **No "don't ask again."** **Type-to-confirm (`requireTyped`) is required when the action affects the filesystem.**
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: frontend-v2-theming
|
||||
description: Theming, design tokens, colors, and visual language in the RomM v2 frontend. Use when styling v2 components, picking colors, adding/using CSS variables, working with light/dark themes, or whenever you'd reach for a hex/rgba literal. Covers the token pipeline (src/v2/tokens/index.ts → build:tokens → tokens.css), the .r-v2 scope classes, the zero-hex-literal policy, and shared state semantics. Trigger on any color/theme/token work under frontend/src/v2/.
|
||||
---
|
||||
|
||||
# RomM v2 — Tokens, Theming & Visual Language
|
||||
|
||||
**Tokens are the only source of truth for theming. Zero hex/`rgba()` literals in v2 components.** If a value is missing, add a token. Every component must work in both `v2-dark` and `v2-light`.
|
||||
|
||||
---
|
||||
|
||||
## Token pipeline
|
||||
|
||||
`src/v2/tokens/index.ts` is the source. It feeds two consumers:
|
||||
|
||||
- **`src/v2/styles/tokens.css`** — _generated_ by `scripts/build-tokens.ts` (`npm run build:tokens`, hooked into `predev`/`prebuild`). **Do not hand-edit.** This is how the vast majority of tokens are consumed: `var(--r-color-...)` in CSS.
|
||||
- **Direct JS/TS imports** of named exports (`colorCanvas`, `colorCoverArt`, `layout`, …) for the few cases needing a token value in JavaScript — baking colors into an SVG string (`utils/covers`), canvas/QR backgrounds (`Player/Ruffle.vue`, `ShowQRCodeDialog`), and the virtualiser's pixel math (`Gallery/listColumns` reading `layout`).
|
||||
|
||||
v2 has **no Vuetify theme of its own.** `tokens.css` emits a palette block per theme under `.r-v2.r-v2-dark` / `.r-v2.r-v2-light`; `RomM.vue` toggles those classes on `<html>`. v2 surfaces never read Vuetify's runtime theme.
|
||||
|
||||
> Caveat: a wrapped Vuetify component still resolves `color="primary"` against Vuetify's _own_ registered themes (`src/plugins/vuetify.ts`, sourced from v1's `@/styles/themes`). They mirror the brand tokens by hand (both `#8B74E8`), so they line up, but it's a parallel source. **Prefer `var(--r-...)` over the `color` prop.**
|
||||
|
||||
### Adding a new token
|
||||
|
||||
1. Add it to `src/v2/tokens/index.ts` with a **semantic, role-based** name (`--r-color-danger`, not `--r-color-red`).
|
||||
2. Provide **both** dark and light values.
|
||||
3. Consume via `var(--r-...)` (CSS) or the named export (JS).
|
||||
4. Run `npm run build:tokens` to regenerate `tokens.css`.
|
||||
5. If the JS→CSS variable name needs an exception (e.g. `--r-nav-h`), add an entry to `NAME_OVERRIDES` in the generator.
|
||||
|
||||
### Where the scope classes live — and why `<html>`
|
||||
|
||||
`.r-v2`, `.r-v2-dark`, `.r-v2-light` go on `<html>` (`RomM.vue` toggles them whenever `uiVersion` or the active theme changes). Vuetify teleports overlays (`VDialog`, `VMenu`, `VTooltip`) to `<body> > .v-overlay-container`, **outside** `<v-app>`. Only `<html>` covers both the regular tree and the teleports — without it, overlays lose their tokens.
|
||||
|
||||
### Diagnostics — when `var(--r-color-...)` resolves to nothing on an overlay
|
||||
|
||||
1. Check `RomM.vue`'s watch on `documentElement.classList` (load-bearing).
|
||||
2. Check that the teleported component carries a `content-class` tying it back to scope (e.g. `RDialog` uses `content-class="r-dialog"`).
|
||||
3. **Never** "fix" it by swapping the token for a hex literal — that hides the bug and breaks the dual theme.
|
||||
|
||||
---
|
||||
|
||||
## Visual language
|
||||
|
||||
1. **Single visual vocabulary.** Every surface (dialog, menu, popover, card, toolbar) reads as a sibling — same blur, curvature, depth. No standalone "dialog look" vs "menu look".
|
||||
2. **Canonical references when designing**: ask the user before consulting `https://mockup.thebirdcage.tv/`. They decide whether the mockup or existing primitives take priority.
|
||||
3. **State semantics are shared across primitives** (don't reinvent per component):
|
||||
- hover (neutral, or brand-tinted on selected rows)
|
||||
- selected/checked (`--r-color-brand-primary`)
|
||||
- active/favorite (`--r-color-fav`)
|
||||
- focus (modality-gated; visible only on `key`/`pad` — see `frontend-v2-input`)
|
||||
- busy/pending · disabled
|
||||
4. **Implementation gotchas:**
|
||||
- `RDialog` ships unscoped `<style>` at the bottom that strips Vuetify defaults from `.v-overlay__content`. Load-bearing — without it the `--r-radius-card` corners disappear.
|
||||
- Every dialog goes through `RDialog`; every menu through `RMenu`.
|
||||
|
||||
---
|
||||
|
||||
## Color-literal policy: zero exceptions
|
||||
|
||||
Outside `src/v2/tokens/index.ts` (the source-of-truth TS module) and the generated `src/v2/styles/tokens.css`, **no hex or `rgba()` literals exist anywhere in v2.** Everything previously "excepted" is now a token or a `color-mix`:
|
||||
|
||||
- Cover-overlay glass → `--r-color-overlay-*` (fixed dark glass; never theme-flips).
|
||||
- Cover artwork placeholder & shimmer → `--r-color-cover-placeholder`, `--r-color-cover-placeholder-bright`.
|
||||
- Panel / tooltip / shimmer-sweep → `--r-color-panel`, `--r-color-panel-border`, `--r-color-tooltip-bg`, `--r-color-shimmer-sweep`.
|
||||
- Backdrop scrims (`global.css`) → `color-mix(in srgb, var(--r-color-bg) X%, transparent)`.
|
||||
- Status tints → `color-mix(in srgb, var(--r-color-status-base-{success,warning,danger,info}) X%, transparent)`.
|
||||
- Brand-tinted backgrounds (selected rows, focus rings) → `color-mix(in srgb, var(--r-color-brand-primary) X%, transparent)`.
|
||||
- Black/white shadows → `color-mix(in srgb, black X%, transparent)` (CSS named color, not a hex literal).
|
||||
- Metadata-provider chips → `--r-color-provider-*`. Player canvas → `--r-color-canvas-bg`, `--r-color-canvas-bg-deep`.
|
||||
- Emphasis pill (always-white-on-dark "Play" CTA over cover art) → `--r-color-overlay-emphasis-bg/-fg/-bg-hover`.
|
||||
|
||||
If a literal would otherwise be needed, the answer is: **add a token** (steps above), then consume via `var(--r-color-...)` or the named export.
|
||||
|
||||
## Style conventions
|
||||
|
||||
- Scoped `<style>` by default; unscoped only for teleport overrides.
|
||||
- BEM-ish class names: `.feature__element--modifier`. Prefixes: `.r-v2-...` for app-shell surfaces outside components; `.r-...` for globally shared utilities/tokens.
|
||||
- No plain CSS where a Vuetify utility class covers the case (`d-flex`, `pa-4`, `align-center`).
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: pre-pr-verification
|
||||
description: The before-handoff / before-PR verification gate for RomM, covering both stacks. Use right before committing, opening a PR, or telling the user a change is done — to run the right static checks, tests, and (for UI) manual browser/theme/input/Storybook checks so CI stays green. Covers frontend (typecheck/lint/test/build/i18n/tokens), backend (pytest/alembic/trunk), and the OpenAPI regen step. Trigger when wrapping up any change.
|
||||
---
|
||||
|
||||
# RomM — Verification Before Handoff
|
||||
|
||||
Run the checks that match what you touched. **Static checks don't prove a feature works** — when UI changed, also test it in the browser. Mirror the CI gates so review isn't the first place a failure shows up. **Never `--no-verify`.**
|
||||
|
||||
---
|
||||
|
||||
## Frontend (`frontend/`)
|
||||
|
||||
Run from `frontend/`:
|
||||
|
||||
1. `npm run typecheck` — zero errors (`vue-tsc --noEmit`).
|
||||
2. `npm run lint` _(if present)_ / ESLint clean. Trunk also runs ESLint + Prettier in CI.
|
||||
3. `npm run test` — zero failures (Vitest + happy-dom; runs unit tests **and** every `/lib` story's `play()` via `composeStories`).
|
||||
4. `npm run build` — zero failures (CI sanity check).
|
||||
|
||||
**If you touched the backend API:** start the backend, run `npm run generate`, then re-`typecheck`.
|
||||
|
||||
**If you touched tokens** (`src/v2/tokens/index.ts`): `npm run build:tokens` (also auto-runs on `predev`/`prebuild`) and confirm `tokens.css` regenerated.
|
||||
|
||||
**If you touched locales** (`src/locales/**`): `python3 frontend/src/locales/check_i18n_locales.py` must pass with zero missing/extra keys. See the `frontend-i18n` skill.
|
||||
|
||||
### UI manual pass (when changes are visible) — v2
|
||||
|
||||
With `uiVersion = "v2"`:
|
||||
|
||||
- **Golden path + edge cases:** empty, error, loading, no-permission, extreme data; plus nearby regressions.
|
||||
- **Both themes:** `v2-dark` and `v2-light`.
|
||||
- **All four input modalities:** mouse, touch, keyboard, gamepad — focus ring only on `key`/`pad`.
|
||||
- **Responsive sweep:** 320px → 4K across the `useBreakpoint` tiers; overlays full-bleed on `xs`.
|
||||
- **Accessibility:** contrast, keyboard reachability with no traps, aria-labels on icon-only controls.
|
||||
- **Performance:** lists/grids of 1000+ items stay smooth; every `v-for` has a stable `:key`.
|
||||
|
||||
### Storybook (for `/lib`)
|
||||
|
||||
- New primitive → mandatory story with controls + at least one variant per theme; interactive ones get a `play()`.
|
||||
- Modified primitive → existing story still renders and interactions still pass.
|
||||
- Don't duplicate coverage between Vitest (pure logic) and Storybook `play()` (components).
|
||||
|
||||
---
|
||||
|
||||
## Backend (`backend/`)
|
||||
|
||||
Run from `backend/`:
|
||||
|
||||
1. `uv run pytest [path/file]` — zero failures (run the affected subset, or all with `-vv`).
|
||||
2. `trunk fmt && trunk check` — ruff/black/isort/mypy/bandit clean (CI enforces Trunk).
|
||||
3. **If you added a migration:** `uv run alembic upgrade head` then `uv run alembic downgrade -1` to prove both directions; it must work on MariaDB **and** PostgreSQL (CI runs both).
|
||||
4. **If a response schema or route signature changed:** regenerate frontend types (`npm run generate`) and typecheck the frontend.
|
||||
|
||||
---
|
||||
|
||||
## CI gates this mirrors
|
||||
|
||||
`typecheck.yml` (vue-tsc + lockfile lint), `frontend.yml` (vitest + build), `i18n.yml` (locale check), `pytest.yml` (pytest on MariaDB + PostgreSQL), `migrations.yml` (alembic on both DBs), `trunk-check.yml` (Trunk across the repo). Green locally → green in CI.
|
||||
|
||||
## Don't
|
||||
|
||||
- Open a PR without manually testing the UI when UI was touched.
|
||||
- `--no-verify` on commits.
|
||||
- Leave a locale key English-only, a token un-generated, or a migration one-directional.
|
||||
@@ -1 +1,11 @@
|
||||
**/tests/cassettes/*.yaml linguist-generated
|
||||
|
||||
# Keep this file itself in LF so git can parse it reliably on all platforms.
|
||||
.gitattributes text eol=lf
|
||||
|
||||
# Ensure shell scripts always use LF line endings in the repository,
|
||||
# regardless of the developer's OS or core.autocrlf setting.
|
||||
# Without this, Windows checkouts produce CRLF scripts that fail to
|
||||
# execute inside Linux containers with "no such file or directory".
|
||||
*.sh text eol=lf
|
||||
docker/init_scripts/init text eol=lf
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version-file: frontend/.nvmrc
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Frontend Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "frontend/**"
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
paths:
|
||||
- "frontend/**"
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/[email protected]
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run tests (vitest, includes Storybook play() tests)
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/[email protected]
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
@@ -71,7 +71,11 @@ jobs:
|
||||
- name: Initiate MariaDB database
|
||||
if: matrix.db == 'mariadb'
|
||||
run: |
|
||||
mysql --host 127.0.0.1 --port ${{ job.services.mariadb.ports['3306'] }} -uroot -ppasswd -e "GRANT ALL PRIVILEGES ON romm_test.* TO 'romm_test'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;"
|
||||
# Grant on the `romm_test%` namespace so the test user can create the
|
||||
# per-worker databases (romm_test_gw0, ...) used under pytest-xdist,
|
||||
# without granting it global privileges. The backticks are escaped so
|
||||
# the shell doesn't treat them as command substitution.
|
||||
mysql --host 127.0.0.1 --port ${{ job.services.mariadb.ports['3306'] }} -uroot -ppasswd -e "GRANT ALL PRIVILEGES ON \`romm\_test%\`.* TO 'romm_test'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;"
|
||||
|
||||
- name: Run python tests
|
||||
env:
|
||||
@@ -80,9 +84,11 @@ jobs:
|
||||
ROMM_DB_DRIVER: ${{ matrix.db }}
|
||||
REDIS_HOST: 127.0.0.1
|
||||
REDIS_PORT: ${{ job.services.valkey.ports['6379'] }}
|
||||
HYPOTHESIS_PROFILE: ci
|
||||
run: |
|
||||
cd backend
|
||||
uv run pytest -vv --maxfail=10 --junitxml=pytest-report.xml --cov --cov-report xml:coverage.xml --cov-config=.coveragerc .
|
||||
# GitHub-hosted Linux runners have 4 vCPUs; run one worker per core.
|
||||
uv run pytest -n 4 -vv --maxfail=10 --junitxml=pytest-report.xml --cov --cov-report xml:coverage.xml --cov-config=.coveragerc .
|
||||
|
||||
- name: Publish test results
|
||||
uses: EnricoMi/publish-unit-test-result-action/[email protected]
|
||||
|
||||
@@ -25,10 +25,12 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run typecheck
|
||||
|
||||
+7
-1
@@ -54,6 +54,7 @@ romm_mock
|
||||
backend/romm_test/resources
|
||||
backend/romm_test/logs
|
||||
.pytest_cache
|
||||
.hypothesis
|
||||
/romm_test
|
||||
.coverage
|
||||
pytest-report.xml
|
||||
@@ -62,9 +63,14 @@ coverage.xml
|
||||
# service worker
|
||||
frontend/dev-dist
|
||||
|
||||
# storybook
|
||||
frontend/storybook-static
|
||||
|
||||
# outside data
|
||||
switch_titledb.json
|
||||
switch_product_ids.json
|
||||
|
||||
# AI tools
|
||||
.claude
|
||||
# Keep shared agent skills tracked; ignore personal/local Claude config.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
|
||||
@@ -7,7 +7,7 @@ runs:
|
||||
- name: Setup node
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
node-version: 18
|
||||
node-version-file: frontend/.nvmrc
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
|
||||
@@ -51,6 +51,8 @@ lint:
|
||||
- frontend/src/__generated__/**
|
||||
- docker/Dockerfile
|
||||
- docker/nginx/js/**
|
||||
# Intentionally malformed YAML, used as a test fixture
|
||||
- backend/tests/config/fixtures/config/malformed_config.yml
|
||||
files:
|
||||
- name: vue
|
||||
extensions: [vue]
|
||||
|
||||
Vendored
+9
-3
@@ -3,9 +3,8 @@
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Launch frontend",
|
||||
"type": "npm",
|
||||
"path": "frontend",
|
||||
"script": "dev",
|
||||
"type": "shell",
|
||||
"command": "cd frontend && npm run dev",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
@@ -37,6 +36,13 @@
|
||||
"dependsOn": ["Launch frontend", "Launch backend"],
|
||||
"problemMatcher": [],
|
||||
"dependsOrder": "parallel"
|
||||
},
|
||||
{
|
||||
"label": "Launch Storybook",
|
||||
"type": "npm",
|
||||
"path": "frontend",
|
||||
"script": "storybook",
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# RomM - Repository Guide for Contributors & Agents
|
||||
|
||||
RomM is a self-hosted ROM manager and player: scan a game library off disk, enrich it with metadata from 10+ providers, browse it in a web UI, and play in the browser.
|
||||
|
||||
---
|
||||
|
||||
## The stack at a glance
|
||||
|
||||
| | Backend | Frontend |
|
||||
| --------- | ------------------------------------------- | ----------------------------------------- |
|
||||
| Path | `backend/` | `frontend/` |
|
||||
| Language | Python 3.13+ | TypeScript 5.7 (Vue 3) |
|
||||
| Framework | FastAPI, SQLAlchemy 2.0, Alembic | Vue 3 + Vite, Vuetify, Pinia, Vue Router |
|
||||
| Infra | Redis + RQ (jobs/cache/sessions), Socket.IO | vue-i18n, Socket.IO client |
|
||||
| DB | MariaDB (default), MySQL, PostgreSQL | - |
|
||||
| Tooling | `uv`, pytest, Trunk (ruff/black/isort/mypy) | `npm`, vue-tsc, ESLint, Vitest, Storybook |
|
||||
|
||||
The frontend talks to the backend over `/api/*` (REST) and `/ws` (Socket.IO). TypeScript types are **generated** from the backend's OpenAPI schema into `frontend/src/__generated__/` - the backend is the single source of truth for API shapes.
|
||||
|
||||
### Deep-dive references
|
||||
|
||||
- **`docs/BACKEND_ARCHITECTURE.md`** - directory map, ER diagram, every endpoint, auth/scopes, tasks.
|
||||
- **`docs/FRONTEND_ARCHITECTURE.md`** - routing, stores, services, theming, build tooling.
|
||||
- **`DEVELOPER_SETUP.md`** - Docker and manual local setup (mock library, `.env`, services).
|
||||
- **`CONTRIBUTING.md`** - contribution flow, **AI-assistance disclosure**, translations.
|
||||
|
||||
---
|
||||
|
||||
## The frontend has two UIs - know which you're in
|
||||
|
||||
- **v1 is frozen.** Everything under `frontend/src/views/`, `src/components/`, `src/console/`, `src/layouts/` is legacy and will be deleted wholesale in a final wave. **Do not refactor v1.** Only touch it for a critical bug, and when a v2 fork exists, mark the v1 export `@deprecated`.
|
||||
- **v2 is the active rewrite** under `frontend/src/v2/`, gated by `user.ui_settings.uiVersion`. It has its own design system (tokens), primitive library (`R*` components in `src/v2/lib/`), universal input (mouse/touch/keyboard/gamepad), and responsive system. New frontend work goes in v2.
|
||||
|
||||
v2 has a detailed constitution, split across focused skills (below). **Read the relevant skill before editing v2 code.**
|
||||
|
||||
---
|
||||
|
||||
## Skills - load the focused guide for your task
|
||||
|
||||
These live in `.claude/skills/` and carry the detailed rules. Invoke the one that matches what you're doing:
|
||||
|
||||
| Skill | When |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `frontend-v2-components` | Building/editing any v2 component - tiers (lib/shared/feature), file & SFC conventions, barrels, anti-patterns. |
|
||||
| `frontend-v2-theming` | Colors, tokens, light/dark themes, visual language - and the **zero-hex-literal** policy. |
|
||||
| `frontend-v2-input` | Interactive components, focus/spatial navigation, gamepad/keyboard, breakpoints & responsive layout. |
|
||||
| `frontend-v2-patterns` | Feature behavior - errors/snackbars, loading, sockets, state persistence, pagination, forms, permissions, destructive confirmations. |
|
||||
| `frontend-i18n` | Any user-visible string or change under `frontend/src/locales/**`. |
|
||||
| `backend-development` | Endpoints, handlers, models, schemas, metadata adapters, tasks, migrations under `backend/`. |
|
||||
| `pre-pr-verification` | Before committing / opening a PR / declaring done - the checks that keep CI green. |
|
||||
|
||||
---
|
||||
|
||||
## Repo-wide rules
|
||||
|
||||
**Disclose AI assistance in the PR.** RomM requires it (see `CONTRIBUTING.md`): state that AI was used and to what extent. This is mandatory and non-negotiable for agent-written contributions.
|
||||
**Branch off `master`; open PRs against `master`.** Fork → feature branch → PR. Don't push to `master`.
|
||||
**Linting is via [Trunk](https://trunk.io)** (`trunk fmt && trunk check`) — it wraps ruff, black, isort, mypy, ESLint, Prettier, and more, and runs in CI on every PR. **Never commit with `--no-verify`.**
|
||||
**The backend owns the API contract.** Changed a response schema or route? Regenerate frontend types (`npm run generate`) and re-typecheck.
|
||||
**Tests travel with code.** New logic gets a test; new endpoints get endpoint tests; new v2 primitives get a Storybook story (+ `play()` if interactive).
|
||||
**Verify before handoff.** Don't say "done" on UI work without testing it in the browser in both themes and all input modalities. See `pre-pr-verification`.
|
||||
**English first.** Outside of language files, all code, comments, identifiers, `.md` files, and commit/PR messages are in English.
|
||||
**No em-dashes.** Never use em-dashes (—) when writing comments or text. Use commas, parentheses, or separate sentences instead.
|
||||
**Keep comments short.** Comments should be concise, and focus on the "why" rather than the "what" (the code itself is the "what"). Avoid long paragraphs; break them into multiple lines or sentences.
|
||||
**Never commit secrets.** Never commit secrets (API keys, passwords, tokens, etc.) to the repo. Use environment variables or secret management tools instead.
|
||||
**Don't explain a change.** Avoid comments that explain why a change was made to the code. Focus instead on the current behaviour of the code and how it works.
|
||||
|
||||
---
|
||||
|
||||
## Quick command reference
|
||||
|
||||
**Setup:** see `DEVELOPER_SETUP.md`. Docker path is `cp env.template .env` → `docker compose build` → `docker compose up -d` (app at `http://localhost:3000`).
|
||||
|
||||
**Backend** (`cd backend`):
|
||||
|
||||
```bash
|
||||
uv sync --all-extras --dev # install
|
||||
uv run main.py # run (migrations auto-apply)
|
||||
uv run pytest [path/file] # test (subset) - or -vv for all
|
||||
uv run alembic revision --autogenerate -m "msg" # new migration (then HAND-REVIEW)
|
||||
uv run alembic upgrade head # apply migrations
|
||||
```
|
||||
|
||||
**Frontend** (`cd frontend`):
|
||||
|
||||
```bash
|
||||
npm install # install (Node 24)
|
||||
npm run dev # dev server :3000
|
||||
npm run typecheck # vue-tsc
|
||||
npm run test # vitest (+ Storybook play() tests)
|
||||
npm run build # production build
|
||||
npm run generate # regenerate types from backend OpenAPI (backend must be running)
|
||||
npm run build:tokens # regenerate v2 tokens.css (auto on predev/prebuild)
|
||||
npm run storybook # component library on :6006
|
||||
python3 src/locales/check_i18n_locales.py # i18n parity check
|
||||
```
|
||||
|
||||
**Lint (both stacks):** `trunk fmt && trunk check`.
|
||||
+4
-4
@@ -37,10 +37,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ENV NVM_DIR="/root/.nvm"
|
||||
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash \
|
||||
&& . "$NVM_DIR/nvm.sh" \
|
||||
&& nvm install 24.13.1 \
|
||||
&& nvm use 24.13.1 \
|
||||
&& nvm alias default 24.13.1
|
||||
ENV PATH="$NVM_DIR/versions/node/v24.13.1/bin:$PATH"
|
||||
&& nvm install 24.16.0 \
|
||||
&& nvm use 24.16.0 \
|
||||
&& nvm alias default 24.16.0
|
||||
ENV PATH="$NVM_DIR/versions/node/v24.16.0/bin:$PATH"
|
||||
|
||||
# Build and install RAHasher (optional for RA hashes)
|
||||
RUN git clone --recursive --branch 1.8.3 --depth 1 https://github.com/RetroAchievements/RALibretro.git /tmp/RALibretro
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ class _AnchorHrefParser(HTMLParser):
|
||||
# Skip sort/parent/query links exposed by Apache autoindex.
|
||||
if value.startswith(("?", "/", "#")) or ".." in value:
|
||||
return
|
||||
# Only keep image files — libretro stores PNGs.
|
||||
# Only keep image files, since libretro stores PNGs.
|
||||
decoded = unquote(value)
|
||||
if decoded.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
|
||||
self.filenames.append(decoded)
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import http
|
||||
import json
|
||||
from collections.abc import Collection
|
||||
from typing import Literal, overload
|
||||
from typing import Final, Literal, overload
|
||||
|
||||
import aiohttp
|
||||
import yarl
|
||||
@@ -14,6 +14,11 @@ from config import MOBYGAMES_API_KEY
|
||||
from logger.logger import log
|
||||
from utils import get_version
|
||||
from utils.context import ctx_aiohttp_session
|
||||
from utils.rate_limiter import RateLimiter
|
||||
|
||||
# MobyGames caps the free/non-commercial tier at 1 request per second.
|
||||
MOBYGAMES_MAX_REQUESTS_PER_SECOND: Final[float] = 1
|
||||
_rate_limiter = RateLimiter(MOBYGAMES_MAX_REQUESTS_PER_SECOND)
|
||||
|
||||
|
||||
async def auth_middleware(
|
||||
@@ -45,6 +50,7 @@ class MobyGamesService:
|
||||
)
|
||||
|
||||
try:
|
||||
await _rate_limiter.acquire()
|
||||
res = await aiohttp_session.get(
|
||||
url,
|
||||
headers={"user-agent": f"RomM/{get_version()}"},
|
||||
@@ -85,6 +91,7 @@ class MobyGamesService:
|
||||
url,
|
||||
request_timeout,
|
||||
)
|
||||
await _rate_limiter.acquire()
|
||||
res = await aiohttp_session.get(
|
||||
url,
|
||||
headers={"user-agent": f"RomM/{get_version()}"},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
|
||||
from handler.metadata.ra_handler import RAGamesPlatform
|
||||
@@ -10,6 +11,48 @@ from utils.filesystem import COMPRESSED_FILE_EXTENSIONS
|
||||
|
||||
RAHASHER_VALID_HASH_REGEX = re.compile(r"[0-9a-f]{32}")
|
||||
|
||||
# Disc descriptor / playlist files that point RAHasher at the real data
|
||||
# tracks. Handing RAHasher one of these reproduces the hash a shell-expanded
|
||||
# glob would, in priority order: a per-disc descriptor (.cue/.gdi) wins over
|
||||
# an .m3u playlist so single-disc folders resolve to their own disc.
|
||||
RA_DISC_DESCRIPTOR_EXTENSIONS: tuple[str, ...] = (".cue", ".gdi", ".m3u")
|
||||
|
||||
|
||||
def _pick_ra_file(folder: Path) -> Path | None:
|
||||
"""Pick a single real file to hand RAHasher for a folder-based ROM.
|
||||
|
||||
RAHasher is launched without a shell, so it never expands a ``/*`` glob —
|
||||
it must be given a concrete file. Prefer a disc descriptor
|
||||
(``.cue``/``.gdi``/``.m3u``), which RAHasher follows to the referenced
|
||||
tracks; otherwise fall back to the largest file in the folder (a raw
|
||||
``.iso``/``.bin`` image, or the main file of a multi-file cartridge set).
|
||||
Returns ``None`` when the folder is missing or holds no files.
|
||||
"""
|
||||
if not folder.is_dir():
|
||||
return None
|
||||
try:
|
||||
files = [f for f in folder.iterdir() if f.is_file()]
|
||||
except (OSError, PermissionError):
|
||||
return None
|
||||
if not files:
|
||||
return None
|
||||
|
||||
def _size(p: Path) -> int:
|
||||
try:
|
||||
return p.stat().st_size
|
||||
except (OSError, PermissionError):
|
||||
return -1
|
||||
|
||||
for ext in RA_DISC_DESCRIPTOR_EXTENSIONS:
|
||||
matches = [f for f in files if f.name.lower().endswith(ext)]
|
||||
if matches:
|
||||
picked = max(matches, key=_size)
|
||||
return picked if _size(picked) >= 0 else None
|
||||
|
||||
picked = max(files, key=_size)
|
||||
return picked if _size(picked) >= 0 else None
|
||||
|
||||
|
||||
# Platforms whose hash algorithm requires an on-disk disc image
|
||||
# (ISO9660/bin+cue/CHD). When the source file is an archive, RAHasher falls
|
||||
# back to "buffer hash" mode which these consoles don't support, failing
|
||||
@@ -109,7 +152,7 @@ class RAHasherService:
|
||||
# Skip the subprocess entirely when the file is an archive and the
|
||||
# RA platform needs an on-disk disc image. RAHasher would just spawn,
|
||||
# fail with "Unsupported console for buffer hash: {id}", and return
|
||||
# nothing — paying process-spawn overhead per ROM for no result.
|
||||
# nothing, so we'd pay process-spawn overhead per ROM for no result.
|
||||
if file_path.lower().endswith(tuple(COMPRESSED_FILE_EXTENSIONS)):
|
||||
if platform["ra_id"] in RA_BUFFER_HASH_UNSUPPORTED_IDS:
|
||||
log.debug(
|
||||
@@ -119,6 +162,49 @@ class RAHasherService:
|
||||
)
|
||||
return ""
|
||||
|
||||
# For folder-based multi-file ROMs the path ends with /*. RAHasher is
|
||||
# launched without a shell (create_subprocess_exec), so it never expands
|
||||
# the glob — it receives the literal "*" and fails ("Could not open
|
||||
# track/file"). Resolve "/*" to a single real file: hash the largest
|
||||
# archive directly when the folder holds archives (or skip for disc
|
||||
# platforms that can't buffer-hash them), otherwise pick a disc
|
||||
# descriptor / track via _pick_ra_file.
|
||||
if file_path.endswith("/*"):
|
||||
folder = Path(file_path[:-2])
|
||||
|
||||
def _find_archives() -> list[Path]:
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return [
|
||||
f
|
||||
for f in folder.iterdir()
|
||||
if f.is_file()
|
||||
and f.name.lower().endswith(tuple(COMPRESSED_FILE_EXTENSIONS))
|
||||
]
|
||||
|
||||
archive_files = await asyncio.to_thread(_find_archives)
|
||||
if archive_files:
|
||||
if platform["ra_id"] in RA_BUFFER_HASH_UNSUPPORTED_IDS:
|
||||
log.debug(
|
||||
f"Skipping {hl('RAHasher', color=LIGHTMAGENTA)} for folder "
|
||||
f"{hl(file_path)}: contains compressed files on disc-based platform"
|
||||
)
|
||||
return ""
|
||||
# Cartridge platform: buffer-hash the largest archive directly
|
||||
largest = await asyncio.to_thread(
|
||||
max, archive_files, key=lambda f: f.stat().st_size
|
||||
)
|
||||
file_path = str(largest)
|
||||
else:
|
||||
# Folder of uncompressed disc tracks (the standard Redump
|
||||
# .cue + .bin layout, .gdi sets, multi-bin) or a multi-file
|
||||
# cartridge set. RAHasher never expands the "/*" glob itself,
|
||||
# so resolve it to a single real file — the disc descriptor
|
||||
# when present, otherwise the largest track.
|
||||
resolved = await asyncio.to_thread(_pick_ra_file, folder)
|
||||
if resolved is not None:
|
||||
file_path = str(resolved)
|
||||
|
||||
log.debug(
|
||||
f"Executing {hl('RAHasher', color=LIGHTMAGENTA)} for platform: {hl(platform['slug'], color=LIGHTMAGENTA)} - file: {hl(file_path)}"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import http
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import cast
|
||||
from typing import Final, cast
|
||||
|
||||
import aiohttp
|
||||
import yarl
|
||||
@@ -20,6 +20,12 @@ from config import RETROACHIEVEMENTS_API_KEY
|
||||
from logger.logger import log
|
||||
from utils import get_version
|
||||
from utils.context import ctx_aiohttp_session
|
||||
from utils.rate_limiter import RateLimiter
|
||||
|
||||
# RetroAchievements does not publish a fixed limit, try to stay
|
||||
# within the "fair burst" allowance the API documents
|
||||
RA_MAX_REQUESTS_PER_SECOND: Final[float] = 4
|
||||
_rate_limiter = RateLimiter(RA_MAX_REQUESTS_PER_SECOND)
|
||||
|
||||
|
||||
async def auth_middleware(
|
||||
@@ -53,6 +59,7 @@ class RetroAchievementsService:
|
||||
request_timeout,
|
||||
)
|
||||
try:
|
||||
await _rate_limiter.acquire()
|
||||
res = await aiohttp_session.get(
|
||||
url,
|
||||
headers={"user-agent": f"RomM/{get_version()}"},
|
||||
@@ -90,6 +97,7 @@ class RetroAchievementsService:
|
||||
url,
|
||||
request_timeout,
|
||||
)
|
||||
await _rate_limiter.acquire()
|
||||
res = await aiohttp_session.get(
|
||||
url,
|
||||
headers={"user-agent": f"RomM/{get_version()}"},
|
||||
|
||||
@@ -14,11 +14,37 @@ from config import SCREENSCRAPER_PASSWORD, SCREENSCRAPER_USER
|
||||
from logger.logger import log
|
||||
from utils import get_version
|
||||
from utils.context import ctx_aiohttp_session
|
||||
from utils.rate_limiter import ConcurrencyLimiter
|
||||
|
||||
SS_DEV_ID: Final = base64.b64decode("enVyZGkxNQ==").decode()
|
||||
SS_DEV_PASSWORD: Final = base64.b64decode("eFRKd29PRmpPUUc=").decode()
|
||||
LOGIN_ERROR_CHECK: Final = "Erreur de login"
|
||||
|
||||
# ScreenScraper enforces a per-account *thread* (concurrency) cap rather than a
|
||||
# request rate. Because a request can take several seconds, spacing out request
|
||||
# starts is not enough, as overlapping requests would exceed the cap and get
|
||||
# rejected. We instead bound simultaneous in-flight requests.
|
||||
SS_DEFAULT_MAX_THREADS: Final[int] = 1
|
||||
_concurrency_limiter = ConcurrencyLimiter(SS_DEFAULT_MAX_THREADS)
|
||||
|
||||
|
||||
def _update_thread_allowance(response: dict) -> None:
|
||||
"""Raise (or lower) the concurrency cap to the account's advertised threads.
|
||||
|
||||
ScreenScraper reports the per-account thread allowance in
|
||||
``response.ssuser.maxthreads`` (higher for contributors/donors).
|
||||
"""
|
||||
try:
|
||||
max_threads = int(response["response"]["ssuser"]["maxthreads"])
|
||||
except (AttributeError, KeyError, TypeError, ValueError):
|
||||
return
|
||||
|
||||
if max_threads < 1 or max_threads == _concurrency_limiter.max_concurrency:
|
||||
return
|
||||
|
||||
log.info("ScreenScraper: setting thread allowance to %d", max_threads)
|
||||
_concurrency_limiter.set_max_concurrency(max_threads)
|
||||
|
||||
|
||||
async def auth_middleware(
|
||||
req: aiohttp.ClientRequest, handler: aiohttp.ClientHandlerType
|
||||
@@ -30,8 +56,8 @@ async def auth_middleware(
|
||||
"devpassword": SS_DEV_PASSWORD,
|
||||
"output": "json",
|
||||
"softname": "romm",
|
||||
"ssid": SCREENSCRAPER_USER,
|
||||
"sspassword": SCREENSCRAPER_PASSWORD,
|
||||
"ssid": SCREENSCRAPER_USER or "",
|
||||
"sspassword": SCREENSCRAPER_PASSWORD or "",
|
||||
},
|
||||
)
|
||||
return await handler(req)
|
||||
@@ -57,21 +83,24 @@ class ScreenScraperService:
|
||||
request_timeout,
|
||||
)
|
||||
try:
|
||||
res = await aiohttp_session.get(
|
||||
url,
|
||||
headers={"user-agent": f"RomM/{get_version()}"},
|
||||
middlewares=(auth_middleware,),
|
||||
timeout=ClientTimeout(total=request_timeout),
|
||||
)
|
||||
res.raise_for_status()
|
||||
res_text = await res.text()
|
||||
if LOGIN_ERROR_CHECK in res_text:
|
||||
log.error("Invalid ScreenScraper credentials")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid ScreenScraper credentials",
|
||||
async with _concurrency_limiter:
|
||||
res = await aiohttp_session.get(
|
||||
url,
|
||||
headers={"user-agent": f"RomM/{get_version()}"},
|
||||
middlewares=(auth_middleware,),
|
||||
timeout=ClientTimeout(total=request_timeout),
|
||||
)
|
||||
return await res.json()
|
||||
res.raise_for_status()
|
||||
res_text = await res.text()
|
||||
if LOGIN_ERROR_CHECK in res_text:
|
||||
log.error("Invalid ScreenScraper credentials")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid ScreenScraper credentials",
|
||||
)
|
||||
data = await res.json()
|
||||
_update_thread_allowance(data)
|
||||
return data
|
||||
except aiohttp.ServerTimeoutError:
|
||||
# Retry the request once if it times out
|
||||
pass
|
||||
@@ -85,10 +114,34 @@ class ScreenScraperService:
|
||||
) from exc
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if err.status == http.HTTPStatus.TOO_MANY_REQUESTS:
|
||||
# Retry after 2 seconds if rate limit hit
|
||||
log.warning("ScreenScraper: rate limit hit, retrying after 2s")
|
||||
await asyncio.sleep(2)
|
||||
elif err.status == 426:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="ScreenScraper has blacklisted this application version. Please update RomM.",
|
||||
) from err
|
||||
elif err.status == 430:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="ScreenScraper daily scrape quota exhausted. Try again tomorrow.",
|
||||
) from err
|
||||
elif err.status == 431:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="ScreenScraper daily unrecognized-ROM quota exhausted. Try again tomorrow.",
|
||||
) from err
|
||||
elif err.status == 423:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="ScreenScraper API is currently offline.",
|
||||
) from err
|
||||
elif err.status == http.HTTPStatus.UNAUTHORIZED:
|
||||
log.warning(
|
||||
"ScreenScraper API is temporarily unavailable (server CPU >60%)"
|
||||
)
|
||||
return {}
|
||||
else:
|
||||
# Log the error and return an empty dict if the request fails with a different code
|
||||
log.error(err)
|
||||
return {}
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -101,27 +154,51 @@ class ScreenScraperService:
|
||||
url,
|
||||
request_timeout,
|
||||
)
|
||||
res = await aiohttp_session.get(
|
||||
url,
|
||||
headers={"user-agent": f"RomM/{get_version()}"},
|
||||
middlewares=(auth_middleware,),
|
||||
timeout=ClientTimeout(total=request_timeout),
|
||||
)
|
||||
res.raise_for_status()
|
||||
res_text = await res.text()
|
||||
if LOGIN_ERROR_CHECK in res_text:
|
||||
log.error("Invalid ScreenScraper credentials")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid ScreenScraper credentials",
|
||||
async with _concurrency_limiter:
|
||||
res = await aiohttp_session.get(
|
||||
url,
|
||||
headers={"user-agent": f"RomM/{get_version()}"},
|
||||
middlewares=(auth_middleware,),
|
||||
timeout=ClientTimeout(total=request_timeout),
|
||||
)
|
||||
return await res.json()
|
||||
res.raise_for_status()
|
||||
res_text = await res.text()
|
||||
if LOGIN_ERROR_CHECK in res_text:
|
||||
log.error("Invalid ScreenScraper credentials")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid ScreenScraper credentials",
|
||||
)
|
||||
data = await res.json()
|
||||
_update_thread_allowance(data)
|
||||
return data
|
||||
except (aiohttp.ClientResponseError, aiohttp.ServerTimeoutError) as err:
|
||||
if (
|
||||
isinstance(err, aiohttp.ClientResponseError)
|
||||
and err.status == http.HTTPStatus.UNAUTHORIZED
|
||||
):
|
||||
return {}
|
||||
if isinstance(err, aiohttp.ClientResponseError):
|
||||
if err.status == http.HTTPStatus.UNAUTHORIZED:
|
||||
log.warning(
|
||||
"ScreenScraper API is temporarily unavailable (server CPU >60%)"
|
||||
)
|
||||
return {}
|
||||
elif err.status == 426:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="ScreenScraper has blacklisted this application version. Please update RomM.",
|
||||
) from err
|
||||
elif err.status == 430:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="ScreenScraper daily scrape quota exhausted. Try again tomorrow.",
|
||||
) from err
|
||||
elif err.status == 431:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="ScreenScraper daily unrecognized-ROM quota exhausted. Try again tomorrow.",
|
||||
) from err
|
||||
elif err.status == 423:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="ScreenScraper API is currently offline.",
|
||||
) from err
|
||||
|
||||
log.error(err)
|
||||
return {}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
from typing import Literal, TypedDict
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
|
||||
# Per-account limits returned in `response.ssuser`
|
||||
# Reference: https://api.screenscraper.fr/webapi2.php
|
||||
class SSUser(TypedDict):
|
||||
id: NotRequired[str]
|
||||
niveau: NotRequired[str]
|
||||
maxthreads: NotRequired[str]
|
||||
maxrequestspermin: NotRequired[str]
|
||||
maxrequestsperday: NotRequired[str]
|
||||
requeststoday: NotRequired[str]
|
||||
|
||||
|
||||
class SSText(TypedDict):
|
||||
|
||||
@@ -41,6 +41,15 @@ def include_object(object, name, type_, reflected, compare_to):
|
||||
]: # Virtual table
|
||||
return False
|
||||
|
||||
# Skip DB-specific search indexes in autogenerate
|
||||
# to avoid false drop/create operations
|
||||
if type_ == "index" and name in (
|
||||
"idx_roms_name_fs_name_fulltext",
|
||||
"idx_roms_name_trgm",
|
||||
"idx_roms_fs_name_trgm",
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add chd_sha1_hash to rom_files
|
||||
|
||||
Revision ID: 0080_add_chd_sha1_hash
|
||||
Revises: 0079_add_rom_files_rom_id_index
|
||||
Create Date: 2026-04-24 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0080_add_chd_sha1_hash"
|
||||
down_revision = "0079_add_rom_files_rom_id_index"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("rom_files", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("chd_sha1_hash", sa.String(length=100), nullable=True),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("rom_files", schema=None) as batch_op:
|
||||
batch_op.drop_column("chd_sha1_hash", if_exists=True)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Add archive_members JSON column to rom_files
|
||||
|
||||
Revision ID: 0081_add_archive_members
|
||||
Revises: 0080_add_chd_sha1_hash
|
||||
Create Date: 2026-05-28 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from utils.database import CustomJSON
|
||||
|
||||
revision = "0081_add_archive_members"
|
||||
down_revision = "0080_add_chd_sha1_hash"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("rom_files", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("archive_members", CustomJSON(), nullable=True),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("rom_files", schema=None) as batch_op:
|
||||
batch_op.drop_column("archive_members", if_exists=True)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Track the device that created a save
|
||||
|
||||
Revision ID: 0082_save_origin_device
|
||||
Revises: 0081_add_archive_members
|
||||
Create Date: 2026-06-05 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0082_save_origin_device"
|
||||
down_revision = "0081_add_archive_members"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Order matters on MariaDB: the index must exist before the FK (it backs the
|
||||
# constraint), so create column -> index -> FK.
|
||||
with op.batch_alter_table("saves", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("origin_device_id", sa.String(length=255), nullable=True),
|
||||
if_not_exists=True,
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_saves_origin_device_id", ["origin_device_id"], if_not_exists=True
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_saves_origin_device_id",
|
||||
"devices",
|
||||
["origin_device_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop the FK before the index it backs, then the column.
|
||||
with op.batch_alter_table("saves", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("fk_saves_origin_device_id", type_="foreignkey")
|
||||
batch_op.drop_index("ix_saves_origin_device_id", if_exists=True)
|
||||
batch_op.drop_column("origin_device_id", if_exists=True)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Update rom_file.category column enum to include soundtracks, and add
|
||||
audio_meta JSON column for tag/duration/cover metadata.
|
||||
|
||||
Revision ID: 0083_rom_category_soundtrack
|
||||
Revises: 0082_save_origin_device
|
||||
Create Date: 2026-04-17 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from utils.database import CustomJSON, is_postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0083_rom_category_soundtrack"
|
||||
down_revision = "0082_save_origin_device"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
if is_postgresql(connection):
|
||||
# `ALTER TYPE ... ADD VALUE` must run outside a transaction in PostgreSQL.
|
||||
# autocommit_block breaks out of alembic's wrapping transaction for
|
||||
# exactly this operation.
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(
|
||||
"ALTER TYPE romfilecategory ADD VALUE IF NOT EXISTS 'SOUNDTRACK'"
|
||||
)
|
||||
|
||||
# audio_meta column (added separately so the enum change sticks even
|
||||
# if the JSON column already exists from a partial prior run).
|
||||
op.add_column(
|
||||
"rom_files",
|
||||
sa.Column("audio_meta", CustomJSON(), nullable=True),
|
||||
if_not_exists=True,
|
||||
)
|
||||
else:
|
||||
rom_file_category_enum = sa.Enum(
|
||||
"GAME",
|
||||
"DLC",
|
||||
"HACK",
|
||||
"MANUAL",
|
||||
"PATCH",
|
||||
"UPDATE",
|
||||
"MOD",
|
||||
"DEMO",
|
||||
"TRANSLATION",
|
||||
"PROTOTYPE",
|
||||
"CHEAT",
|
||||
"SOUNDTRACK",
|
||||
name="romfilecategory",
|
||||
)
|
||||
with op.batch_alter_table("rom_files", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"category", type_=rom_file_category_enum, nullable=True
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("audio_meta", CustomJSON(), nullable=True),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# PostgreSQL cannot remove enum values, so on downgrade we only drop the
|
||||
# audio_meta column and leave SOUNDTRACK in the enum. Any rows that still
|
||||
# hold the new value keep working; a re-upgrade is a no-op.
|
||||
with op.batch_alter_table("rom_files", schema=None) as batch_op:
|
||||
batch_op.drop_column("audio_meta", if_exists=True)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Speed up gallery search and name ordering on the roms table
|
||||
|
||||
This migration adds the database structures the gallery relies on to search
|
||||
and sort large libraries without full table scans:
|
||||
|
||||
- Search indexes on roms.name and roms.fs_name, tailored per backend so the
|
||||
search query stays index-backed: a FULLTEXT index on MySQL/MariaDB, and
|
||||
pg_trgm GIN indexes on PostgreSQL (installing the pg_trgm extension first).
|
||||
- A plain idx_roms_name index on roms.name to accelerate name lookups and
|
||||
range scans.
|
||||
- A precomputed, indexed name_sort_key column for natural-sort ordering.
|
||||
Sorting by name previously ran a per-row regexp (strip leading articles,
|
||||
zero-pad numbers) that no index could cover, forcing a full sort on every
|
||||
page. The key is now stored on write and backfilled here, so ordering by
|
||||
name — including deep-offset pages — uses idx_roms_name_sort_key.
|
||||
|
||||
downgrade() drops every object created here in reverse order, leaving the
|
||||
pg_trgm extension in place since other objects may depend on it.
|
||||
|
||||
Revision ID: 0084_add_roms_search_index
|
||||
Revises: 0083_rom_category_soundtrack
|
||||
Create Date: 2026-06-16 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from models.rom import NAME_SORT_KEY_MAX_LENGTH, compute_name_sort_key
|
||||
from utils.database import is_mariadb, is_mysql, is_postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0084_add_roms_search_index"
|
||||
down_revision = "0083_rom_category_soundtrack"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
FULLTEXT_INDEX_NAME = "idx_roms_name_fs_name_fulltext"
|
||||
PG_NAME_INDEX = "idx_roms_name_trgm"
|
||||
PG_FS_NAME_INDEX = "idx_roms_fs_name_trgm"
|
||||
|
||||
_BACKFILL_BATCH = 1000
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. DB-specific search indexes on roms.name and roms.fs_name.
|
||||
if is_mysql(bind) or is_mariadb(bind):
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"CREATE FULLTEXT INDEX {FULLTEXT_INDEX_NAME} "
|
||||
"ON roms (name, fs_name)"
|
||||
)
|
||||
)
|
||||
elif is_postgresql(bind):
|
||||
# pg_trgm is a trusted extension since PostgreSQL 13, so a non-superuser
|
||||
# with CREATE on the database can install it.
|
||||
op.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"CREATE INDEX IF NOT EXISTS {PG_NAME_INDEX} "
|
||||
"ON roms USING gin (name gin_trgm_ops)"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"CREATE INDEX IF NOT EXISTS {PG_FS_NAME_INDEX} "
|
||||
"ON roms USING gin (fs_name gin_trgm_ops)"
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Plain index on roms.name.
|
||||
with op.batch_alter_table("roms", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
"idx_roms_name",
|
||||
["name"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
# 3. Precomputed name_sort_key column for natural-sort ordering.
|
||||
op.add_column(
|
||||
"roms",
|
||||
sa.Column(
|
||||
"name_sort_key",
|
||||
sa.String(length=NAME_SORT_KEY_MAX_LENGTH),
|
||||
nullable=True,
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
roms = sa.table(
|
||||
"roms",
|
||||
sa.column("id", sa.Integer),
|
||||
sa.column("name", sa.String),
|
||||
sa.column("name_sort_key", sa.String),
|
||||
)
|
||||
|
||||
result = bind.execute(sa.select(roms.c.id, roms.c.name))
|
||||
update_stmt = (
|
||||
roms.update()
|
||||
.where(roms.c.id == sa.bindparam("_id"))
|
||||
.values(name_sort_key=sa.bindparam("_key"))
|
||||
)
|
||||
while True:
|
||||
batch = result.fetchmany(_BACKFILL_BATCH)
|
||||
if not batch:
|
||||
break
|
||||
bind.execute(
|
||||
update_stmt,
|
||||
[{"_id": row.id, "_key": compute_name_sort_key(row.name)} for row in batch],
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"idx_roms_name_sort_key", "roms", ["name_sort_key"], if_not_exists=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
# 3. name_sort_key column and its index.
|
||||
op.drop_index("idx_roms_name_sort_key", table_name="roms", if_exists=True)
|
||||
op.drop_column("roms", "name_sort_key", if_exists=True)
|
||||
|
||||
# 2. Plain index on roms.name.
|
||||
with op.batch_alter_table("roms", schema=None) as batch_op:
|
||||
batch_op.drop_index("idx_roms_name", if_exists=True)
|
||||
|
||||
# 1. DB-specific search indexes.
|
||||
if is_mysql(bind) or is_mariadb(bind):
|
||||
op.execute(sa.text(f"DROP INDEX {FULLTEXT_INDEX_NAME} ON roms"))
|
||||
elif is_postgresql(bind):
|
||||
# Leave the pg_trgm extension in place; other objects may depend on it.
|
||||
op.execute(sa.text(f"DROP INDEX IF EXISTS {PG_FS_NAME_INDEX}"))
|
||||
op.execute(sa.text(f"DROP INDEX IF EXISTS {PG_NAME_INDEX}"))
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Update rom_file.category column enum to include screenshots.
|
||||
|
||||
Revision ID: 0085_rom_category_screenshot
|
||||
Revises: 0084_add_roms_search_index
|
||||
Create Date: 2026-06-17 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from utils.database import is_postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0085_rom_category_screenshot"
|
||||
down_revision = "0084_add_roms_search_index"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
if is_postgresql(connection):
|
||||
# `ALTER TYPE ... ADD VALUE` must run outside a transaction in PostgreSQL.
|
||||
# autocommit_block breaks out of alembic's wrapping transaction for
|
||||
# exactly this operation.
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(
|
||||
"ALTER TYPE romfilecategory ADD VALUE IF NOT EXISTS 'SCREENSHOT'"
|
||||
)
|
||||
else:
|
||||
rom_file_category_enum = sa.Enum(
|
||||
"GAME",
|
||||
"DLC",
|
||||
"HACK",
|
||||
"MANUAL",
|
||||
"PATCH",
|
||||
"UPDATE",
|
||||
"MOD",
|
||||
"DEMO",
|
||||
"TRANSLATION",
|
||||
"PROTOTYPE",
|
||||
"CHEAT",
|
||||
"SOUNDTRACK",
|
||||
"SCREENSHOT",
|
||||
name="romfilecategory",
|
||||
)
|
||||
with op.batch_alter_table("rom_files", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"category", type_=rom_file_category_enum, nullable=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# PostgreSQL cannot remove enum values, so the downgrade is a no-op there.
|
||||
# On other backends, narrow the enum back to its pre-screenshot set. Any
|
||||
# rows still holding the new value would block this, but the upload flow
|
||||
# is additive and a re-upgrade is a no-op.
|
||||
connection = op.get_bind()
|
||||
|
||||
if is_postgresql(connection):
|
||||
return
|
||||
|
||||
rom_file_category_enum = sa.Enum(
|
||||
"GAME",
|
||||
"DLC",
|
||||
"HACK",
|
||||
"MANUAL",
|
||||
"PATCH",
|
||||
"UPDATE",
|
||||
"MOD",
|
||||
"DEMO",
|
||||
"TRANSLATION",
|
||||
"PROTOTYPE",
|
||||
"CHEAT",
|
||||
"SOUNDTRACK",
|
||||
name="romfilecategory",
|
||||
)
|
||||
with op.batch_alter_table("rom_files", schema=None) as batch_op:
|
||||
batch_op.alter_column("category", type_=rom_file_category_enum, nullable=True)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Add is_gallery + is_public flags to screenshots for per-user community
|
||||
gallery screenshots (distinct from auto-captured save/state thumbnails).
|
||||
|
||||
Revision ID: 0086_screenshot_visibility
|
||||
Revises: 0085_rom_category_screenshot
|
||||
Create Date: 2026-06-18 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0086_screenshot_visibility"
|
||||
down_revision = "0085_rom_category_screenshot"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"screenshots",
|
||||
sa.Column(
|
||||
"is_gallery",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.add_column(
|
||||
"screenshots",
|
||||
sa.Column(
|
||||
"is_public",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_screenshots_public", "screenshots", ["is_public"], if_not_exists=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_screenshots_public", table_name="screenshots", if_exists=True)
|
||||
op.drop_column("screenshots", "is_public", if_exists=True)
|
||||
op.drop_column("screenshots", "is_gallery", if_exists=True)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add is_public flag to saves and states for per-user community sharing
|
||||
(mirrors screenshots/notes visibility).
|
||||
|
||||
Revision ID: 0087_save_state_visibility
|
||||
Revises: 0086_screenshot_visibility
|
||||
Create Date: 2026-06-20 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0087_save_state_visibility"
|
||||
down_revision = "0086_screenshot_visibility"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in ("saves", "states"):
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column(
|
||||
"is_public",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(f"idx_{table}_public", table, ["is_public"], if_not_exists=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ("saves", "states"):
|
||||
op.drop_index(f"idx_{table}_public", table_name=table, if_exists=True)
|
||||
op.drop_column(table, "is_public", if_exists=True)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add client_device_identifier to devices
|
||||
|
||||
Revision ID: 0088_devices_client_identifier
|
||||
Revises: 0087_save_state_visibility
|
||||
Create Date: 2026-04-24 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0088_devices_client_identifier"
|
||||
down_revision = "0087_save_state_visibility"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("devices", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("client_device_identifier", sa.String(length=255), nullable=True),
|
||||
if_not_exists=True,
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_devices_user_client_identifier",
|
||||
["user_id", "client_device_identifier"],
|
||||
unique=True,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("devices", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_devices_user_client_identifier", if_exists=True)
|
||||
batch_op.drop_column("client_device_identifier", if_exists=True)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Add device_id FK to client_tokens
|
||||
|
||||
Revision ID: 0089_client_tokens_device_id
|
||||
Revises: 0088_devices_client_identifier
|
||||
Create Date: 2026-04-24 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0089_client_tokens_device_id"
|
||||
down_revision = "0088_devices_client_identifier"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("client_tokens", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("device_id", sa.String(length=255), nullable=True),
|
||||
if_not_exists=True,
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_client_tokens_device_id",
|
||||
"devices",
|
||||
["device_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_client_tokens_device_id",
|
||||
["device_id"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("client_tokens", schema=None) as batch_op:
|
||||
# Drop FK before the backing index -- MariaDB refuses otherwise
|
||||
batch_op.drop_constraint("fk_client_tokens_device_id", type_="foreignkey")
|
||||
batch_op.drop_index("ix_client_tokens_device_id", if_exists=True)
|
||||
batch_op.drop_column("device_id", if_exists=True)
|
||||
@@ -89,6 +89,7 @@ REFRESH_RETROACHIEVEMENTS_CACHE_DAYS: Final[int] = safe_int(
|
||||
)
|
||||
|
||||
# LAUNCHBOX
|
||||
LAUNCHBOX_BASE_PATH: Final[str] = f"{ROMM_BASE_PATH}/launchbox"
|
||||
LAUNCHBOX_API_ENABLED: Final[bool] = safe_str_to_bool(_get_env("LAUNCHBOX_API_ENABLED"))
|
||||
|
||||
# PLAYMATCH
|
||||
@@ -141,6 +142,9 @@ INVITE_TOKEN_EXPIRY_SECONDS: Final[int] = safe_int(
|
||||
# OIDC
|
||||
OIDC_ENABLED: Final[bool] = safe_str_to_bool(_get_env("OIDC_ENABLED"))
|
||||
OIDC_AUTOLOGIN: Final[bool] = safe_str_to_bool(_get_env("OIDC_AUTOLOGIN"))
|
||||
OIDC_ALLOW_REGISTRATION: Final[bool] = safe_str_to_bool(
|
||||
_get_env("OIDC_ALLOW_REGISTRATION", "true")
|
||||
)
|
||||
OIDC_PROVIDER: Final[str] = _get_env("OIDC_PROVIDER", "")
|
||||
OIDC_CLIENT_ID: Final[str] = _get_env("OIDC_CLIENT_ID", "")
|
||||
OIDC_CLIENT_SECRET: Final[str] = _get_env("OIDC_CLIENT_SECRET", "")
|
||||
@@ -213,7 +217,7 @@ SCHEDULED_RETROACHIEVEMENTS_PROGRESS_SYNC_CRON: Final[str] = _get_env(
|
||||
)
|
||||
|
||||
# SYNC
|
||||
SYNC_BASE_PATH: Final[str] = _get_env("SYNC_BASE_PATH", f"{ROMM_BASE_PATH}/sync")
|
||||
SYNC_BASE_PATH: Final[str] = f"{ROMM_BASE_PATH}/sync"
|
||||
ENABLE_SYNC_FOLDER_WATCHER: Final[bool] = safe_str_to_bool(
|
||||
_get_env("ENABLE_SYNC_FOLDER_WATCHER")
|
||||
)
|
||||
@@ -226,10 +230,10 @@ SYNC_PUSH_PULL_CRON: Final[str] = _get_env(
|
||||
"*/30 * * * *", # Every 30 minutes
|
||||
)
|
||||
SYNC_SSH_KEYS_PATH: Final[str] = _get_env(
|
||||
"SYNC_SSH_KEYS_PATH", f"{ROMM_BASE_PATH}/sync/keys"
|
||||
"SYNC_SSH_KEYS_PATH", f"{SYNC_BASE_PATH}/keys"
|
||||
)
|
||||
SYNC_SSH_KNOWN_HOSTS_PATH: Final[str] = _get_env(
|
||||
"SYNC_SSH_KNOWN_HOSTS_PATH", f"{ROMM_BASE_PATH}/sync/known_hosts"
|
||||
"SYNC_SSH_KNOWN_HOSTS_PATH", f"{SYNC_BASE_PATH}/known_hosts"
|
||||
)
|
||||
|
||||
# EMULATION
|
||||
@@ -238,6 +242,7 @@ DISABLE_RUFFLE_RS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_RUFFLE_RS"))
|
||||
|
||||
# FRONTEND
|
||||
KIOSK_MODE: Final[bool] = safe_str_to_bool(_get_env("KIOSK_MODE"))
|
||||
DISABLE_LOGS_VIEWER: Final[bool] = safe_str_to_bool(_get_env("DISABLE_LOGS_VIEWER"))
|
||||
|
||||
# LOGGING
|
||||
LOGLEVEL: Final[str] = _get_env("LOGLEVEL", "INFO").upper()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import enum
|
||||
import functools
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Final, NotRequired, TypedDict
|
||||
|
||||
import pydash
|
||||
@@ -49,6 +52,7 @@ DEFAULT_EXCLUDED_FILES: Final = [
|
||||
]
|
||||
DEFAULT_EXCLUDED_DIRS: Final = [
|
||||
"@eaDir",
|
||||
"assets",
|
||||
"__MACOSX",
|
||||
"$RECYCLE.BIN",
|
||||
".Trash-*",
|
||||
@@ -69,8 +73,10 @@ class MetadataMediaType(enum.StrEnum):
|
||||
BEZEL = "bezel"
|
||||
BOX2D = "box2d"
|
||||
BOX2D_BACK = "box2d_back"
|
||||
BOX2D_SIDE = "box2d_side"
|
||||
BOX3D = "box3d"
|
||||
MIXIMAGE = "miximage"
|
||||
MIXIMAGE_V2 = "miximage_v2"
|
||||
PHYSICAL = "physical"
|
||||
SCREENSHOT = "screenshot"
|
||||
TITLE_SCREEN = "title_screen"
|
||||
@@ -114,7 +120,6 @@ class Config:
|
||||
ROMS_FOLDER_NAME: str
|
||||
FIRMWARE_FOLDER_NAME: str
|
||||
SKIP_HASH_CALCULATION: bool
|
||||
HIGH_PRIO_STRUCTURE_PATH: str
|
||||
EJS_DEBUG: bool
|
||||
EJS_CACHE_LIMIT: int | None
|
||||
EJS_DISABLE_AUTO_UNLOAD: bool
|
||||
@@ -133,7 +138,28 @@ class Config:
|
||||
|
||||
def __init__(self, **entries):
|
||||
self.__dict__.update(entries)
|
||||
self.HIGH_PRIO_STRUCTURE_PATH = f"{LIBRARY_BASE_PATH}/{self.ROMS_FOLDER_NAME}"
|
||||
|
||||
@functools.cached_property
|
||||
def has_structure_path_a(self) -> bool:
|
||||
# Structure A ({roms_folder}/{platform}) takes priority: if the top-level roms
|
||||
# folder exists, claim Structure A even if some platform dirs happen to
|
||||
# contain a {roms_folder} sub-folder.
|
||||
roms_path = os.path.join(LIBRARY_BASE_PATH, self.ROMS_FOLDER_NAME)
|
||||
return os.path.isdir(roms_path)
|
||||
|
||||
@functools.cached_property
|
||||
def has_structure_path_b(self) -> bool:
|
||||
if self.has_structure_path_a:
|
||||
return False
|
||||
|
||||
pattern = os.path.join(
|
||||
LIBRARY_BASE_PATH, "*", glob.escape(self.ROMS_FOLDER_NAME)
|
||||
)
|
||||
for match in glob.iglob(pattern):
|
||||
if os.path.isdir(match):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
@@ -163,14 +189,12 @@ class ConfigManager:
|
||||
# Check if the config file is mounted
|
||||
with open(self.config_file, "r") as cf:
|
||||
self._config_file_mounted = True
|
||||
self._raw_config = yaml.load(cf, Loader=SafeLoader) or {}
|
||||
self._raw_config = self._safe_load_yaml(cf)
|
||||
|
||||
# Also check if the config file is writable
|
||||
self._config_file_writable = os.access(self.config_file, os.W_OK)
|
||||
except FileNotFoundError:
|
||||
log.critical(
|
||||
"Config file not found! Any changes made to the configuration will not persist after the application restarts."
|
||||
)
|
||||
self._create_missing_config_file()
|
||||
except PermissionError:
|
||||
log.warning(
|
||||
"Config file not writable! Any changes made to the configuration will not persist after the application restarts."
|
||||
@@ -180,6 +204,41 @@ class ConfigManager:
|
||||
self._parse_config()
|
||||
self._validate_config()
|
||||
|
||||
def _safe_load_yaml(self, cf) -> dict:
|
||||
"""Load YAML, falling back to an empty config on syntax errors so the
|
||||
app can still boot with defaults rather than crashing."""
|
||||
try:
|
||||
return yaml.load(cf, Loader=SafeLoader) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
log.critical(
|
||||
f"Failed to parse {hl(self.config_file, BLUE)}: {exc}. "
|
||||
"Falling back to default configuration, fix the YAML "
|
||||
"syntax to apply your settings."
|
||||
)
|
||||
return {}
|
||||
|
||||
def _create_missing_config_file(self) -> None:
|
||||
log.warning(
|
||||
f"Config file not found, creating an empty config at {hl(self.config_file, BLUE)}"
|
||||
)
|
||||
|
||||
try:
|
||||
config_file = Path(self.config_file)
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file.touch(exist_ok=True)
|
||||
|
||||
# Reset any previously loaded singleton state so parsing reflects
|
||||
# the newly created empty config file.
|
||||
self._raw_config = {}
|
||||
self._config_file_mounted = True
|
||||
self._config_file_writable = os.access(self.config_file, os.W_OK)
|
||||
except PermissionError:
|
||||
self._config_file_mounted = False
|
||||
self._config_file_writable = False
|
||||
log.critical(
|
||||
"Config file not found and could not be created! Any changes made to the configuration will not persist after the application restarts."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_db_engine() -> URL:
|
||||
"""Builds the database connection string using environment variables
|
||||
@@ -602,17 +661,25 @@ class ConfigManager:
|
||||
log.critical("Invalid config.yml: scan.media must be a list")
|
||||
sys.exit(3)
|
||||
|
||||
for media in self.config.SCAN_MEDIA:
|
||||
if media not in MetadataMediaType:
|
||||
log.critical(
|
||||
f"Invalid config.yml: scan.media.{media} is not a valid media type"
|
||||
)
|
||||
sys.exit(3)
|
||||
# Drop unknown media types rather than exiting, since a newer release
|
||||
# may ship sample configs referencing media types this version doesn't know.
|
||||
unknown_media = [
|
||||
m for m in self.config.SCAN_MEDIA if m not in MetadataMediaType
|
||||
]
|
||||
if unknown_media:
|
||||
log.warning(
|
||||
f"Ignoring unknown values in scan.media: {unknown_media}. "
|
||||
"These may be from a newer RomM version; update to use them."
|
||||
)
|
||||
self.config.SCAN_MEDIA = [
|
||||
m for m in self.config.SCAN_MEDIA if m in MetadataMediaType
|
||||
]
|
||||
|
||||
valid_thumbnail_options = {
|
||||
MetadataMediaType.BOX2D,
|
||||
MetadataMediaType.BOX3D,
|
||||
MetadataMediaType.MIXIMAGE,
|
||||
MetadataMediaType.MIXIMAGE_V2,
|
||||
MetadataMediaType.PHYSICAL,
|
||||
}
|
||||
if not isinstance(self.config.GAMELIST_MEDIA_THUMBNAIL, str):
|
||||
@@ -621,14 +688,17 @@ class ConfigManager:
|
||||
)
|
||||
sys.exit(3)
|
||||
if self.config.GAMELIST_MEDIA_THUMBNAIL not in valid_thumbnail_options:
|
||||
log.critical(
|
||||
f"Invalid config.yml: scan.gamelist.media.thumbnail must be one of {valid_thumbnail_options}"
|
||||
log.warning(
|
||||
f"Unknown scan.gamelist.media.thumbnail value "
|
||||
f"{self.config.GAMELIST_MEDIA_THUMBNAIL!r}; falling back to "
|
||||
f"{MetadataMediaType.BOX2D.value!r}. Valid options: {sorted(o.value for o in valid_thumbnail_options)}."
|
||||
)
|
||||
sys.exit(3)
|
||||
self.config.GAMELIST_MEDIA_THUMBNAIL = MetadataMediaType.BOX2D
|
||||
|
||||
valid_image_options = {
|
||||
MetadataMediaType.TITLE_SCREEN,
|
||||
MetadataMediaType.MIXIMAGE,
|
||||
MetadataMediaType.MIXIMAGE_V2,
|
||||
MetadataMediaType.BOX2D,
|
||||
MetadataMediaType.SCREENSHOT,
|
||||
}
|
||||
@@ -640,15 +710,17 @@ class ConfigManager:
|
||||
sys.exit(3)
|
||||
|
||||
if self.config.GAMELIST_MEDIA_IMAGE not in valid_image_options:
|
||||
log.critical(
|
||||
f"Invalid config.yml: scan.gamelist.media.image must be one of {valid_image_options}"
|
||||
log.warning(
|
||||
f"Unknown scan.gamelist.media.image value "
|
||||
f"{self.config.GAMELIST_MEDIA_IMAGE!r}; falling back to "
|
||||
f"{MetadataMediaType.SCREENSHOT.value!r}. Valid options: {sorted(o.value for o in valid_image_options)}."
|
||||
)
|
||||
sys.exit(3)
|
||||
self.config.GAMELIST_MEDIA_IMAGE = MetadataMediaType.SCREENSHOT
|
||||
|
||||
def get_config(self) -> Config:
|
||||
try:
|
||||
with open(self.config_file, "r") as config_file:
|
||||
self._raw_config = yaml.load(config_file, Loader=SafeLoader) or {}
|
||||
self._raw_config = self._safe_load_yaml(config_file)
|
||||
except FileNotFoundError:
|
||||
log.debug("Config file not found!")
|
||||
|
||||
@@ -682,6 +754,7 @@ class ConfigManager:
|
||||
"filesystem": {
|
||||
"roms_folder": self.config.ROMS_FOLDER_NAME,
|
||||
"firmware_folder": self.config.FIRMWARE_FOLDER_NAME,
|
||||
"skip_hash_calculation": self.config.SKIP_HASH_CALCULATION,
|
||||
},
|
||||
"system": {
|
||||
"platforms": self.config.PLATFORMS_BINDING,
|
||||
@@ -710,8 +783,8 @@ class ConfigManager:
|
||||
"gamelist": {
|
||||
"export": self.config.GAMELIST_AUTO_EXPORT_ON_SCAN,
|
||||
"media": {
|
||||
"thumbnail": self.config.GAMELIST_MEDIA_THUMBNAIL,
|
||||
"image": self.config.GAMELIST_MEDIA_IMAGE,
|
||||
"thumbnail": str(self.config.GAMELIST_MEDIA_THUMBNAIL),
|
||||
"image": str(self.config.GAMELIST_MEDIA_IMAGE),
|
||||
},
|
||||
},
|
||||
"pegasus": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
# When running under pytest-xdist, give each worker its own database so the
|
||||
# autouse `clear_database` fixture in one worker can't wipe rows another worker
|
||||
# is mid-test with. This must run before any application module (config /
|
||||
# database handlers) is imported, so the engine built at import time binds to
|
||||
# the per-worker name. As the rootdir conftest, this file is imported before
|
||||
# `tests/conftest.py` (which imports those modules).
|
||||
#
|
||||
# The Redis cache needs no equivalent handling: under pytest it is an in-process
|
||||
# FakeRedis, so each worker process is already isolated.
|
||||
_xdist_worker = os.environ.get("PYTEST_XDIST_WORKER")
|
||||
if _xdist_worker:
|
||||
_base_db_name = os.environ.get("DB_NAME", "romm_test")
|
||||
os.environ["DB_NAME"] = f"{_base_db_name}_{_xdist_worker}"
|
||||
@@ -10,7 +10,8 @@ from logger.logger import log
|
||||
def begin_session(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if hasattr(kwargs, "session"):
|
||||
# Reuse a caller-provided session so the handler can join an existing unit of work
|
||||
if kwargs.get("session") is not None:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Local test infrastructure matching .github/workflows/pytest.yml.
|
||||
# Usage: docker compose -f docker-compose.test.yml up -d
|
||||
# uv run pytest
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.11
|
||||
ports:
|
||||
- "3306:3306"
|
||||
environment:
|
||||
MYSQL_USER: romm_test
|
||||
MYSQL_PASSWORD: passwd
|
||||
MYSQL_DATABASE: romm_test
|
||||
MYSQL_ROOT_PASSWORD: passwd
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"mariadb-admin",
|
||||
"ping",
|
||||
"-h",
|
||||
"127.0.0.1",
|
||||
"-uroot",
|
||||
"-ppasswd",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:7.2
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
@@ -0,0 +1,137 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from decorators.auth import protected_route
|
||||
from endpoints.responses.activity import ActivityClearSchema, ActivityEntrySchema
|
||||
from handler.activity_handler import ActivityEntry, activity_handler
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_device_handler, db_rom_handler, db_save_handler
|
||||
from handler.socket_handler import socket_handler
|
||||
from logger.logger import log
|
||||
from utils.router import APIRouter
|
||||
from utils.screenshots import continue_playing_screenshot
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/activity",
|
||||
tags=["activity"],
|
||||
)
|
||||
|
||||
|
||||
class DeviceHeartbeatPayload(BaseModel):
|
||||
rom_id: int = Field(ge=1)
|
||||
device_id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
@protected_route(router.get, "", [Scope.ROMS_USER_READ])
|
||||
async def get_all_activity(request: Request) -> list[ActivityEntrySchema]:
|
||||
"""Return every currently active play session across all users."""
|
||||
entries = await activity_handler.get_all_active()
|
||||
return [ActivityEntrySchema(**e) for e in entries]
|
||||
|
||||
|
||||
@protected_route(router.get, "/rom/{rom_id}", [Scope.ROMS_USER_READ])
|
||||
async def get_rom_activity(request: Request, rom_id: int) -> list[ActivityEntrySchema]:
|
||||
"""Return all active play sessions for a specific ROM."""
|
||||
entries = await activity_handler.get_active_for_rom(rom_id)
|
||||
return [ActivityEntrySchema(**e) for e in entries]
|
||||
|
||||
|
||||
@protected_route(router.post, "/heartbeat", [Scope.ROMS_USER_WRITE])
|
||||
async def device_heartbeat(
|
||||
request: Request, payload: DeviceHeartbeatPayload
|
||||
) -> ActivityEntrySchema:
|
||||
"""Heartbeat endpoint for external devices (muOS, Android, etc.).
|
||||
|
||||
Called periodically by devices while the user is playing a game. Writes
|
||||
activity state to Redis and broadcasts an ``activity:update`` event over
|
||||
the main Socket.IO namespace.
|
||||
"""
|
||||
rom = db_rom_handler.get_rom(payload.rom_id)
|
||||
if rom is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"ROM {payload.rom_id} not found",
|
||||
)
|
||||
|
||||
device = db_device_handler.get_device(
|
||||
device_id=payload.device_id, user_id=request.user.id
|
||||
)
|
||||
if device is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Device {payload.device_id} not found for this user",
|
||||
)
|
||||
|
||||
# Preserve the started_at from the existing entry if we are refreshing.
|
||||
existing = await activity_handler.get_active(request.user.id, device.id)
|
||||
started_at = (
|
||||
existing["started_at"] if existing else datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
latest_save = db_save_handler.get_latest_saves_for_roms(
|
||||
user_id=request.user.id, rom_ids=[rom.id]
|
||||
).get(rom.id)
|
||||
screenshot_path = continue_playing_screenshot(rom, latest_save) or ""
|
||||
|
||||
platform = rom.platform
|
||||
entry = ActivityEntry(
|
||||
user_id=request.user.id,
|
||||
username=request.user.username,
|
||||
avatar_path=request.user.avatar_path or "",
|
||||
rom_id=rom.id,
|
||||
rom_name=rom.name or rom.fs_name,
|
||||
rom_cover_path=rom.path_cover_s or "",
|
||||
screenshot_path=screenshot_path,
|
||||
platform_slug=platform.slug if platform else "",
|
||||
platform_name=(platform.custom_name or platform.name) if platform else "",
|
||||
device_id=device.id,
|
||||
device_type=device.client or "unknown",
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
await activity_handler.set_active(entry)
|
||||
|
||||
# Update the device last_seen as a side-effect (mirrors play session ingest).
|
||||
db_device_handler.update_last_seen(device_id=device.id, user_id=request.user.id)
|
||||
|
||||
# Broadcast to all connected sockets. The REST app shares this process with
|
||||
# the Socket.IO server, so emit through the already-initialised, Redis-backed
|
||||
# server (it fans out across workers) rather than opening a manager per call.
|
||||
try:
|
||||
await socket_handler.socket_server.emit("activity:update", dict(entry))
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
f"Failed to broadcast activity:update for user {request.user.id}: {e}"
|
||||
)
|
||||
|
||||
return ActivityEntrySchema(**entry)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.delete,
|
||||
"/heartbeat",
|
||||
[Scope.ROMS_USER_WRITE],
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def clear_device_activity(request: Request, device_id: str) -> None:
|
||||
"""Immediately clear an active session for a device (e.g. on graceful exit)."""
|
||||
rom_id = await activity_handler.clear_active(request.user.id, device_id)
|
||||
if rom_id is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
await socket_handler.socket_server.emit(
|
||||
"activity:clear",
|
||||
ActivityClearSchema(
|
||||
user_id=request.user.id,
|
||||
device_id=device_id,
|
||||
rom_id=rom_id,
|
||||
).model_dump(),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
f"Failed to broadcast activity:clear for user {request.user.id}: {e}"
|
||||
)
|
||||
return None
|
||||
@@ -6,6 +6,7 @@ from typing import Annotated
|
||||
from fastapi import File, Form, HTTPException
|
||||
from fastapi import Path as PathVar
|
||||
from fastapi import Query, Request, UploadFile, status
|
||||
from pydantic import BaseModel as PydanticBaseModel
|
||||
|
||||
from decorators.auth import protected_route
|
||||
from endpoints.responses.collection import (
|
||||
@@ -21,6 +22,7 @@ from exceptions.endpoint_exceptions import (
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_collection_handler
|
||||
from handler.filesystem import fs_resource_handler
|
||||
from handler.filesystem.assets_handler import validate_image_upload
|
||||
from handler.filesystem.base_handler import CoverSize
|
||||
from logger.formatter import BLUE
|
||||
from logger.formatter import highlight as hl
|
||||
@@ -81,7 +83,7 @@ async def add_collection(
|
||||
|
||||
try:
|
||||
if artwork is not None and artwork.filename is not None:
|
||||
file_ext = artwork.filename.split(".")[-1]
|
||||
file_ext = validate_image_upload(artwork, label="Artwork")
|
||||
artwork_content = BytesIO(await artwork.read())
|
||||
(
|
||||
path_cover_l,
|
||||
@@ -426,7 +428,7 @@ async def update_collection(
|
||||
cleaned_data.update({"url_cover": ""})
|
||||
else:
|
||||
if artwork is not None and artwork.filename is not None:
|
||||
file_ext = artwork.filename.split(".")[-1]
|
||||
file_ext = validate_image_upload(artwork, label="Artwork")
|
||||
artwork_content = BytesIO(await artwork.read())
|
||||
(
|
||||
path_cover_l,
|
||||
@@ -474,6 +476,68 @@ async def update_collection(
|
||||
return CollectionSchema.model_validate(updated_collection)
|
||||
|
||||
|
||||
class CollectionRomsPayload(PydanticBaseModel):
|
||||
rom_ids: list[int]
|
||||
|
||||
|
||||
@protected_route(router.post, "/{id}/roms", [Scope.COLLECTIONS_WRITE])
|
||||
async def add_roms_to_collection(
|
||||
request: Request,
|
||||
id: int,
|
||||
payload: CollectionRomsPayload,
|
||||
) -> CollectionSchema:
|
||||
"""Atomically add ROMs to a collection without replacing the full list.
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
id (int): Collection id
|
||||
payload (CollectionRomsPayload): ROM IDs to add
|
||||
|
||||
Returns:
|
||||
CollectionSchema: Updated collection
|
||||
"""
|
||||
collection = db_collection_handler.get_collection(id)
|
||||
if not collection:
|
||||
raise CollectionNotFoundInDatabaseException(id)
|
||||
|
||||
if collection.user_id != request.user.id:
|
||||
raise CollectionPermissionError(id)
|
||||
|
||||
updated_collection = db_collection_handler.add_roms_to_collection(
|
||||
id, payload.rom_ids
|
||||
)
|
||||
return CollectionSchema.model_validate(updated_collection)
|
||||
|
||||
|
||||
@protected_route(router.delete, "/{id}/roms", [Scope.COLLECTIONS_WRITE])
|
||||
async def remove_roms_from_collection(
|
||||
request: Request,
|
||||
id: int,
|
||||
payload: CollectionRomsPayload,
|
||||
) -> CollectionSchema:
|
||||
"""Atomically remove ROMs from a collection without replacing the full list.
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
id (int): Collection id
|
||||
payload (CollectionRomsPayload): ROM IDs to remove
|
||||
|
||||
Returns:
|
||||
CollectionSchema: Updated collection
|
||||
"""
|
||||
collection = db_collection_handler.get_collection(id)
|
||||
if not collection:
|
||||
raise CollectionNotFoundInDatabaseException(id)
|
||||
|
||||
if collection.user_id != request.user.id:
|
||||
raise CollectionPermissionError(id)
|
||||
|
||||
updated_collection = db_collection_handler.remove_roms_from_collection(
|
||||
id, payload.rom_ids
|
||||
)
|
||||
return CollectionSchema.model_validate(updated_collection)
|
||||
|
||||
|
||||
@protected_route(router.put, "/smart/{id}", [Scope.COLLECTIONS_WRITE])
|
||||
async def update_smart_collection(
|
||||
request: Request,
|
||||
|
||||
@@ -8,7 +8,7 @@ from decorators.auth import protected_route
|
||||
from endpoints.responses.device import DeviceCreateResponse, DeviceSchema
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_device_handler, db_device_save_sync_handler
|
||||
from handler.filesystem import fs_sync_handler
|
||||
from handler.filesystem import get_fs_sync_handler
|
||||
from logger.logger import log
|
||||
from models.device import Device, SyncMode
|
||||
from utils.router import APIRouter
|
||||
@@ -123,7 +123,7 @@ def register_device(
|
||||
# Auto-create sync folders for file_transfer devices
|
||||
if payload.sync_mode == SyncMode.FILE_TRANSFER:
|
||||
try:
|
||||
fs_sync_handler.ensure_device_directories(device_id)
|
||||
get_fs_sync_handler().ensure_device_directories(device_id)
|
||||
except Exception:
|
||||
log.warning(f"Failed to create sync directories for device {device_id}")
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Device authorization flow endpoints (RFC 8628-style, tailored for RomM).
|
||||
|
||||
A device POSTs to /authorize with its metadata and requested scopes; the server
|
||||
returns a device_code (secret, for polling) and a user_code (short, for QR).
|
||||
The user scans the QR on their phone, lands on /pair/device in the web UI,
|
||||
approves (possibly editing scopes and device name), and the device's next poll
|
||||
on /token returns a ClientToken bound 1:1 to a Device record.
|
||||
|
||||
The flow is unauthenticated on /authorize and /token (the whole point — the
|
||||
device has no credentials yet). State lives exclusively in Redis with a hard
|
||||
10-minute TTL ceiling.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from decorators.auth import protected_route
|
||||
from endpoints.responses.device_auth import (
|
||||
DeviceAuthApprovePayload,
|
||||
DeviceAuthApproveResponse,
|
||||
DeviceAuthDenyPayload,
|
||||
DeviceAuthInitPayload,
|
||||
DeviceAuthInitResponse,
|
||||
DeviceAuthPendingSchema,
|
||||
DeviceAuthTokenPayload,
|
||||
DeviceAuthTokenResponse,
|
||||
)
|
||||
from handler.auth import auth_handler
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_client_token_handler, db_device_handler
|
||||
from handler.database.base_handler import sync_session
|
||||
from logger.logger import log
|
||||
from models.client_token import ClientToken
|
||||
from models.device import Device, SyncMode
|
||||
from utils.client_tokens import parse_expiry
|
||||
from utils.device_auth import (
|
||||
PENDING_TTL_SECONDS,
|
||||
POLL_DEFAULT_INTERVAL_SECONDS,
|
||||
FlowStatus,
|
||||
build_verification_paths,
|
||||
check_authorize_rate_limit,
|
||||
check_token_poll_rate_limit,
|
||||
consume_approved,
|
||||
generate_device_code,
|
||||
generate_user_code,
|
||||
load_pending,
|
||||
mark_approved,
|
||||
mark_denied,
|
||||
normalize_user_code,
|
||||
pending_expires_at,
|
||||
polled_too_fast,
|
||||
resolve_device_code_from_user_code,
|
||||
store_pending,
|
||||
)
|
||||
from utils.router import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/auth/device", tags=["device-auth"])
|
||||
|
||||
|
||||
def _device_code_prefix(device_code: str) -> str:
|
||||
"""Short prefix for log lines — never log the full secret."""
|
||||
return device_code[:8]
|
||||
|
||||
|
||||
@router.post("/init", status_code=status.HTTP_201_CREATED)
|
||||
def device_auth_init(
|
||||
request: Request, payload: DeviceAuthInitPayload
|
||||
) -> DeviceAuthInitResponse:
|
||||
"""Device-initiated: start a new pairing flow. Open endpoint, rate-limited."""
|
||||
check_authorize_rate_limit(request)
|
||||
|
||||
device_code = generate_device_code()
|
||||
user_code = generate_user_code()
|
||||
|
||||
store_pending(
|
||||
device_code,
|
||||
user_code,
|
||||
{
|
||||
"client_device_identifier": payload.client_device_identifier,
|
||||
"name": payload.name,
|
||||
"client": payload.client,
|
||||
"platform": payload.platform,
|
||||
"client_version": payload.client_version,
|
||||
"requested_scopes": payload.requested_scopes,
|
||||
"interval": POLL_DEFAULT_INTERVAL_SECONDS,
|
||||
},
|
||||
)
|
||||
|
||||
verification_path, verification_path_complete = build_verification_paths(user_code)
|
||||
|
||||
log.info(
|
||||
f"device_auth.init client={payload.client} "
|
||||
f"identifier={payload.client_device_identifier} "
|
||||
f"device_code_prefix={_device_code_prefix(device_code)}"
|
||||
)
|
||||
|
||||
return DeviceAuthInitResponse(
|
||||
device_code=device_code,
|
||||
user_code=user_code,
|
||||
verification_path=verification_path,
|
||||
verification_path_complete=verification_path_complete,
|
||||
expires_in=PENDING_TTL_SECONDS,
|
||||
interval=POLL_DEFAULT_INTERVAL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@protected_route(router.get, "/pending/{user_code}", [Scope.ME_READ])
|
||||
def get_pending(request: Request, user_code: str) -> DeviceAuthPendingSchema:
|
||||
"""Fetch a pending request's metadata for the approval screen."""
|
||||
normalized = normalize_user_code(user_code)
|
||||
device_code = resolve_device_code_from_user_code(normalized)
|
||||
if device_code is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Unknown or expired code",
|
||||
)
|
||||
|
||||
data = load_pending(device_code)
|
||||
if data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Unknown or expired code",
|
||||
)
|
||||
if data.get("status") != FlowStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail=f"Code already {data.get('status')}",
|
||||
)
|
||||
|
||||
user_scopes = {str(s) for s in request.user.oauth_scopes}
|
||||
requested = list(data.get("requested_scopes", []))
|
||||
allowed = sorted(set(requested) & user_scopes)
|
||||
|
||||
return DeviceAuthPendingSchema(
|
||||
client_device_identifier=data["client_device_identifier"],
|
||||
name=data["name"],
|
||||
client=data["client"],
|
||||
platform=data.get("platform"),
|
||||
client_version=data.get("client_version"),
|
||||
requested_scopes=sorted(requested),
|
||||
allowed_scopes=allowed,
|
||||
expires_at=pending_expires_at(device_code),
|
||||
)
|
||||
|
||||
|
||||
@protected_route(router.post, "/approve", [Scope.ME_WRITE])
|
||||
def approve(
|
||||
request: Request, payload: DeviceAuthApprovePayload
|
||||
) -> DeviceAuthApproveResponse:
|
||||
"""Create the Device + bound ClientToken and mark the request approved."""
|
||||
normalized = normalize_user_code(payload.user_code)
|
||||
device_code = resolve_device_code_from_user_code(normalized)
|
||||
if device_code is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Unknown or expired code",
|
||||
)
|
||||
|
||||
data = load_pending(device_code)
|
||||
if data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Unknown or expired code",
|
||||
)
|
||||
if data.get("status") != FlowStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail=f"Code already {data.get('status')}",
|
||||
)
|
||||
|
||||
user_scopes = {str(s) for s in request.user.oauth_scopes}
|
||||
requested = set(data.get("requested_scopes", []))
|
||||
allowed = requested & user_scopes
|
||||
approved_set = set(payload.approved_scopes)
|
||||
if not approved_set or not approved_set.issubset(allowed):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Approved scopes exceed what's allowed for this user",
|
||||
)
|
||||
|
||||
expires_at = parse_expiry(payload.expires_in)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
device_name = payload.device_name or data["name"]
|
||||
|
||||
raw_token = auth_handler.generate_client_token()
|
||||
|
||||
# Create/update the Device and its bound ClientToken in a single unit of work
|
||||
with sync_session.begin() as session:
|
||||
existing = db_device_handler.get_device_by_client_identifier(
|
||||
user_id=request.user.id,
|
||||
client_device_identifier=data["client_device_identifier"],
|
||||
session=session,
|
||||
)
|
||||
if existing is not None:
|
||||
update_data = {
|
||||
"name": device_name,
|
||||
"last_seen": now,
|
||||
}
|
||||
client_version = data.get("client_version")
|
||||
if client_version is not None:
|
||||
update_data["client_version"] = client_version
|
||||
device = (
|
||||
db_device_handler.update_device(
|
||||
device_id=existing.id,
|
||||
user_id=request.user.id,
|
||||
data=update_data,
|
||||
session=session,
|
||||
)
|
||||
or existing
|
||||
)
|
||||
else:
|
||||
device = db_device_handler.add_device(
|
||||
Device(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=request.user.id,
|
||||
name=device_name,
|
||||
client=data.get("client"),
|
||||
platform=data.get("platform"),
|
||||
client_version=data.get("client_version"),
|
||||
client_device_identifier=data["client_device_identifier"],
|
||||
sync_mode=SyncMode.API,
|
||||
last_seen=now,
|
||||
),
|
||||
session=session,
|
||||
)
|
||||
|
||||
token = db_client_token_handler.add_token(
|
||||
ClientToken(
|
||||
user_id=request.user.id,
|
||||
name=device_name,
|
||||
hashed_token=auth_handler.hash_client_token(raw_token),
|
||||
scopes=" ".join(sorted(approved_set)),
|
||||
expires_at=expires_at,
|
||||
device_id=device.id,
|
||||
),
|
||||
session=session,
|
||||
)
|
||||
|
||||
mark_approved(
|
||||
device_code,
|
||||
raw_token=raw_token,
|
||||
device_id=device.id,
|
||||
scopes=sorted(approved_set),
|
||||
expires_at=token.expires_at,
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"device_auth.approve user_id={request.user.id} "
|
||||
f"device_id={device.id} token_id={token.id} "
|
||||
f"device_code_prefix={_device_code_prefix(device_code)} "
|
||||
f"scopes={' '.join(sorted(approved_set))}"
|
||||
)
|
||||
|
||||
return DeviceAuthApproveResponse(device_id=device.id, device_name=device_name)
|
||||
|
||||
|
||||
@protected_route(router.post, "/deny", [Scope.ME_WRITE])
|
||||
def deny(request: Request, payload: DeviceAuthDenyPayload) -> None:
|
||||
normalized = normalize_user_code(payload.user_code)
|
||||
device_code = resolve_device_code_from_user_code(normalized)
|
||||
if device_code is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Unknown or expired code",
|
||||
)
|
||||
|
||||
data = load_pending(device_code)
|
||||
if data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Unknown or expired code",
|
||||
)
|
||||
if data.get("status") != FlowStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail=f"Code already {data.get('status')}",
|
||||
)
|
||||
|
||||
mark_denied(device_code)
|
||||
log.info(
|
||||
f"device_auth.deny user_id={request.user.id} "
|
||||
f"device_code_prefix={_device_code_prefix(device_code)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
def token(request: Request, payload: DeviceAuthTokenPayload) -> DeviceAuthTokenResponse:
|
||||
"""Device-facing polling endpoint. Open, rate-limited per-IP and per-code."""
|
||||
check_token_poll_rate_limit(request)
|
||||
|
||||
data = load_pending(payload.device_code)
|
||||
if data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="expired_token",
|
||||
)
|
||||
|
||||
state = data.get("status")
|
||||
|
||||
if state == FlowStatus.PENDING:
|
||||
interval = int(data.get("interval", POLL_DEFAULT_INTERVAL_SECONDS))
|
||||
if polled_too_fast(payload.device_code, interval):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="slow_down",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="authorization_pending",
|
||||
)
|
||||
|
||||
if state == FlowStatus.DENIED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="access_denied",
|
||||
)
|
||||
|
||||
if state == FlowStatus.APPROVED:
|
||||
approved = consume_approved(payload.device_code)
|
||||
if approved is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="expired_token",
|
||||
)
|
||||
expires_at_raw = approved.get("expires_at")
|
||||
expires_at = datetime.fromisoformat(expires_at_raw) if expires_at_raw else None
|
||||
log.info(
|
||||
f"device_auth.token_issued device_id={approved['device_id']} "
|
||||
f"device_code_prefix={_device_code_prefix(payload.device_code)}"
|
||||
)
|
||||
return DeviceAuthTokenResponse(
|
||||
access_token=approved["raw_token"],
|
||||
device_id=approved["device_id"],
|
||||
scopes=list(approved.get("scopes", [])),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="expired_token",
|
||||
)
|
||||
+26
-10
@@ -40,7 +40,6 @@ from endpoints.responses.feeds import (
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_platform_handler, db_rom_handler
|
||||
from handler.filesystem import fs_rom_handler
|
||||
from handler.filesystem.roms_handler import is_compressed_file
|
||||
from handler.metadata import meta_igdb_handler
|
||||
from handler.metadata.base_handler import (
|
||||
SONY_SERIAL_REGEX,
|
||||
@@ -49,6 +48,7 @@ from handler.metadata.base_handler import (
|
||||
)
|
||||
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
|
||||
from models.rom import Rom, RomFile, RomFileCategory
|
||||
from utils.archives import is_compressed_file
|
||||
from utils.router import APIRouter
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ async def tinfoil_index_feed(
|
||||
|
||||
return titledb
|
||||
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[switch.id])
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[switch.id], include_files=True)
|
||||
|
||||
return TinfoilFeedSchema(
|
||||
files=[
|
||||
@@ -329,7 +329,9 @@ def pkgi_ps3_feed(
|
||||
status_code=400, detail=f"Invalid content type: {content_type}"
|
||||
) from e
|
||||
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[ps3_platform.id])
|
||||
roms = db_rom_handler.get_roms_scalar(
|
||||
platform_ids=[ps3_platform.id], include_files=True
|
||||
)
|
||||
txt_lines = []
|
||||
|
||||
for rom in roms:
|
||||
@@ -405,7 +407,9 @@ def pkgi_psvita_feed(
|
||||
status_code=400, detail=f"Invalid content type: {content_type}"
|
||||
) from e
|
||||
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[psvita_platform.id])
|
||||
roms = db_rom_handler.get_roms_scalar(
|
||||
platform_ids=[psvita_platform.id], include_files=True
|
||||
)
|
||||
txt_lines = []
|
||||
|
||||
for rom in roms:
|
||||
@@ -480,7 +484,9 @@ def pkgi_psp_feed(
|
||||
status_code=400, detail=f"Invalid content type: {content_type}"
|
||||
) from e
|
||||
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[psp_platform.id])
|
||||
roms = db_rom_handler.get_roms_scalar(
|
||||
platform_ids=[psp_platform.id], include_files=True
|
||||
)
|
||||
txt_lines = []
|
||||
|
||||
for rom in roms:
|
||||
@@ -663,7 +669,9 @@ def pkgj_psp_games_feed(request: Request) -> Response:
|
||||
status_code=404, detail="PlayStation Portable platform not found"
|
||||
)
|
||||
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id])
|
||||
roms = db_rom_handler.get_roms_scalar(
|
||||
platform_ids=[platform.id], include_files=True
|
||||
)
|
||||
txt_lines = []
|
||||
txt_lines.append(
|
||||
"Title ID\tRegion\tType\tName\tPKG direct link\tContent ID\tLast Modification Date\tRAP\tDownload .RAP file\tFile Size\tSHA256"
|
||||
@@ -727,7 +735,9 @@ def pkgj_psp_dlcs_feed(request: Request) -> Response:
|
||||
status_code=404, detail="PlayStation Portable platform not found"
|
||||
)
|
||||
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id])
|
||||
roms = db_rom_handler.get_roms_scalar(
|
||||
platform_ids=[platform.id], include_files=True
|
||||
)
|
||||
txt_lines = []
|
||||
txt_lines.append(
|
||||
"Title ID\tRegion\tName\tPKG direct link\tContent ID\tLast Modification Date\tRAP\tDownload .RAP file\tFile Size\tSHA256"
|
||||
@@ -789,7 +799,9 @@ def pkgj_psv_games_feed(request: Request) -> Response:
|
||||
status_code=404, detail="PlayStation Vita platform not found"
|
||||
)
|
||||
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id])
|
||||
roms = db_rom_handler.get_roms_scalar(
|
||||
platform_ids=[platform.id], include_files=True
|
||||
)
|
||||
txt_lines = []
|
||||
txt_lines.append(
|
||||
"Title ID\tRegion\tName\tPKG direct link\tzRIF\tContent ID\tLast Modification Date\tOriginal Name\tFile Size\tSHA256\tRequired FW\tApp Version"
|
||||
@@ -854,7 +866,9 @@ def pkgj_psv_dlcs_feed(request: Request) -> Response:
|
||||
status_code=404, detail="PlayStation Vita platform not found"
|
||||
)
|
||||
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id])
|
||||
roms = db_rom_handler.get_roms_scalar(
|
||||
platform_ids=[platform.id], include_files=True
|
||||
)
|
||||
txt_lines = []
|
||||
txt_lines.append(
|
||||
"Title ID\tRegion\tName\tPKG direct link\tzRIF\tContent ID\tLast Modification Date\tFile Size\tSHA256"
|
||||
@@ -911,7 +925,9 @@ def pkgj_psx_games_feed(request: Request) -> Response:
|
||||
if not platform:
|
||||
raise HTTPException(status_code=404, detail="PlayStation platform not found")
|
||||
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id])
|
||||
roms = db_rom_handler.get_roms_scalar(
|
||||
platform_ids=[platform.id], include_files=True
|
||||
)
|
||||
txt_lines = []
|
||||
txt_lines.append(
|
||||
"Title ID\tRegion\tName\tPKG direct link\tContent ID\tLast Modification Date\tOriginal Name\tFile Size\tSHA256"
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import HTTPException, Request, status
|
||||
|
||||
from config import (
|
||||
DISABLE_EMULATOR_JS,
|
||||
DISABLE_LOGS_VIEWER,
|
||||
DISABLE_RUFFLE_RS,
|
||||
DISABLE_SETUP_WIZARD,
|
||||
DISABLE_USERPASS_LOGIN,
|
||||
@@ -30,6 +31,7 @@ from exceptions.fs_exceptions import PlatformAlreadyExistsException
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_user_handler
|
||||
from handler.filesystem import fs_platform_handler
|
||||
from handler.filesystem.base_handler import LibraryStructure
|
||||
from handler.metadata import (
|
||||
meta_flashpoint_handler,
|
||||
meta_gamelist_handler,
|
||||
@@ -117,6 +119,7 @@ async def heartbeat() -> HeartbeatResponse:
|
||||
},
|
||||
"FRONTEND": {
|
||||
"DISABLE_USERPASS_LOGIN": DISABLE_USERPASS_LOGIN,
|
||||
"DISABLE_LOGS_VIEWER": DISABLE_LOGS_VIEWER,
|
||||
"YOUTUBE_BASE_URL": YOUTUBE_BASE_URL,
|
||||
},
|
||||
"OIDC": {
|
||||
@@ -159,6 +162,8 @@ async def metadata_heartbeat(source: str) -> bool:
|
||||
return await meta_launchbox_handler.heartbeat()
|
||||
case MetadataSource.HASHEOUS:
|
||||
return await meta_hasheous_handler.heartbeat()
|
||||
case MetadataSource.PLAYMATCH:
|
||||
return await meta_playmatch_handler.heartbeat()
|
||||
case MetadataSource.TGDB:
|
||||
return await meta_tgdb_handler.heartbeat()
|
||||
case MetadataSource.SGDB:
|
||||
@@ -224,7 +229,7 @@ async def get_setup_library_info(request: Request):
|
||||
rom_count = 0
|
||||
try:
|
||||
# Determine the roms directory based on structure
|
||||
if detected_structure == "struct_a":
|
||||
if detected_structure == LibraryStructure.A:
|
||||
roms_path = os.path.join(
|
||||
LIBRARY_BASE_PATH, cnfg.ROMS_FOLDER_NAME, fs_slug
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from config import DISABLE_LOGS_VIEWER
|
||||
from decorators.auth import protected_route
|
||||
from endpoints.responses.log import LogEntrySchema
|
||||
from endpoints.sockets.logs import get_recent_logs
|
||||
from handler.auth.constants import Scope
|
||||
from logger.log_stream_handler import LOG_BUFFER_SIZE
|
||||
from utils.router import APIRouter
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/logs",
|
||||
tags=["logs"],
|
||||
)
|
||||
|
||||
# Largest backfill the buffer holds — derived from the producer's ring buffer
|
||||
# so the two never drift.
|
||||
MAX_LOG_LIMIT = LOG_BUFFER_SIZE
|
||||
|
||||
|
||||
@protected_route(router.get, "", [Scope.LOGS_READ])
|
||||
async def get_logs(
|
||||
request: Request, limit: int = MAX_LOG_LIMIT
|
||||
) -> list[LogEntrySchema]:
|
||||
"""Return the most recent backend log lines for backfill (admin only).
|
||||
|
||||
Args:
|
||||
request (Request): FastAPI Request object.
|
||||
limit (int): Maximum number of recent lines to return.
|
||||
|
||||
Returns:
|
||||
list[LogEntrySchema]: Recent log lines, oldest-first.
|
||||
"""
|
||||
if DISABLE_LOGS_VIEWER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Logs viewer is disabled"
|
||||
)
|
||||
|
||||
bounded = max(1, min(limit, MAX_LOG_LIMIT))
|
||||
entries = await get_recent_logs(bounded)
|
||||
return [LogEntrySchema(**entry) for entry in entries]
|
||||
@@ -113,7 +113,6 @@ def get_platform(
|
||||
async def update_platform(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Platform id.", ge=1)],
|
||||
aspect_ratio: Annotated[str | None, Body(description="Cover aspect ratio.")] = None,
|
||||
custom_name: Annotated[
|
||||
str | None, Body(description="Custom platform name.")
|
||||
] = None,
|
||||
@@ -124,8 +123,6 @@ async def update_platform(
|
||||
if not platform_db:
|
||||
raise PlatformNotFoundInDatabaseException(id)
|
||||
|
||||
if aspect_ratio is not None:
|
||||
platform_db.aspect_ratio = aspect_ratio
|
||||
if custom_name is not None:
|
||||
platform_db.custom_name = custom_name
|
||||
platform_db = db_platform_handler.add_platform(platform_db)
|
||||
|
||||
@@ -62,6 +62,8 @@ def ingest_play_sessions(
|
||||
detail=f"Batch size exceeds maximum of {MAX_BATCH_SIZE}",
|
||||
)
|
||||
|
||||
device_id = payload.device_id or getattr(request.state, "device_id", None)
|
||||
|
||||
summary = _ingest(
|
||||
user_id=request.user.id,
|
||||
username=request.user.username,
|
||||
@@ -75,7 +77,7 @@ def ingest_play_sessions(
|
||||
}
|
||||
for s in payload.sessions
|
||||
],
|
||||
device_id=payload.device_id,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
return PlaySessionIngestResponse(
|
||||
@@ -103,10 +105,11 @@ def get_play_sessions(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[PlaySessionSchema]:
|
||||
effective_device_id = device_id or getattr(request.state, "device_id", None)
|
||||
sessions = db_play_session_handler.get_sessions(
|
||||
user_id=request.user.id,
|
||||
rom_id=rom_id,
|
||||
device_id=device_id,
|
||||
device_id=effective_device_id,
|
||||
start_after=start_after,
|
||||
end_before=end_before,
|
||||
limit=limit if start_after is None and end_before is None else None,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from mimetypes import guess_type
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from decorators.auth import protected_route
|
||||
from handler.auth.constants import Scope
|
||||
from handler.filesystem import fs_asset_handler
|
||||
from handler.filesystem.assets_handler import SAFE_IMAGE_MIME_TYPES
|
||||
from utils.router import APIRouter
|
||||
|
||||
router = APIRouter(
|
||||
@@ -12,6 +16,24 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def _build_asset_response(resolved_path: Path) -> FileResponse:
|
||||
guessed_type, _ = guess_type(resolved_path.name)
|
||||
if guessed_type in SAFE_IMAGE_MIME_TYPES:
|
||||
return FileResponse(
|
||||
path=str(resolved_path),
|
||||
filename=resolved_path.name,
|
||||
media_type=guessed_type,
|
||||
content_disposition_type="inline",
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=str(resolved_path),
|
||||
filename=resolved_path.name,
|
||||
media_type="application/octet-stream",
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
|
||||
|
||||
@protected_route(router.head, "/assets/{path:path}", [Scope.ASSETS_READ])
|
||||
def head_raw_asset(request: Request, path: str):
|
||||
try:
|
||||
@@ -21,9 +43,9 @@ def head_raw_asset(request: Request, path: str):
|
||||
|
||||
# Check if file exists and is a file (not directory)
|
||||
if not resolved_path.exists() or not resolved_path.is_file():
|
||||
return HTTPException(status_code=404, detail="Asset not found")
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
return FileResponse(path=str(resolved_path), filename=resolved_path.name)
|
||||
return _build_asset_response(resolved_path)
|
||||
|
||||
|
||||
@protected_route(router.get, "/assets/{path:path}", [Scope.ASSETS_READ])
|
||||
@@ -47,9 +69,6 @@ def get_raw_asset(request: Request, path: str):
|
||||
|
||||
# Check if file exists and is a file (not directory)
|
||||
if not resolved_path.exists() or not resolved_path.is_file():
|
||||
return HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
if not resolved_path:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
return FileResponse(path=str(resolved_path), filename=resolved_path.name)
|
||||
return _build_asset_response(resolved_path)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class ActivityEntrySchema(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
avatar_path: str
|
||||
rom_id: int
|
||||
rom_name: str
|
||||
rom_cover_path: str = ""
|
||||
screenshot_path: str = ""
|
||||
platform_slug: str
|
||||
platform_name: str
|
||||
device_id: str
|
||||
device_type: str
|
||||
started_at: str
|
||||
|
||||
|
||||
class ActivityClearSchema(BaseModel):
|
||||
user_id: int
|
||||
device_id: str
|
||||
rom_id: int
|
||||
@@ -30,14 +30,27 @@ class BaseAsset(BaseModel):
|
||||
|
||||
|
||||
class ScreenshotSchema(BaseAsset):
|
||||
pass
|
||||
is_gallery: bool = False
|
||||
is_public: bool = False
|
||||
|
||||
|
||||
class UserScreenshotSchema(ScreenshotSchema):
|
||||
"""A gallery screenshot enriched with its owner's username, for the
|
||||
community (My / Community) view. Mirrors UserNoteSchema."""
|
||||
|
||||
username: str
|
||||
# Author identity for rendering an avatar next to community screenshots.
|
||||
user_avatar_path: str = ""
|
||||
user_updated_at: UTCDatetime | None = None
|
||||
|
||||
|
||||
class SaveSchema(BaseAsset):
|
||||
emulator: str | None
|
||||
slot: str | None = None
|
||||
content_hash: str | None = None
|
||||
is_public: bool = False
|
||||
screenshot: ScreenshotSchema | None
|
||||
origin_device_id: str | None = None
|
||||
device_syncs: list[DeviceSyncSchema] = []
|
||||
|
||||
@model_validator(mode="before")
|
||||
@@ -60,6 +73,16 @@ class SaveSchema(BaseAsset):
|
||||
return result
|
||||
|
||||
|
||||
class UserSaveSchema(SaveSchema):
|
||||
"""A save enriched with its owner's username, for the community (My /
|
||||
Community) view. Mirrors UserScreenshotSchema."""
|
||||
|
||||
username: str
|
||||
# Author identity for rendering an avatar next to community saves.
|
||||
user_avatar_path: str = ""
|
||||
user_updated_at: UTCDatetime | None = None
|
||||
|
||||
|
||||
class SlotSummarySchema(BaseModel):
|
||||
slot: str | None
|
||||
count: int
|
||||
@@ -73,4 +96,15 @@ class SaveSummarySchema(BaseModel):
|
||||
|
||||
class StateSchema(BaseAsset):
|
||||
emulator: str | None
|
||||
is_public: bool = False
|
||||
screenshot: ScreenshotSchema | None
|
||||
|
||||
|
||||
class UserStateSchema(StateSchema):
|
||||
"""A state enriched with its owner's username, for the community (My /
|
||||
Community) view. Mirrors UserScreenshotSchema."""
|
||||
|
||||
username: str
|
||||
# Author identity for rendering an avatar next to community states.
|
||||
user_avatar_path: str = ""
|
||||
user_updated_at: UTCDatetime | None = None
|
||||
|
||||
@@ -13,6 +13,7 @@ class ClientTokenSchema(BaseModel):
|
||||
last_used_at: UTCDatetime | None
|
||||
created_at: UTCDatetime
|
||||
user_id: int
|
||||
device_id: str | None = None
|
||||
|
||||
|
||||
class ClientTokenCreateSchema(ClientTokenSchema):
|
||||
@@ -21,6 +22,8 @@ class ClientTokenCreateSchema(ClientTokenSchema):
|
||||
|
||||
class ClientTokenAdminSchema(ClientTokenSchema):
|
||||
username: str
|
||||
user_avatar_path: str
|
||||
user_updated_at: UTCDatetime
|
||||
|
||||
|
||||
class ClientTokenPairSchema(BaseModel):
|
||||
|
||||
@@ -31,6 +31,7 @@ class DeviceSchema(BaseModel):
|
||||
ip_address: str | None
|
||||
mac_address: str | None
|
||||
hostname: str | None
|
||||
client_device_identifier: str | None
|
||||
sync_mode: SyncMode
|
||||
sync_enabled: bool
|
||||
sync_config: dict | None
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from handler.auth.constants import Scope
|
||||
|
||||
from .base import BaseModel, UTCDatetime
|
||||
|
||||
# A request can never legitimately ask for more than every known scope.
|
||||
_MAX_REQUESTED_SCOPES = len(Scope)
|
||||
_VALID_SCOPES = {str(s) for s in Scope}
|
||||
|
||||
|
||||
class DeviceAuthInitPayload(BaseModel):
|
||||
client_device_identifier: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
client: str = Field(min_length=1, max_length=50)
|
||||
platform: str | None = Field(default=None, max_length=50)
|
||||
client_version: str | None = Field(default=None, max_length=50)
|
||||
requested_scopes: list[str] = Field(min_length=1, max_length=_MAX_REQUESTED_SCOPES)
|
||||
|
||||
@field_validator("requested_scopes")
|
||||
@classmethod
|
||||
def _validate_requested_scopes(cls, value: list[str]) -> list[str]:
|
||||
# Reject invalid scopes at the boundary
|
||||
invalid = [s for s in value if s not in _VALID_SCOPES]
|
||||
if invalid:
|
||||
raise ValueError(f"Unknown scopes: {', '.join(sorted(set(invalid)))}")
|
||||
return value
|
||||
|
||||
|
||||
class DeviceAuthInitResponse(BaseModel):
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_path: str = Field(
|
||||
description=(
|
||||
"Relative web-UI path (/pair/device). The client joins it with the "
|
||||
"origin it was configured to reach; the server is origin-agnostic."
|
||||
)
|
||||
)
|
||||
verification_path_complete: str = Field(
|
||||
description="Same path with ?user_code= appended, for QR display."
|
||||
)
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
class DeviceAuthPendingSchema(BaseModel):
|
||||
client_device_identifier: str
|
||||
name: str
|
||||
client: str
|
||||
platform: str | None
|
||||
client_version: str | None
|
||||
requested_scopes: list[str]
|
||||
allowed_scopes: list[str]
|
||||
expires_at: UTCDatetime
|
||||
|
||||
|
||||
class DeviceAuthApprovePayload(BaseModel):
|
||||
user_code: str = Field(min_length=1, max_length=32)
|
||||
approved_scopes: list[str] = Field(min_length=1)
|
||||
device_name: str | None = Field(default=None, max_length=255)
|
||||
expires_in: str | None = None
|
||||
|
||||
|
||||
class DeviceAuthApproveResponse(BaseModel):
|
||||
device_id: str
|
||||
device_name: str | None
|
||||
|
||||
|
||||
class DeviceAuthDenyPayload(BaseModel):
|
||||
user_code: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class DeviceAuthTokenPayload(BaseModel):
|
||||
device_code: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class DeviceAuthTokenResponse(BaseModel):
|
||||
access_token: str
|
||||
device_id: str
|
||||
scopes: list[str]
|
||||
expires_at: UTCDatetime | None
|
||||
@@ -33,6 +33,7 @@ class EmulationDict(TypedDict):
|
||||
|
||||
class FrontendDict(TypedDict):
|
||||
DISABLE_USERPASS_LOGIN: bool
|
||||
DISABLE_LOGS_VIEWER: bool
|
||||
YOUTUBE_BASE_URL: str
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ class UserSchema(BaseModel):
|
||||
return None
|
||||
|
||||
schema = cls.model_validate(db_user)
|
||||
schema.current_device_id = request.session.get("device_id")
|
||||
schema.current_device_id = getattr(
|
||||
request.state, "device_id", None
|
||||
) or request.session.get("device_id")
|
||||
return schema
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from endpoints.responses.base import BaseModel
|
||||
|
||||
|
||||
class LogEntrySchema(BaseModel):
|
||||
"""A single backend log line streamed to the admin log viewer."""
|
||||
|
||||
ts: int # epoch milliseconds
|
||||
level: str
|
||||
module: str
|
||||
message: str
|
||||
@@ -1,7 +1,5 @@
|
||||
from pydantic import ConfigDict, Field, computed_field, field_validator
|
||||
|
||||
from models.platform import DEFAULT_COVER_ASPECT_RATIO
|
||||
|
||||
from .base import BaseModel, UTCDatetime
|
||||
from .firmware import FirmwareSchema
|
||||
|
||||
@@ -35,7 +33,6 @@ class PlatformSchema(BaseModel):
|
||||
url: str | None = None
|
||||
url_logo: str | None = None
|
||||
firmware: list[FirmwareSchema] = Field(default_factory=list)
|
||||
aspect_ratio: str = DEFAULT_COVER_ASPECT_RATIO
|
||||
created_at: UTCDatetime
|
||||
updated_at: UTCDatetime
|
||||
fs_size_bytes: int
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import NotRequired, TypedDict, get_type_hints
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import ConfigDict, computed_field, field_validator, model_validator
|
||||
from pydantic import ConfigDict, Field, computed_field, field_validator, model_validator
|
||||
|
||||
from endpoints.responses.assets import SaveSchema, ScreenshotSchema, StateSchema
|
||||
from endpoints.responses.assets import (
|
||||
SaveSchema,
|
||||
ScreenshotSchema,
|
||||
StateSchema,
|
||||
UserSaveSchema,
|
||||
UserScreenshotSchema,
|
||||
UserStateSchema,
|
||||
)
|
||||
from handler.metadata.flashpoint_handler import FlashpointMetadata
|
||||
from handler.metadata.gamelist_handler import GamelistMetadata
|
||||
from handler.metadata.hasheous_handler import HasheousMetadata
|
||||
@@ -18,7 +26,7 @@ from handler.metadata.moby_handler import MobyMetadata
|
||||
from handler.metadata.ra_handler import RAMetadata
|
||||
from handler.metadata.ss_handler import SSMetadata
|
||||
from models.collection import Collection
|
||||
from models.rom import Rom, RomFileCategory, RomUserStatus
|
||||
from models.rom import Rom, RomArchiveMember, RomFile, RomFileCategory, RomUserStatus
|
||||
|
||||
from .base import BaseModel, UTCDatetime
|
||||
|
||||
@@ -37,6 +45,9 @@ class UserNoteSchema(BaseModel):
|
||||
updated_at: UTCDatetime
|
||||
user_id: int
|
||||
username: str
|
||||
# Author identity for rendering an avatar next to community notes.
|
||||
user_avatar_path: str = ""
|
||||
user_updated_at: UTCDatetime | None = None
|
||||
|
||||
|
||||
RomIGDBMetadata = TypedDict( # type: ignore[misc]
|
||||
@@ -147,6 +158,21 @@ class RomUserSchema(BaseModel):
|
||||
return rom_user_schema_factory()
|
||||
|
||||
|
||||
class RomFileAudioMetaSchema(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
title: str | None = None
|
||||
artist: str | None = None
|
||||
album: str | None = None
|
||||
year: str | None = None
|
||||
genre: str | None = None
|
||||
track: str | None = None
|
||||
disc: str | None = None
|
||||
duration_seconds: float | None = None
|
||||
has_embedded_cover: bool = False
|
||||
cover_path: str | None = None
|
||||
|
||||
|
||||
class RomFileSchema(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -164,7 +190,19 @@ class RomFileSchema(BaseModel):
|
||||
md5_hash: str | None
|
||||
sha1_hash: str | None
|
||||
ra_hash: str | None
|
||||
chd_sha1_hash: str | None
|
||||
archive_members: list[RomArchiveMember] | None
|
||||
category: RomFileCategory | None
|
||||
audio_meta: RomFileAudioMetaSchema | None = None
|
||||
|
||||
|
||||
class SoundtrackTrackMetaSchema(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
file_id: int
|
||||
file_name: str
|
||||
file_size_bytes: int
|
||||
audio_meta: RomFileAudioMetaSchema | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def default_category_for_non_nested(self) -> RomFileSchema:
|
||||
@@ -251,6 +289,7 @@ class RomSchema(BaseModel):
|
||||
fs_size_bytes: int
|
||||
|
||||
name: str | None
|
||||
name_sort_key: str | None
|
||||
slug: str | None
|
||||
summary: str | None
|
||||
|
||||
@@ -273,6 +312,8 @@ class RomSchema(BaseModel):
|
||||
url_cover: str | None
|
||||
|
||||
has_manual: bool
|
||||
has_manual_files: bool
|
||||
has_soundtrack: bool
|
||||
path_manual: str | None
|
||||
url_manual: str | None
|
||||
|
||||
@@ -295,31 +336,33 @@ class RomSchema(BaseModel):
|
||||
has_simple_single_file: bool
|
||||
has_nested_single_file: bool
|
||||
has_multiple_files: bool
|
||||
files: list[RomFileSchema]
|
||||
full_path: str
|
||||
created_at: UTCDatetime
|
||||
updated_at: UTCDatetime
|
||||
missing_from_fs: bool
|
||||
has_notes: bool
|
||||
|
||||
siblings: list[SiblingRomSchema]
|
||||
rom_user: RomUserSchema
|
||||
merged_screenshots: list[str]
|
||||
merged_ra_metadata: RomRAMetadata | None
|
||||
|
||||
files: list[RomFileSchema] = Field(validation_alias="included_files")
|
||||
sibling_roms: list[SiblingRomSchema] = Field(
|
||||
validation_alias="included_sibling_roms"
|
||||
)
|
||||
|
||||
@field_validator("files")
|
||||
def sort_files(cls, v: list[RomFileSchema]) -> list[RomFileSchema]:
|
||||
return sorted(v, key=lambda x: x.file_name)
|
||||
|
||||
@field_validator("sibling_roms")
|
||||
def sort_sibling_roms(cls, v: list[SiblingRomSchema]) -> list[SiblingRomSchema]:
|
||||
return sorted(v, key=lambda x: x.sort_comparator)
|
||||
|
||||
@classmethod
|
||||
def populate_properties(cls, db_rom: Rom, request: Request) -> Rom:
|
||||
db_rom.rom_user = RomUserSchema.for_user(request.user.id, db_rom) # type: ignore
|
||||
db_rom.siblings = [ # type: ignore
|
||||
SiblingRomSchema(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
fs_name_no_tags=s.fs_name_no_tags,
|
||||
fs_name_no_ext=s.fs_name_no_ext,
|
||||
)
|
||||
for s in db_rom.sibling_roms
|
||||
]
|
||||
db_rom.has_notes = any( # type: ignore
|
||||
db_rom.rom_user = RomUserSchema.for_user(request.user.id, db_rom) # type: ignore[assignment]
|
||||
db_rom.has_notes = any( # type: ignore[assignment]
|
||||
note.is_public or note.user_id == request.user.id for note in db_rom.notes
|
||||
)
|
||||
return db_rom
|
||||
@@ -332,20 +375,13 @@ class RomSchema(BaseModel):
|
||||
def sort_alternative_names(cls, v: list[str]) -> list[str]:
|
||||
return sorted(v)
|
||||
|
||||
@field_validator("files")
|
||||
def sort_files(cls, v: list[RomFileSchema]) -> list[RomFileSchema]:
|
||||
return sorted(v, key=lambda x: x.file_name)
|
||||
|
||||
@field_validator("siblings")
|
||||
def sort_siblings(cls, v: list[SiblingRomSchema]) -> list[SiblingRomSchema]:
|
||||
return sorted(v, key=lambda x: x.sort_comparator)
|
||||
|
||||
|
||||
class SiblingRomSchema(BaseModel):
|
||||
id: int
|
||||
name: str | None
|
||||
fs_name_no_tags: str
|
||||
fs_name_no_ext: str
|
||||
is_main_sibling: bool
|
||||
|
||||
@computed_field # type: ignore
|
||||
@property
|
||||
@@ -359,18 +395,68 @@ class SiblingRomSchema(BaseModel):
|
||||
.lower()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_rom(cls, rom: Rom, *, is_main_sibling: bool = False) -> SiblingRomSchema:
|
||||
return cls(
|
||||
id=rom.id,
|
||||
name=rom.name,
|
||||
fs_name_no_tags=rom.fs_name_no_tags,
|
||||
fs_name_no_ext=rom.fs_name_no_ext,
|
||||
is_main_sibling=is_main_sibling,
|
||||
)
|
||||
|
||||
|
||||
class SimpleRomSchema(RomSchema):
|
||||
screenshot_path: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_orm_with_request(cls, db_rom: Rom, request: Request) -> SimpleRomSchema:
|
||||
def from_orm_with_request(
|
||||
cls,
|
||||
db_rom: Rom,
|
||||
request: Request,
|
||||
files: Sequence[RomFile] | None = None,
|
||||
siblings: Sequence[tuple[Rom, bool]] | None = None,
|
||||
screenshot_path: str | None = None,
|
||||
) -> SimpleRomSchema:
|
||||
db_rom = cls.populate_properties(db_rom, request)
|
||||
db_rom.screenshot_path = screenshot_path # type: ignore[assignment]
|
||||
|
||||
# The list endpoint passes pre-fetched `files`/`siblings` (batched via
|
||||
# get_files_for_roms / get_siblings_for_roms, no per-row hydration).
|
||||
# Single-rom endpoints (e.g. `/{id}/simple`, loaded via the
|
||||
# `with_details` decorator) pass neither and fall back to the
|
||||
# eager-loaded relationships. `None` (not provided) is distinct from an
|
||||
# explicit empty list (e.g. the gallery list, which intentionally omits
|
||||
# files unless `with_files` is set).
|
||||
if files is None:
|
||||
files = db_rom.files
|
||||
if siblings is None:
|
||||
user_id = request.user.id
|
||||
siblings = [
|
||||
(
|
||||
s,
|
||||
any(
|
||||
ru.user_id == user_id and ru.is_main_sibling
|
||||
for ru in s.rom_users
|
||||
),
|
||||
)
|
||||
for s in db_rom.sibling_roms
|
||||
]
|
||||
|
||||
db_rom.included_files = list(files) # type: ignore[assignment]
|
||||
db_rom.included_sibling_roms = [ # type: ignore[assignment]
|
||||
SiblingRomSchema.from_rom(s, is_main_sibling=is_main)
|
||||
for s, is_main in siblings
|
||||
]
|
||||
return cls.model_validate(db_rom)
|
||||
|
||||
@classmethod
|
||||
def from_orm_with_factory(cls, db_rom: Rom) -> SimpleRomSchema:
|
||||
db_rom.rom_user = rom_user_schema_factory() # type: ignore
|
||||
db_rom.siblings = [] # type: ignore
|
||||
db_rom.has_notes = False # type: ignore
|
||||
db_rom.rom_user = rom_user_schema_factory() # type: ignore[assignment]
|
||||
db_rom.included_files = [] # type: ignore[assignment]
|
||||
db_rom.included_sibling_roms = [] # type: ignore[assignment]
|
||||
db_rom.has_notes = False # type: ignore[assignment]
|
||||
db_rom.screenshot_path = None # type: ignore[assignment]
|
||||
return cls.model_validate(db_rom)
|
||||
|
||||
|
||||
@@ -395,7 +481,10 @@ class UserCollectionSchema(BaseModel):
|
||||
class DetailedRomSchema(RomSchema):
|
||||
user_saves: list[SaveSchema]
|
||||
user_states: list[StateSchema]
|
||||
all_user_saves: list[UserSaveSchema]
|
||||
all_user_states: list[UserStateSchema]
|
||||
user_screenshots: list[ScreenshotSchema]
|
||||
all_user_screenshots: list[UserScreenshotSchema]
|
||||
user_collections: list[UserCollectionSchema]
|
||||
all_user_notes: list[UserNoteSchema]
|
||||
|
||||
@@ -404,18 +493,34 @@ class DetailedRomSchema(RomSchema):
|
||||
user_id = request.user.id
|
||||
db_rom = cls.populate_properties(db_rom, request)
|
||||
|
||||
db_rom.user_saves = [ # type: ignore
|
||||
sorted_siblings = sorted(
|
||||
(
|
||||
SiblingRomSchema.from_rom(
|
||||
s,
|
||||
is_main_sibling=any(
|
||||
ru.user_id == user_id and ru.is_main_sibling
|
||||
for ru in s.rom_users
|
||||
),
|
||||
)
|
||||
for s in db_rom.sibling_roms
|
||||
),
|
||||
key=lambda x: x.sort_comparator,
|
||||
)
|
||||
db_rom.included_sibling_roms = sorted_siblings # type: ignore[assignment]
|
||||
db_rom.included_files = sorted(db_rom.files, key=lambda x: x.file_name) # type: ignore[assignment]
|
||||
|
||||
db_rom.user_saves = [ # type: ignore[assignment]
|
||||
SaveSchema.model_validate(s) for s in db_rom.saves if s.user_id == user_id
|
||||
]
|
||||
db_rom.user_states = [ # type: ignore
|
||||
db_rom.user_states = [ # type: ignore[assignment]
|
||||
StateSchema.model_validate(s) for s in db_rom.states if s.user_id == user_id
|
||||
]
|
||||
db_rom.user_screenshots = [ # type: ignore
|
||||
db_rom.user_screenshots = [ # type: ignore[assignment]
|
||||
ScreenshotSchema.model_validate(s)
|
||||
for s in db_rom.screenshots
|
||||
if s.user_id == user_id
|
||||
]
|
||||
db_rom.user_collections = UserCollectionSchema.for_user( # type: ignore
|
||||
db_rom.user_collections = UserCollectionSchema.for_user( # type: ignore[assignment]
|
||||
user_id, db_rom.collections
|
||||
)
|
||||
|
||||
@@ -437,12 +542,73 @@ class DetailedRomSchema(RomSchema):
|
||||
"updated_at": note.updated_at,
|
||||
"user_id": note.user_id,
|
||||
"username": note.user.username,
|
||||
"user_avatar_path": note.user.avatar_path,
|
||||
"user_updated_at": note.user.updated_at,
|
||||
}
|
||||
all_notes.append(UserNoteSchema.model_validate(note_dict))
|
||||
|
||||
# Sort notes by updated_at (most recent first)
|
||||
all_notes.sort(key=lambda x: x.updated_at, reverse=True)
|
||||
db_rom.all_user_notes = all_notes # type: ignore
|
||||
db_rom.all_user_notes = all_notes # type: ignore[assignment]
|
||||
|
||||
# Gallery screenshots visible to this user: own (public + private) plus
|
||||
# other users' public ones. Mirrors the notes flow above. Excludes the
|
||||
# auto-captured save/state thumbnails (is_gallery == False).
|
||||
from handler.database import db_screenshot_handler
|
||||
|
||||
gallery_screenshots = db_screenshot_handler.get_rom_gallery_screenshots(
|
||||
rom_id=db_rom.id, user_id=user_id
|
||||
)
|
||||
db_rom.all_user_screenshots = [ # type: ignore[assignment]
|
||||
UserScreenshotSchema.model_validate(
|
||||
{
|
||||
**{
|
||||
field: getattr(s, field)
|
||||
for field in ScreenshotSchema.model_fields
|
||||
},
|
||||
"username": s.user.username,
|
||||
"user_avatar_path": s.user.avatar_path,
|
||||
"user_updated_at": s.user.updated_at,
|
||||
}
|
||||
)
|
||||
for s in gallery_screenshots
|
||||
]
|
||||
|
||||
# Saves/states visible to this user: own (public + private) plus other
|
||||
# users' public ones. Mirrors the screenshots flow above.
|
||||
from handler.database import db_save_handler, db_state_handler
|
||||
|
||||
# SaveSchema.model_validate handles the lazy `device_syncs` relationship
|
||||
# (skips it when unloaded); reuse it rather than getattr-ing raw fields.
|
||||
shared_saves = db_save_handler.get_rom_shared_saves(
|
||||
rom_id=db_rom.id, user_id=user_id
|
||||
)
|
||||
db_rom.all_user_saves = [ # type: ignore[assignment]
|
||||
UserSaveSchema.model_validate(
|
||||
{
|
||||
**SaveSchema.model_validate(s).model_dump(),
|
||||
"username": s.user.username,
|
||||
"user_avatar_path": s.user.avatar_path,
|
||||
"user_updated_at": s.user.updated_at,
|
||||
}
|
||||
)
|
||||
for s in shared_saves
|
||||
]
|
||||
|
||||
shared_states = db_state_handler.get_rom_shared_states(
|
||||
rom_id=db_rom.id, user_id=user_id
|
||||
)
|
||||
db_rom.all_user_states = [ # type: ignore[assignment]
|
||||
UserStateSchema.model_validate(
|
||||
{
|
||||
**StateSchema.model_validate(s).model_dump(),
|
||||
"username": s.user.username,
|
||||
"user_avatar_path": s.user.avatar_path,
|
||||
"user_updated_at": s.user.updated_at,
|
||||
}
|
||||
)
|
||||
for s in shared_states
|
||||
]
|
||||
|
||||
return cls.model_validate(db_rom)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from base64 import b64encode
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from stat import S_IFREG
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Any, Sequence
|
||||
from urllib.parse import quote
|
||||
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
|
||||
|
||||
@@ -25,7 +25,7 @@ from fastapi import (
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import Response
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate
|
||||
from fastapi_pagination import resolve_params
|
||||
from fastapi_pagination.limit_offset import LimitOffsetPage, LimitOffsetParams
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.responses import FileResponse
|
||||
@@ -46,33 +46,39 @@ from endpoints.responses.rom import (
|
||||
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
|
||||
from exceptions.fs_exceptions import RomAlreadyExistsException
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_rom_handler
|
||||
from handler.database import db_rom_handler, db_save_handler
|
||||
from handler.database.base_handler import sync_session
|
||||
from handler.filesystem import fs_resource_handler, fs_rom_handler
|
||||
from handler.filesystem.assets_handler import validate_image_upload
|
||||
from handler.metadata import (
|
||||
meta_flashpoint_handler,
|
||||
meta_igdb_handler,
|
||||
meta_launchbox_handler,
|
||||
meta_moby_handler,
|
||||
meta_playmatch_handler,
|
||||
meta_ra_handler,
|
||||
meta_ss_handler,
|
||||
)
|
||||
from handler.metadata.ss_handler import get_preferred_media_types
|
||||
from handler.metadata.ss_handler import add_ss_auth_to_url, get_preferred_media_types
|
||||
from logger.formatter import BLUE
|
||||
from logger.formatter import highlight as hl
|
||||
from logger.logger import log
|
||||
from models.rom import Rom, RomUserStatus
|
||||
from models.rom import Rom, RomUserStatus, compute_name_sort_key
|
||||
from utils.background_tasks import fire_and_forget
|
||||
from utils.database import safe_int, safe_str_to_bool
|
||||
from utils.filesystem import sanitize_filename
|
||||
from utils.hashing import crc32_to_hex
|
||||
from utils.nginx import FileRedirectResponse, ZipContentLine, ZipResponse
|
||||
from utils.router import APIRouter
|
||||
from utils.screenshots import continue_playing_screenshot
|
||||
from utils.validation import ValidationError
|
||||
|
||||
from .files import router as files_router
|
||||
from .manual import router as manual_router
|
||||
from .notes import router as notes_router
|
||||
from .patch import router as patch_router
|
||||
from .screenshot import router as screenshot_router
|
||||
from .soundtrack import router as soundtrack_router
|
||||
from .upload import router as upload_router
|
||||
|
||||
router = APIRouter(
|
||||
@@ -82,6 +88,8 @@ router = APIRouter(
|
||||
router.include_router(upload_router)
|
||||
router.include_router(files_router)
|
||||
router.include_router(manual_router)
|
||||
router.include_router(soundtrack_router)
|
||||
router.include_router(screenshot_router)
|
||||
router.include_router(notes_router)
|
||||
router.include_router(patch_router)
|
||||
|
||||
@@ -130,6 +138,7 @@ class RomUpdateForm(BaseModel):
|
||||
default=None, description="Raw manual metadata as JSON string."
|
||||
)
|
||||
name: str | None = None
|
||||
name_sort_key: str | None = None
|
||||
summary: str | None = None
|
||||
fs_name: str | None = None
|
||||
url_cover: str | None = None
|
||||
@@ -189,6 +198,7 @@ async def parse_rom_update_form(
|
||||
raw_hltb_metadata: str | None = Form(default=None),
|
||||
raw_manual_metadata: str | None = Form(default=None),
|
||||
name: str | None = Form(default=None),
|
||||
name_sort_key: str | None = Form(default=None),
|
||||
summary: str | None = Form(default=None),
|
||||
fs_name: str | None = Form(default=None),
|
||||
url_cover: str | None = Form(default=None),
|
||||
@@ -217,6 +227,7 @@ async def parse_rom_update_form(
|
||||
"raw_hltb_metadata": raw_hltb_metadata,
|
||||
"raw_manual_metadata": raw_manual_metadata,
|
||||
"name": name,
|
||||
"name_sort_key": name_sort_key,
|
||||
"summary": summary,
|
||||
"fs_name": fs_name,
|
||||
"url_cover": url_cover,
|
||||
@@ -469,8 +480,14 @@ def get_roms(
|
||||
] = "any",
|
||||
order_by: Annotated[
|
||||
str,
|
||||
Query(description="Field to order results by."),
|
||||
] = "name",
|
||||
Query(
|
||||
description=(
|
||||
"Field to order results by. Leave empty to order by search "
|
||||
"relevance when a search term is given on MySQL/MariaDB; other "
|
||||
"databases fall back to name."
|
||||
),
|
||||
),
|
||||
] = "",
|
||||
order_dir: Annotated[
|
||||
str,
|
||||
Query(description="Order direction, either 'asc' or 'desc'."),
|
||||
@@ -481,12 +498,17 @@ def get_roms(
|
||||
description="Filter roms updated after this datetime (ISO 8601 format with timezone information)."
|
||||
),
|
||||
] = None,
|
||||
with_files: Annotated[
|
||||
bool,
|
||||
Query(description="Whether to include each rom's file entries."),
|
||||
] = False,
|
||||
) -> CustomLimitOffsetPage[SimpleRomSchema]:
|
||||
"""Retrieve roms."""
|
||||
unfiltered_query, order_by_attr = db_rom_handler.get_roms_query(
|
||||
user_id=request.user.id,
|
||||
order_by=order_by.lower(),
|
||||
order_dir=order_dir.lower(),
|
||||
search_term=search_term,
|
||||
)
|
||||
|
||||
# Filter down the query
|
||||
@@ -527,13 +549,36 @@ def get_roms(
|
||||
player_counts_logic=player_counts_logic,
|
||||
group_by_meta_id=group_by_meta_id,
|
||||
updated_after=updated_after,
|
||||
include_file_stats=True,
|
||||
)
|
||||
|
||||
# Cache only the unscoped library scan; scoped/searched sets are narrower and computed live.
|
||||
is_unscoped = not (
|
||||
search_term
|
||||
or platform_ids
|
||||
or collection_id
|
||||
or virtual_collection_id
|
||||
or smart_collection_id
|
||||
)
|
||||
|
||||
# Get the char index for the roms
|
||||
char_index_dict = {}
|
||||
if with_char_index:
|
||||
# The computed positions depend on ordering and grouping, so those
|
||||
# must be part of the cache key. Otherwise switching sort
|
||||
# direction/column (or toggling grouping) reuses a stale index and
|
||||
# the AlphaStrip highlights the wrong letters.
|
||||
char_index_cache_key = (
|
||||
f"all:u{request.user.id}"
|
||||
f":o{order_by.lower()}:d{order_dir.lower()}:g{int(group_by_meta_id)}"
|
||||
if is_unscoped
|
||||
else None
|
||||
)
|
||||
char_index = db_rom_handler.with_char_index(
|
||||
query=query, order_by_attr=order_by_attr
|
||||
query=query,
|
||||
order_by_attr=order_by_attr,
|
||||
order_dir=order_dir.lower(),
|
||||
cache_key=char_index_cache_key,
|
||||
)
|
||||
char_index_dict = {char: index for (char, index) in char_index}
|
||||
|
||||
@@ -560,7 +605,16 @@ def get_roms(
|
||||
smart_collection_id=smart_collection_id,
|
||||
search_term=search_term,
|
||||
)
|
||||
query_filters = db_rom_handler.with_filter_values(query=filter_query)
|
||||
cache_key = (
|
||||
f"all:u{request.user.id}"
|
||||
f":o{order_by.lower()}:d{order_dir.lower()}:g{int(group_by_meta_id)}"
|
||||
if is_unscoped
|
||||
else None
|
||||
)
|
||||
query_filters = db_rom_handler.with_filter_values(
|
||||
query=filter_query,
|
||||
cache_key=cache_key,
|
||||
)
|
||||
# trunk-ignore(mypy/typeddict-item)
|
||||
filter_values = RomFiltersDict(**query_filters)
|
||||
|
||||
@@ -568,17 +622,56 @@ def get_roms(
|
||||
with sync_session.begin() as session:
|
||||
rom_id_index = session.scalars(query.with_only_columns(Rom.id)).all() # type: ignore
|
||||
|
||||
return paginate(
|
||||
session,
|
||||
query,
|
||||
transformer=lambda items: [
|
||||
SimpleRomSchema.from_orm_with_request(i, request) for i in items
|
||||
],
|
||||
additional_data={
|
||||
"char_index": char_index_dict,
|
||||
"rom_id_index": rom_id_index,
|
||||
"filter_values": filter_values,
|
||||
},
|
||||
def _transform(items: Sequence[Rom]) -> list[SimpleRomSchema]:
|
||||
rom_ids = [i.id for i in items]
|
||||
files_by_rom = (
|
||||
db_rom_handler.get_files_for_roms(rom_ids, session=session)
|
||||
if with_files
|
||||
else {}
|
||||
)
|
||||
siblings_by_rom = db_rom_handler.get_siblings_for_roms(
|
||||
rom_ids, user_id=request.user.id, session=session
|
||||
)
|
||||
|
||||
# Continue-playing rail
|
||||
screenshot_by_rom: dict[int, str | None] = {}
|
||||
if last_played:
|
||||
latest_saves = db_save_handler.get_latest_saves_for_roms(
|
||||
user_id=request.user.id, rom_ids=rom_ids, session=session
|
||||
)
|
||||
for item in items:
|
||||
screenshot_by_rom[item.id] = continue_playing_screenshot(
|
||||
item, latest_saves.get(item.id)
|
||||
)
|
||||
|
||||
return [
|
||||
SimpleRomSchema.from_orm_with_request(
|
||||
db_rom=item,
|
||||
request=request,
|
||||
files=files_by_rom.get(item.id, []),
|
||||
siblings=siblings_by_rom.get(item.id, []),
|
||||
screenshot_path=screenshot_by_rom.get(item.id),
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
|
||||
params = resolve_params()
|
||||
total = len(rom_id_index)
|
||||
page_ids = list(rom_id_index[params.offset : params.offset + params.limit])
|
||||
if page_ids:
|
||||
page_rows = session.scalars(query.where(Rom.id.in_(page_ids))).all()
|
||||
rows_by_id = {rom.id: rom for rom in page_rows}
|
||||
page_items = [rows_by_id[i] for i in page_ids if i in rows_by_id]
|
||||
else:
|
||||
page_items = []
|
||||
|
||||
return CustomLimitOffsetPage.create(
|
||||
_transform(page_items),
|
||||
params,
|
||||
total=total,
|
||||
char_index=char_index_dict,
|
||||
rom_id_index=list(rom_id_index),
|
||||
filter_values=filter_values,
|
||||
)
|
||||
|
||||
|
||||
@@ -804,6 +897,32 @@ async def get_rom_filters(request: Request) -> RomFiltersDict:
|
||||
return RomFiltersDict(**filters)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.get,
|
||||
"/{id}/simple",
|
||||
[] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
def get_rom_simple(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
) -> SimpleRomSchema:
|
||||
"""Retrieve a rom by ID with the lightweight schema — no eager-loaded
|
||||
`user_saves` / `user_states` / `user_screenshots` / `user_collections` /
|
||||
`all_user_notes` arrays. Designed for the v2 gallery card which only
|
||||
needs the indicator flags (`has_notes`, `ra_id`, status, etc.) already
|
||||
present on `SimpleRomSchema`. The full `DetailedRomSchema` is only
|
||||
fetched on user-driven detail interactions (game details page, quick-
|
||||
note dialog open, achievements panel)."""
|
||||
|
||||
rom = db_rom_handler.get_rom(id)
|
||||
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
return SimpleRomSchema.from_orm_with_request(rom, request)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.get,
|
||||
"/{id}",
|
||||
@@ -854,6 +973,12 @@ async def head_rom_content(
|
||||
files = [f for f in files if f.id in file_id_values]
|
||||
files.sort(key=lambda x: x.file_name)
|
||||
|
||||
if not files:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No files found for ROM {id}",
|
||||
)
|
||||
|
||||
# Serve the file directly in development mode for emulatorjs
|
||||
if DEV_MODE:
|
||||
if len(files) == 1:
|
||||
@@ -931,6 +1056,12 @@ async def get_rom_content(
|
||||
files = [f for f in files if f.id in file_id_values]
|
||||
files.sort(key=lambda x: x.file_name)
|
||||
|
||||
if not files:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No files found for ROM {id}",
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"User {hl(current_username, color=BLUE)} is downloading {hl(rom.fs_name)}"
|
||||
)
|
||||
@@ -1094,6 +1225,7 @@ async def update_rom(
|
||||
"hltb_id": None,
|
||||
"libretro_id": None,
|
||||
"name": rom.fs_name,
|
||||
"name_sort_key": compute_name_sort_key(rom.fs_name),
|
||||
"summary": "",
|
||||
"url_screenshots": [],
|
||||
"path_screenshots": [],
|
||||
@@ -1119,6 +1251,7 @@ async def update_rom(
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
db_rom_handler.invalidate_filter_values_cache()
|
||||
return DetailedRomSchema.from_orm_with_request(rom, request)
|
||||
|
||||
provided_fields = form_data.model_fields_set
|
||||
@@ -1255,7 +1388,7 @@ async def update_rom(
|
||||
cleaned_data.update({"ss_id": None, "ss_metadata": {}})
|
||||
|
||||
if cleaned_data["igdb_id"] and int(cleaned_data["igdb_id"]) != rom.igdb_id:
|
||||
igdb_rom = await meta_igdb_handler.get_rom_by_id(cleaned_data["igdb_id"])
|
||||
igdb_rom = await meta_igdb_handler.get_rom_by_id(rom, cleaned_data["igdb_id"])
|
||||
if igdb_rom.get("igdb_id"):
|
||||
cleaned_data.update(igdb_rom)
|
||||
elif rom.igdb_id and not cleaned_data["igdb_id"]:
|
||||
@@ -1268,7 +1401,7 @@ async def update_rom(
|
||||
path_screenshots = await fs_resource_handler.get_rom_screenshots(
|
||||
rom=rom,
|
||||
overwrite=bool(screenshots_changed),
|
||||
url_screenshots=cleaned_data.get("url_screenshots", []),
|
||||
url_screenshots=[add_ss_auth_to_url(u) for u in url_screenshots],
|
||||
)
|
||||
cleaned_data.update(
|
||||
{"path_screenshots": path_screenshots, "url_screenshots": []}
|
||||
@@ -1277,33 +1410,51 @@ async def update_rom(
|
||||
log.error(f"Invalid screenshot URL in update_rom: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
name_value = form_data.name if "name" in provided_fields else rom.name
|
||||
cleaned_data.update(
|
||||
{
|
||||
"name": form_data.name if "name" in provided_fields else rom.name,
|
||||
"name": name_value,
|
||||
"summary": (
|
||||
form_data.summary if "summary" in provided_fields else rom.summary
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if "name_sort_key" in provided_fields:
|
||||
# The edit form always echoes the current key back, so only act when the
|
||||
# user actually changed it: a value is a custom override, an empty value
|
||||
# reverts to deriving from the (possibly new) name. When unchanged, leave
|
||||
# it out so update_rom re-derives only if the stored key isn't custom.
|
||||
submitted = (form_data.name_sort_key or "").strip()
|
||||
if submitted != (rom.name_sort_key or "").strip():
|
||||
cleaned_data["name_sort_key"] = compute_name_sort_key(
|
||||
submitted or name_value
|
||||
)
|
||||
|
||||
new_fs_name = str(form_data.fs_name or rom.fs_name)
|
||||
new_fs_name = sanitize_filename(new_fs_name)
|
||||
cleaned_data.update(
|
||||
{
|
||||
"fs_name": new_fs_name,
|
||||
"fs_name_no_tags": fs_rom_handler.get_file_name_with_no_tags(new_fs_name),
|
||||
"fs_name_no_ext": fs_rom_handler.get_file_name_with_no_extension(
|
||||
new_fs_name
|
||||
),
|
||||
}
|
||||
)
|
||||
cleaned_data.update({"fs_name": new_fs_name})
|
||||
|
||||
# Re-parse tags from the filename so region/language/revision/version/tags
|
||||
# stay in sync whenever the fs_name changes.
|
||||
if new_fs_name != rom.fs_name:
|
||||
parsed_tags = fs_rom_handler.parse_tags(new_fs_name)
|
||||
cleaned_data.update(
|
||||
{
|
||||
"regions": parsed_tags.regions,
|
||||
"languages": parsed_tags.languages,
|
||||
"tags": parsed_tags.other_tags,
|
||||
"revision": parsed_tags.revision,
|
||||
"version": parsed_tags.version,
|
||||
}
|
||||
)
|
||||
|
||||
if remove_cover:
|
||||
cleaned_data.update(await fs_resource_handler.remove_cover(rom))
|
||||
cleaned_data.update({"url_cover": ""})
|
||||
else:
|
||||
if artwork is not None and artwork.filename is not None:
|
||||
file_ext = artwork.filename.split(".")[-1]
|
||||
file_ext = validate_image_upload(artwork, label="Artwork")
|
||||
artwork_content = BytesIO(await artwork.read())
|
||||
(
|
||||
path_cover_l,
|
||||
@@ -1325,7 +1476,7 @@ async def update_rom(
|
||||
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
|
||||
entity=rom,
|
||||
overwrite=url_cover != rom.url_cover,
|
||||
url_cover=str(url_cover),
|
||||
url_cover=add_ss_auth_to_url(url_cover),
|
||||
)
|
||||
cleaned_data.update(
|
||||
{
|
||||
@@ -1345,7 +1496,7 @@ async def update_rom(
|
||||
path_manual = await fs_resource_handler.get_manual(
|
||||
rom=rom,
|
||||
overwrite=url_manual != rom.url_manual,
|
||||
url_manual=str(url_manual) if url_manual else None,
|
||||
url_manual=add_ss_auth_to_url(url_manual),
|
||||
)
|
||||
cleaned_data.update(
|
||||
{
|
||||
@@ -1385,10 +1536,16 @@ async def update_rom(
|
||||
media_type,
|
||||
)
|
||||
|
||||
if cleaned_data.get("ss_metadata", {}).get(f"{media_type.value}_path"):
|
||||
media_path = cleaned_data.get("ss_metadata", {}).get(
|
||||
f"{media_type.value}_path"
|
||||
)
|
||||
media_url = cleaned_data.get("ss_metadata", {}).get(
|
||||
f"{media_type.value}_url"
|
||||
)
|
||||
if media_path and media_url:
|
||||
await fs_resource_handler.store_media_file(
|
||||
cleaned_data["ss_metadata"][f"{media_type.value}_url"],
|
||||
cleaned_data["ss_metadata"][f"{media_type.value}_path"],
|
||||
add_ss_auth_to_url(media_url),
|
||||
media_path,
|
||||
)
|
||||
|
||||
log.debug(
|
||||
@@ -1428,6 +1585,10 @@ async def update_rom(
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
if meta_playmatch_handler.is_manual_match(form_data.model_fields_set):
|
||||
fire_and_forget(meta_playmatch_handler.submit_manual_match_suggestion(rom))
|
||||
|
||||
db_rom_handler.invalidate_filter_values_cache()
|
||||
return DetailedRomSchema.from_orm_with_request(rom, request)
|
||||
|
||||
|
||||
@@ -1495,11 +1656,9 @@ async def delete_roms(
|
||||
f"Couldn't clean up empty parent directory for {hl(rom.fs_name)}: {dir_err}"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
error = f"Rom file {hl(rom.fs_name)} not found for platform {hl(rom.platform_display_name, color=BLUE)}[{hl(rom.platform_slug)}]"
|
||||
log.error(error)
|
||||
errors.append(error)
|
||||
failed_items += 1
|
||||
continue
|
||||
log.warning(
|
||||
f"Rom file {hl(rom.fs_name)} not found for platform {hl(rom.platform_display_name, color=BLUE)}[{hl(rom.platform_slug)}], deleting database entry only"
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Deleting {hl(str(rom.name or 'ROM'), color=BLUE)} [{hl(rom.fs_name)}] from database"
|
||||
@@ -1518,6 +1677,9 @@ async def delete_roms(
|
||||
failed_items += 1
|
||||
errors.append(f"Failed to delete ROM {id}: {str(e)}")
|
||||
|
||||
if successful_items:
|
||||
db_rom_handler.invalidate_filter_values_cache()
|
||||
|
||||
return {
|
||||
"successful_items": successful_items,
|
||||
"failed_items": failed_items,
|
||||
@@ -1567,4 +1729,7 @@ async def update_rom_user(
|
||||
|
||||
rom_user = db_rom_handler.update_rom_user(db_rom_user.id, cleaned_data)
|
||||
|
||||
if "hidden" in cleaned_data:
|
||||
db_rom_handler.invalidate_filter_values_cache()
|
||||
|
||||
return RomUserSchema.model_validate(rom_user)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from typing import Annotated
|
||||
from urllib.parse import quote
|
||||
|
||||
from anyio import Path
|
||||
from fastapi import HTTPException
|
||||
@@ -16,6 +15,8 @@ from handler.filesystem import fs_rom_handler
|
||||
from logger.formatter import BLUE
|
||||
from logger.formatter import highlight as hl
|
||||
from logger.logger import log
|
||||
from models.rom import RomFileCategory
|
||||
from utils.audio_tags import guess_audio_media_type
|
||||
from utils.nginx import FileRedirectResponse
|
||||
from utils.router import APIRouter
|
||||
|
||||
@@ -68,22 +69,37 @@ async def get_romfile_content(
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
log.info(f"User {hl(current_username, color=BLUE)} is downloading {hl(file_name)}")
|
||||
log.info(
|
||||
f"User {hl(current_username, color=BLUE)} is downloading {hl(file.file_name)}"
|
||||
)
|
||||
|
||||
# Derive content type / disposition / download name from the trusted DB
|
||||
# record, never from the client-supplied file_name path param — otherwise a
|
||||
# caller could request the same bytes with an arbitrary extension to force a
|
||||
# mismatched Content-Type while served inline (content-sniffing/XSS).
|
||||
is_audio = file.category == RomFileCategory.SOUNDTRACK
|
||||
media_type = (
|
||||
guess_audio_media_type(file.file_name)
|
||||
if is_audio
|
||||
else "application/octet-stream"
|
||||
)
|
||||
disposition = "inline" if is_audio else "attachment"
|
||||
|
||||
# Serve the file directly in development mode for emulatorjs
|
||||
if DEV_MODE:
|
||||
rom_path = fs_rom_handler.validate_path(file.full_path)
|
||||
# Starlette sets Content-Length and honors Range natively — inline
|
||||
# disposition lets <audio> seek via Range requests.
|
||||
return FileResponse(
|
||||
path=rom_path,
|
||||
filename=file_name,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}; filename=\"{quote(file_name)}\"",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(file.file_size_bytes),
|
||||
},
|
||||
filename=file.file_name,
|
||||
media_type=media_type,
|
||||
content_disposition_type=disposition,
|
||||
)
|
||||
|
||||
# Otherwise proxy through nginx
|
||||
# Otherwise proxy through nginx (which parses Range itself via X-Accel-Redirect)
|
||||
return FileRedirectResponse(
|
||||
download_path=Path(f"/library/{file.full_path}"),
|
||||
disposition=disposition,
|
||||
media_type=media_type,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
@@ -12,14 +13,23 @@ from decorators.auth import protected_route
|
||||
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_rom_handler
|
||||
from handler.filesystem import fs_resource_handler
|
||||
from handler.filesystem import fs_resource_handler, fs_rom_handler
|
||||
from logger.formatter import BLUE
|
||||
from logger.formatter import highlight as hl
|
||||
from logger.logger import log
|
||||
from models.rom import RomFile, RomFileCategory
|
||||
from utils.router import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MANUAL_FOLDER = "manual"
|
||||
ALLOWED_MANUAL_EXTENSIONS = frozenset({".pdf", ".md"})
|
||||
|
||||
|
||||
def _is_allowed_manual_file(file_name: str) -> bool:
|
||||
_, ext = os.path.splitext(file_name)
|
||||
return ext.lower() in ALLOWED_MANUAL_EXTENSIONS
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
@@ -45,6 +55,16 @@ async def add_rom_manuals(
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
# The stored filename is always `{rom.id}.pdf`; we only use `filename` as
|
||||
# the form-field key, but normalise it to a safe basename first.
|
||||
try:
|
||||
safe_field_name = fs_resource_handler._sanitize_filename(filename)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid upload filename: {exc}",
|
||||
) from exc
|
||||
|
||||
manuals_path = f"{rom.fs_resources_path}/manual"
|
||||
file_location = fs_resource_handler.validate_path(f"{manuals_path}/{rom.id}.pdf")
|
||||
log.info(f"Uploading manual to {hl(str(file_location))}")
|
||||
@@ -53,7 +73,7 @@ async def add_rom_manuals(
|
||||
|
||||
parser = StreamingFormDataParser(headers=request.headers)
|
||||
parser.register("x-upload-platform", NullTarget())
|
||||
parser.register(filename, FileTarget(str(file_location)))
|
||||
parser.register(safe_field_name, FileTarget(str(file_location)))
|
||||
|
||||
def cleanup_partial_file():
|
||||
if file_location.exists():
|
||||
@@ -62,16 +82,10 @@ async def add_rom_manuals(
|
||||
try:
|
||||
async for chunk in request.stream():
|
||||
parser.data_received(chunk)
|
||||
|
||||
db_rom_handler.update_rom(
|
||||
id,
|
||||
{
|
||||
"path_manual": f"{manuals_path}/{rom.id}.pdf",
|
||||
},
|
||||
)
|
||||
except ClientDisconnect:
|
||||
log.error("Client disconnected during upload")
|
||||
cleanup_partial_file()
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.error("Error uploading files", exc_info=exc)
|
||||
cleanup_partial_file()
|
||||
@@ -80,6 +94,228 @@ async def add_rom_manuals(
|
||||
detail="There was an error uploading the manual",
|
||||
) from exc
|
||||
|
||||
db_rom_handler.update_rom(
|
||||
id,
|
||||
{
|
||||
"path_manual": f"{manuals_path}/{rom.id}.pdf",
|
||||
},
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/{id}/manuals/redownload",
|
||||
[Scope.ROMS_WRITE],
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {},
|
||||
status.HTTP_400_BAD_REQUEST: {},
|
||||
},
|
||||
)
|
||||
async def redownload_rom_manual(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
) -> Response:
|
||||
"""Re-download a rom's manual from its scraped URL (e.g. screenscraper.fr)."""
|
||||
|
||||
rom = db_rom_handler.get_rom(id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
if not rom.url_manual:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No scraped manual URL available for this ROM",
|
||||
)
|
||||
|
||||
try:
|
||||
path_manual = await fs_resource_handler.get_manual(
|
||||
rom=rom,
|
||||
overwrite=True,
|
||||
url_manual=str(rom.url_manual),
|
||||
)
|
||||
db_rom_handler.update_rom(id, {"path_manual": path_manual})
|
||||
log.info(
|
||||
f"Re-downloaded manual for {hl(rom.name or 'ROM', color=BLUE)} "
|
||||
f"[{hl(rom.fs_name)}]"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("Error re-downloading manual", exc_info=exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="There was an error re-downloading the manual",
|
||||
) from exc
|
||||
|
||||
return Response()
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/{id}/manuals/files",
|
||||
[Scope.ROMS_WRITE],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {},
|
||||
status.HTTP_400_BAD_REQUEST: {},
|
||||
},
|
||||
)
|
||||
async def add_rom_manual_file(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
filename: Annotated[
|
||||
str,
|
||||
Header(
|
||||
description="The name of the file being uploaded.",
|
||||
alias="x-upload-filename",
|
||||
),
|
||||
],
|
||||
) -> Response:
|
||||
"""Upload a manual PDF into the ROM's own manual/ subfolder."""
|
||||
|
||||
rom = db_rom_handler.get_rom(id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
if rom.has_simple_single_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Manual files can only be uploaded to folder-based ROMs",
|
||||
)
|
||||
|
||||
try:
|
||||
safe_filename = fs_rom_handler._sanitize_filename(filename)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid upload filename: {exc}",
|
||||
) from exc
|
||||
|
||||
if safe_filename != filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Upload filename must be a plain file name, not a path",
|
||||
)
|
||||
|
||||
if not _is_allowed_manual_file(safe_filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"Unsupported manual file type. Allowed: "
|
||||
f"{', '.join(sorted(ALLOWED_MANUAL_EXTENSIONS))}"
|
||||
),
|
||||
)
|
||||
|
||||
manual_dir_rel = f"{rom.full_path}/{MANUAL_FOLDER}"
|
||||
file_rel_path = f"{manual_dir_rel}/{safe_filename}"
|
||||
file_location = fs_rom_handler.validate_path(file_rel_path)
|
||||
log.info(f"Uploading manual file to {hl(str(file_location))}")
|
||||
|
||||
await fs_rom_handler.make_directory(manual_dir_rel)
|
||||
|
||||
parser = StreamingFormDataParser(headers=request.headers)
|
||||
parser.register("x-upload-platform", NullTarget())
|
||||
parser.register(safe_filename, FileTarget(str(file_location)))
|
||||
|
||||
def cleanup_partial_file():
|
||||
if file_location.exists():
|
||||
file_location.unlink()
|
||||
|
||||
try:
|
||||
async for chunk in request.stream():
|
||||
parser.data_received(chunk)
|
||||
except ClientDisconnect:
|
||||
log.error("Client disconnected during upload")
|
||||
cleanup_partial_file()
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.error("Error uploading manual file", exc_info=exc)
|
||||
cleanup_partial_file()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="There was an error uploading the manual file",
|
||||
) from exc
|
||||
|
||||
stat = os.stat(file_location)
|
||||
existing = db_rom_handler.get_rom_file_by_path(
|
||||
rom_id=rom.id, file_path=manual_dir_rel, file_name=safe_filename
|
||||
)
|
||||
if existing:
|
||||
db_rom_handler.update_rom_file(
|
||||
existing.id,
|
||||
{
|
||||
"file_size_bytes": stat.st_size,
|
||||
"last_modified": stat.st_mtime,
|
||||
"category": RomFileCategory.MANUAL,
|
||||
"missing_from_fs": False,
|
||||
},
|
||||
)
|
||||
else:
|
||||
db_rom_handler.add_rom_file(
|
||||
RomFile(
|
||||
rom_id=rom.id,
|
||||
file_name=safe_filename,
|
||||
file_path=manual_dir_rel,
|
||||
file_size_bytes=stat.st_size,
|
||||
last_modified=stat.st_mtime,
|
||||
category=RomFileCategory.MANUAL,
|
||||
)
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.delete,
|
||||
"/{id}/manuals/files/{file_id}",
|
||||
[Scope.ROMS_WRITE],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def delete_rom_manual_file(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
file_id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)],
|
||||
) -> Response:
|
||||
"""Delete a single manual file from a ROM's manual/ subfolder."""
|
||||
|
||||
rom = db_rom_handler.get_rom(id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
rom_file = db_rom_handler.get_rom_file_by_id(file_id)
|
||||
if (
|
||||
not rom_file
|
||||
or rom_file.rom_id != rom.id
|
||||
or rom_file.category != RomFileCategory.MANUAL
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Manual file not found",
|
||||
)
|
||||
|
||||
file_rel_path = rom_file.full_path
|
||||
|
||||
try:
|
||||
await fs_rom_handler.remove_file(file_rel_path)
|
||||
except FileNotFoundError:
|
||||
log.warning(
|
||||
f"Manual file {hl(file_rel_path)} not found on disk; "
|
||||
f"removing DB row anyway"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error(f"Error deleting manual file {hl(file_rel_path)}", exc_info=exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="There was an error deleting the manual file",
|
||||
) from exc
|
||||
|
||||
db_rom_handler.delete_rom_file(file_id)
|
||||
|
||||
log.info(
|
||||
f"Deleted manual file {hl(rom_file.file_name)} from "
|
||||
f"{hl(rom.name or 'ROM', color=BLUE)} [{hl(rom.fs_name)}]"
|
||||
)
|
||||
|
||||
return Response()
|
||||
|
||||
|
||||
|
||||
@@ -100,8 +100,10 @@ async def create_rom_note(
|
||||
tags=note_data.get("tags", []),
|
||||
)
|
||||
|
||||
# Add username to the note data
|
||||
# Add author identity to the note data
|
||||
note["username"] = request.user.username
|
||||
note["user_avatar_path"] = request.user.avatar_path
|
||||
note["user_updated_at"] = request.user.updated_at
|
||||
return UserNoteSchema.model_validate(note)
|
||||
|
||||
|
||||
@@ -133,8 +135,10 @@ async def update_rom_note(
|
||||
status_code=404, detail="Note not found or not owned by user"
|
||||
)
|
||||
|
||||
# Add username to the note data
|
||||
# Add author identity to the note data
|
||||
note["username"] = request.user.username
|
||||
note["user_avatar_path"] = request.user.avatar_path
|
||||
note["user_updated_at"] = request.user.updated_at
|
||||
return UserNoteSchema.model_validate(note)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from fastapi import Path as PathVar
|
||||
from fastapi import Request, status
|
||||
from fastapi.responses import Response
|
||||
from starlette.requests import ClientDisconnect
|
||||
from streaming_form_data import StreamingFormDataParser
|
||||
from streaming_form_data.targets import FileTarget, NullTarget
|
||||
|
||||
from decorators.auth import protected_route
|
||||
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_rom_handler
|
||||
from handler.filesystem import fs_rom_handler
|
||||
from logger.formatter import BLUE
|
||||
from logger.formatter import highlight as hl
|
||||
from logger.logger import log
|
||||
from models.rom import RomFile, RomFileCategory
|
||||
from utils.router import APIRouter
|
||||
from utils.screenshots import (
|
||||
ALLOWED_SCREENSHOT_EXTENSIONS,
|
||||
is_allowed_screenshot_file,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SCREENSHOT_FOLDER = "screenshots"
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/{id}/screenshots",
|
||||
[Scope.ROMS_WRITE],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def add_rom_screenshots(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
filename: Annotated[
|
||||
str,
|
||||
Header(
|
||||
description="The name of the file being uploaded.",
|
||||
alias="x-upload-filename",
|
||||
),
|
||||
],
|
||||
) -> Response:
|
||||
"""Upload a screenshot image for a multi-file ROM."""
|
||||
|
||||
rom = db_rom_handler.get_rom(id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
if rom.has_simple_single_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Screenshots can only be uploaded to folder-based ROMs",
|
||||
)
|
||||
|
||||
try:
|
||||
safe_filename = fs_rom_handler._sanitize_filename(filename)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid upload filename: {exc}",
|
||||
) from exc
|
||||
|
||||
# Reject rather than silently strip — otherwise the client's form-field
|
||||
# name won't match what we register with the parser below.
|
||||
if safe_filename != filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Upload filename must be a plain file name, not a path",
|
||||
)
|
||||
|
||||
if not is_allowed_screenshot_file(safe_filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"Unsupported image file type. Allowed: "
|
||||
f"{', '.join(sorted(ALLOWED_SCREENSHOT_EXTENSIONS))}"
|
||||
),
|
||||
)
|
||||
|
||||
screenshot_dir_rel = f"{rom.full_path}/{SCREENSHOT_FOLDER}"
|
||||
file_rel_path = f"{screenshot_dir_rel}/{safe_filename}"
|
||||
file_location = fs_rom_handler.validate_path(file_rel_path)
|
||||
log.info(f"Uploading screenshot to {hl(str(file_location))}")
|
||||
|
||||
await fs_rom_handler.make_directory(screenshot_dir_rel)
|
||||
|
||||
parser = StreamingFormDataParser(headers=request.headers)
|
||||
parser.register("x-upload-platform", NullTarget())
|
||||
parser.register(safe_filename, FileTarget(str(file_location)))
|
||||
|
||||
def cleanup_partial_file():
|
||||
if file_location.exists():
|
||||
file_location.unlink()
|
||||
|
||||
try:
|
||||
async for chunk in request.stream():
|
||||
parser.data_received(chunk)
|
||||
except ClientDisconnect:
|
||||
log.error("Client disconnected during upload")
|
||||
cleanup_partial_file()
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.error("Error uploading screenshot", exc_info=exc)
|
||||
cleanup_partial_file()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="There was an error uploading the screenshot",
|
||||
) from exc
|
||||
|
||||
stat = os.stat(file_location)
|
||||
existing = db_rom_handler.get_rom_file_by_path(
|
||||
rom_id=rom.id, file_path=screenshot_dir_rel, file_name=safe_filename
|
||||
)
|
||||
if existing:
|
||||
db_rom_handler.update_rom_file(
|
||||
existing.id,
|
||||
{
|
||||
"file_size_bytes": stat.st_size,
|
||||
"last_modified": stat.st_mtime,
|
||||
"category": RomFileCategory.SCREENSHOT,
|
||||
"missing_from_fs": False,
|
||||
},
|
||||
)
|
||||
else:
|
||||
db_rom_handler.add_rom_file(
|
||||
RomFile(
|
||||
rom_id=rom.id,
|
||||
file_name=safe_filename,
|
||||
file_path=screenshot_dir_rel,
|
||||
file_size_bytes=stat.st_size,
|
||||
last_modified=stat.st_mtime,
|
||||
category=RomFileCategory.SCREENSHOT,
|
||||
)
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.delete,
|
||||
"/{id}/screenshots/{file_id}",
|
||||
[Scope.ROMS_WRITE],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def delete_rom_screenshot(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
file_id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)],
|
||||
) -> Response:
|
||||
"""Delete a single screenshot file from a ROM."""
|
||||
|
||||
rom = db_rom_handler.get_rom(id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
rom_file = db_rom_handler.get_rom_file_by_id(file_id)
|
||||
if (
|
||||
not rom_file
|
||||
or rom_file.rom_id != rom.id
|
||||
or rom_file.category != RomFileCategory.SCREENSHOT
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Screenshot file not found",
|
||||
)
|
||||
|
||||
file_rel_path = rom_file.full_path
|
||||
|
||||
try:
|
||||
await fs_rom_handler.remove_file(file_rel_path)
|
||||
except FileNotFoundError:
|
||||
log.warning(
|
||||
f"Screenshot file {hl(file_rel_path)} not found on disk; "
|
||||
f"removing DB row anyway"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
f"Error deleting screenshot {hl(file_rel_path)}",
|
||||
exc_info=exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="There was an error deleting the screenshot",
|
||||
) from exc
|
||||
|
||||
db_rom_handler.delete_rom_file(file_id)
|
||||
|
||||
log.info(
|
||||
f"Deleted screenshot {hl(rom_file.file_name)} from "
|
||||
f"{hl(rom.name or 'ROM', color=BLUE)} [{hl(rom.fs_name)}]"
|
||||
)
|
||||
|
||||
return Response()
|
||||
@@ -0,0 +1,259 @@
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from fastapi import Path as PathVar
|
||||
from fastapi import Request, status
|
||||
from fastapi.responses import Response
|
||||
from starlette.requests import ClientDisconnect
|
||||
from streaming_form_data import StreamingFormDataParser
|
||||
from streaming_form_data.targets import FileTarget, NullTarget
|
||||
|
||||
from decorators.auth import protected_route
|
||||
from endpoints.responses.rom import RomFileAudioMetaSchema, SoundtrackTrackMetaSchema
|
||||
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_rom_handler
|
||||
from handler.filesystem import fs_rom_handler
|
||||
from logger.formatter import BLUE
|
||||
from logger.formatter import highlight as hl
|
||||
from logger.logger import log
|
||||
from models.rom import RomFile, RomFileCategory
|
||||
from utils.audio_tags import (
|
||||
ALLOWED_AUDIO_EXTENSIONS,
|
||||
extract_audio_meta,
|
||||
is_allowed_audio_file,
|
||||
persist_cover_and_build_meta,
|
||||
remove_persisted_cover,
|
||||
)
|
||||
from utils.router import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SOUNDTRACK_FOLDER = "soundtrack"
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.get,
|
||||
"/{id}/soundtracks/metadata",
|
||||
[Scope.ROMS_READ],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def get_rom_soundtrack_metadata(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
) -> list[SoundtrackTrackMetaSchema]:
|
||||
"""Return compact audio metadata for every soundtrack file of a ROM."""
|
||||
|
||||
rom = db_rom_handler.get_rom(id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
tracks = db_rom_handler.get_rom_files_by_category(
|
||||
rom_id=rom.id, category=RomFileCategory.SOUNDTRACK
|
||||
)
|
||||
|
||||
return [
|
||||
SoundtrackTrackMetaSchema(
|
||||
file_id=f.id,
|
||||
file_name=f.file_name,
|
||||
file_size_bytes=f.file_size_bytes,
|
||||
audio_meta=(
|
||||
RomFileAudioMetaSchema.model_validate(f.audio_meta)
|
||||
if f.audio_meta
|
||||
else None
|
||||
),
|
||||
)
|
||||
for f in tracks
|
||||
]
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/{id}/soundtracks",
|
||||
[Scope.ROMS_WRITE],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def add_rom_soundtracks(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
filename: Annotated[
|
||||
str,
|
||||
Header(
|
||||
description="The name of the file being uploaded.",
|
||||
alias="x-upload-filename",
|
||||
),
|
||||
],
|
||||
) -> Response:
|
||||
"""Upload a soundtrack audio file for a multi-file ROM."""
|
||||
|
||||
rom = db_rom_handler.get_rom(id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
if rom.has_simple_single_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Soundtracks can only be uploaded to folder-based ROMs",
|
||||
)
|
||||
|
||||
try:
|
||||
safe_filename = fs_rom_handler._sanitize_filename(filename)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid upload filename: {exc}",
|
||||
) from exc
|
||||
|
||||
# Reject rather than silently strip — otherwise the client's form-field
|
||||
# name won't match what we register with the parser below.
|
||||
if safe_filename != filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Upload filename must be a plain file name, not a path",
|
||||
)
|
||||
|
||||
if not is_allowed_audio_file(safe_filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"Unsupported audio file type. Allowed: "
|
||||
f"{', '.join(sorted(ALLOWED_AUDIO_EXTENSIONS))}"
|
||||
),
|
||||
)
|
||||
|
||||
soundtrack_dir_rel = f"{rom.full_path}/{SOUNDTRACK_FOLDER}"
|
||||
file_rel_path = f"{soundtrack_dir_rel}/{safe_filename}"
|
||||
file_location = fs_rom_handler.validate_path(file_rel_path)
|
||||
log.info(f"Uploading soundtrack to {hl(str(file_location))}")
|
||||
|
||||
await fs_rom_handler.make_directory(soundtrack_dir_rel)
|
||||
|
||||
parser = StreamingFormDataParser(headers=request.headers)
|
||||
parser.register("x-upload-platform", NullTarget())
|
||||
parser.register(safe_filename, FileTarget(str(file_location)))
|
||||
|
||||
def cleanup_partial_file():
|
||||
if file_location.exists():
|
||||
file_location.unlink()
|
||||
|
||||
try:
|
||||
async for chunk in request.stream():
|
||||
parser.data_received(chunk)
|
||||
except ClientDisconnect:
|
||||
log.error("Client disconnected during upload")
|
||||
cleanup_partial_file()
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.error("Error uploading soundtrack", exc_info=exc)
|
||||
cleanup_partial_file()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="There was an error uploading the soundtrack",
|
||||
) from exc
|
||||
|
||||
stat = os.stat(file_location)
|
||||
audio_meta = extract_audio_meta(str(file_location))
|
||||
existing = db_rom_handler.get_rom_file_by_path(
|
||||
rom_id=rom.id, file_path=soundtrack_dir_rel, file_name=safe_filename
|
||||
)
|
||||
if existing:
|
||||
# Reuploading: drop stale cover first so a new id-based path can replace it.
|
||||
if existing.audio_meta and existing.audio_meta.get("cover_path"):
|
||||
remove_persisted_cover(existing.audio_meta["cover_path"])
|
||||
saved = db_rom_handler.update_rom_file(
|
||||
existing.id,
|
||||
{
|
||||
"file_size_bytes": stat.st_size,
|
||||
"last_modified": stat.st_mtime,
|
||||
"category": RomFileCategory.SOUNDTRACK,
|
||||
"audio_meta": audio_meta,
|
||||
"missing_from_fs": False,
|
||||
},
|
||||
)
|
||||
else:
|
||||
saved = db_rom_handler.add_rom_file(
|
||||
RomFile(
|
||||
rom_id=rom.id,
|
||||
file_name=safe_filename,
|
||||
file_path=soundtrack_dir_rel,
|
||||
file_size_bytes=stat.st_size,
|
||||
last_modified=stat.st_mtime,
|
||||
category=RomFileCategory.SOUNDTRACK,
|
||||
audio_meta=audio_meta,
|
||||
)
|
||||
)
|
||||
|
||||
if saved and audio_meta and audio_meta.get("has_embedded_cover"):
|
||||
persisted_meta = persist_cover_and_build_meta(
|
||||
audio_full_path=str(file_location),
|
||||
platform_id=rom.platform_id,
|
||||
rom_id=rom.id,
|
||||
file_id=saved.id,
|
||||
audio_meta=audio_meta,
|
||||
)
|
||||
if persisted_meta:
|
||||
db_rom_handler.update_rom_file(saved.id, {"audio_meta": persisted_meta})
|
||||
|
||||
return Response(status_code=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.delete,
|
||||
"/{id}/soundtracks/{file_id}",
|
||||
[Scope.ROMS_WRITE],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def delete_rom_soundtrack(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
file_id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)],
|
||||
) -> Response:
|
||||
"""Delete a single soundtrack file from a ROM."""
|
||||
|
||||
rom = db_rom_handler.get_rom(id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
rom_file = db_rom_handler.get_rom_file_by_id(file_id)
|
||||
if (
|
||||
not rom_file
|
||||
or rom_file.rom_id != rom.id
|
||||
or rom_file.category != RomFileCategory.SOUNDTRACK
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Soundtrack file not found",
|
||||
)
|
||||
|
||||
file_rel_path = rom_file.full_path
|
||||
|
||||
try:
|
||||
await fs_rom_handler.remove_file(file_rel_path)
|
||||
except FileNotFoundError:
|
||||
log.warning(
|
||||
f"Soundtrack file {hl(file_rel_path)} not found on disk; "
|
||||
f"removing DB row anyway"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
f"Error deleting soundtrack {hl(file_rel_path)}",
|
||||
exc_info=exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="There was an error deleting the soundtrack",
|
||||
) from exc
|
||||
|
||||
if rom_file.audio_meta and rom_file.audio_meta.get("cover_path"):
|
||||
remove_persisted_cover(rom_file.audio_meta["cover_path"])
|
||||
|
||||
db_rom_handler.delete_rom_file(file_id)
|
||||
|
||||
log.info(
|
||||
f"Deleted soundtrack {hl(rom_file.file_name)} from "
|
||||
f"{hl(rom.name or 'ROM', color=BLUE)} [{hl(rom.fs_name)}]"
|
||||
)
|
||||
|
||||
return Response()
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from uuid import UUID, uuid4
|
||||
@@ -9,6 +8,7 @@ from anyio import open_file
|
||||
from fastapi import Header, HTTPException, Request, status
|
||||
from starlette.responses import Response
|
||||
|
||||
from config import ROMM_BASE_PATH, ROMM_TMP_PATH
|
||||
from decorators.auth import protected_route
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_platform_handler
|
||||
@@ -22,7 +22,10 @@ router = APIRouter(
|
||||
tags=["upload"],
|
||||
)
|
||||
|
||||
ROM_UPLOAD_TMP_BASE = Path(tempfile.gettempdir()) / "romm" / "uploads"
|
||||
# Store upload chunks under ROMM_BASE_PATH (disk-backed) by default.
|
||||
# Users can override the tmp location with the ROMM_TMP_PATH env variable.
|
||||
_tmp_root = Path(ROMM_TMP_PATH) if ROMM_TMP_PATH else Path(ROMM_BASE_PATH)
|
||||
ROM_UPLOAD_TMP_BASE = _tmp_root / "tmp" / "uploads"
|
||||
ROM_UPLOAD_TTL = 86400 # 24 hours
|
||||
ROM_ASSEMBLY_CHUNK_SIZE = 8192 # 8KB read buffer during assembly
|
||||
ROM_UPLOAD_MAX_CHUNK_SIZE = 64 * 1024 * 1024 # 64MB hard cap per chunk
|
||||
|
||||
+95
-52
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
@@ -34,34 +35,66 @@ from utils.router import APIRouter
|
||||
|
||||
def _build_save_schema(
|
||||
save: Save,
|
||||
syncs: Sequence[tuple[DeviceSaveSync, str | None]] = (),
|
||||
device: Device | None = None,
|
||||
sync: DeviceSaveSync | None = None,
|
||||
) -> SaveSchema:
|
||||
"""Attach one ``DeviceSyncSchema`` per device that has synced this save.
|
||||
|
||||
``syncs`` is the full list of sync rows (paired with device name) for this
|
||||
save across every device, so clients can attribute the save to its creator.
|
||||
``device`` is the caller's device, when supplied: its entry is emitted first
|
||||
for stable ordering and old-client compatibility, and a placeholder entry is
|
||||
synthesized when the caller has not yet synced this save.
|
||||
"""
|
||||
save_schema = SaveSchema.model_validate(save)
|
||||
|
||||
if device:
|
||||
if sync:
|
||||
is_current = to_utc(sync.last_synced_at) >= to_utc(save.updated_at)
|
||||
last_synced = sync.last_synced_at
|
||||
is_untracked = sync.is_untracked
|
||||
else:
|
||||
is_current = False
|
||||
last_synced = save.updated_at
|
||||
is_untracked = False
|
||||
save_updated = to_utc(save.updated_at)
|
||||
caller_present = False
|
||||
entries: list[DeviceSyncSchema] = []
|
||||
for sync, device_name in syncs:
|
||||
if device and sync.device_id == device.id:
|
||||
caller_present = True
|
||||
entries.append(
|
||||
DeviceSyncSchema(
|
||||
device_id=sync.device_id,
|
||||
device_name=device_name,
|
||||
last_synced_at=sync.last_synced_at,
|
||||
is_untracked=sync.is_untracked,
|
||||
is_current=to_utc(sync.last_synced_at) >= save_updated,
|
||||
)
|
||||
)
|
||||
|
||||
save_schema.device_syncs = [
|
||||
if device and not caller_present:
|
||||
entries.append(
|
||||
DeviceSyncSchema(
|
||||
device_id=device.id,
|
||||
device_name=device.name,
|
||||
last_synced_at=last_synced,
|
||||
is_untracked=is_untracked,
|
||||
is_current=is_current,
|
||||
last_synced_at=save.updated_at,
|
||||
is_untracked=False,
|
||||
is_current=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if device:
|
||||
entries.sort(key=lambda entry: entry.device_id != device.id)
|
||||
|
||||
save_schema.device_syncs = entries
|
||||
return save_schema
|
||||
|
||||
|
||||
def _syncs_for_save(
|
||||
save_id: int, device: Device | None
|
||||
) -> list[tuple[DeviceSaveSync, str | None]]:
|
||||
"""Fetch every device sync for a single save when a device is in context.
|
||||
|
||||
Device attribution is only meaningful to a device-scoped caller, so callers
|
||||
without a device get an empty list and no query is issued.
|
||||
"""
|
||||
if not device:
|
||||
return []
|
||||
return db_device_save_sync_handler.get_syncs_for_saves([save_id]).get(save_id, [])
|
||||
|
||||
|
||||
DATETIME_TAG_PATTERN = re.compile(r" \[\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\]")
|
||||
|
||||
|
||||
@@ -162,7 +195,7 @@ async def add_save(
|
||||
actual_filename = _apply_datetime_tag(sanitized_save_filename)
|
||||
|
||||
db_save = db_save_handler.get_save_by_filename(
|
||||
user_id=request.user.id, rom_id=rom.id, file_name=actual_filename
|
||||
user_id=request.user.id, rom_id=rom.id, file_name=actual_filename, slot=slot
|
||||
)
|
||||
|
||||
if device and slot and not overwrite:
|
||||
@@ -222,18 +255,16 @@ async def add_save(
|
||||
user_id=request.user.id,
|
||||
rom_id=rom.id,
|
||||
content_hash=scanned_save.content_hash,
|
||||
slot=slot,
|
||||
)
|
||||
if existing_by_hash:
|
||||
try:
|
||||
await fs_asset_handler.remove_file(f"{saves_path}/{actual_filename}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
sync = None
|
||||
if device:
|
||||
sync = db_device_save_sync_handler.get_sync(
|
||||
device_id=device.id, save_id=existing_by_hash.id
|
||||
)
|
||||
return _build_save_schema(existing_by_hash, device, sync)
|
||||
return _build_save_schema(
|
||||
existing_by_hash, _syncs_for_save(existing_by_hash.id, device), device
|
||||
)
|
||||
|
||||
if db_save:
|
||||
update_data: dict = {
|
||||
@@ -248,6 +279,7 @@ async def add_save(
|
||||
scanned_save.user_id = request.user.id
|
||||
scanned_save.emulator = emulator
|
||||
scanned_save.slot = slot
|
||||
scanned_save.origin_device_id = device.id if device else None
|
||||
db_save = db_save_handler.add_save(save=scanned_save)
|
||||
|
||||
if device:
|
||||
@@ -321,12 +353,7 @@ async def add_save(
|
||||
rom_user.id, {"last_played": datetime.now(timezone.utc)}
|
||||
)
|
||||
|
||||
sync = None
|
||||
if device:
|
||||
sync = db_device_save_sync_handler.get_sync(
|
||||
device_id=device.id, save_id=db_save.id
|
||||
)
|
||||
return _build_save_schema(db_save, device, sync)
|
||||
return _build_save_schema(db_save, _syncs_for_save(db_save.id, device), device)
|
||||
|
||||
|
||||
@protected_route(router.get, "", [Scope.ASSETS_READ])
|
||||
@@ -349,13 +376,13 @@ def get_saves(
|
||||
if not device:
|
||||
return [_build_save_schema(save) for save in saves]
|
||||
|
||||
syncs = db_device_save_sync_handler.get_syncs_for_device_and_saves(
|
||||
device_id=device.id, save_ids=[s.id for s in saves]
|
||||
syncs_by_save_id = db_device_save_sync_handler.get_syncs_for_saves(
|
||||
[s.id for s in saves]
|
||||
)
|
||||
sync_by_save_id = {s.save_id: s for s in syncs}
|
||||
|
||||
return [
|
||||
_build_save_schema(save, device, sync_by_save_id.get(save.id)) for save in saves
|
||||
_build_save_schema(save, syncs_by_save_id.get(save.id, []), device)
|
||||
for save in saves
|
||||
]
|
||||
|
||||
|
||||
@@ -403,12 +430,7 @@ def get_save(request: Request, id: int, device_id: str | None = None) -> SaveSch
|
||||
detail=f"Save with ID {id} not found",
|
||||
)
|
||||
|
||||
sync = None
|
||||
if device:
|
||||
sync = db_device_save_sync_handler.get_sync(
|
||||
device_id=device.id, save_id=save.id
|
||||
)
|
||||
return _build_save_schema(save, device, sync)
|
||||
return _build_save_schema(save, _syncs_for_save(save.id, device), device)
|
||||
|
||||
|
||||
@protected_route(router.get, "/{id}/content", [Scope.ASSETS_READ])
|
||||
@@ -424,12 +446,14 @@ def download_save(
|
||||
device_id, request.user.id, request.auth.scopes, Scope.DEVICES_READ
|
||||
)
|
||||
|
||||
save = db_save_handler.get_save(user_id=request.user.id, id=id)
|
||||
if not save:
|
||||
# Owner can download any of their saves; everyone else only public ones.
|
||||
save = db_save_handler.get_save_by_id(id)
|
||||
if not save or (save.user_id != request.user.id and not save.is_public):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Save with ID {id} not found",
|
||||
)
|
||||
is_owner = save.user_id == request.user.id
|
||||
|
||||
try:
|
||||
file_path = fs_asset_handler.validate_path(save.full_path)
|
||||
@@ -445,7 +469,8 @@ def download_save(
|
||||
detail="Save file not found on disk",
|
||||
)
|
||||
|
||||
if device and optimistic:
|
||||
# Sync bookkeeping only makes sense for the owner's own saves.
|
||||
if device and optimistic and is_owner:
|
||||
db_device_save_sync_handler.upsert_sync(
|
||||
device_id=device.id,
|
||||
save_id=save.id,
|
||||
@@ -474,14 +499,14 @@ def confirm_download(
|
||||
)
|
||||
|
||||
device = _resolve_device(device_id, request.user.id)
|
||||
sync = db_device_save_sync_handler.upsert_sync(
|
||||
db_device_save_sync_handler.upsert_sync(
|
||||
device_id=device_id,
|
||||
save_id=save.id,
|
||||
synced_at=save.updated_at,
|
||||
)
|
||||
db_device_handler.update_last_seen(device_id=device_id, user_id=request.user.id)
|
||||
|
||||
return _build_save_schema(save, device, sync)
|
||||
return _build_save_schema(save, _syncs_for_save(save.id, device), device)
|
||||
|
||||
|
||||
@protected_route(router.put, "/{id}", [Scope.ASSETS_WRITE])
|
||||
@@ -580,12 +605,30 @@ async def update_save(
|
||||
)
|
||||
db_device_handler.update_last_seen(device_id=device.id, user_id=request.user.id)
|
||||
|
||||
sync = None
|
||||
if device:
|
||||
sync = db_device_save_sync_handler.get_sync(
|
||||
device_id=device.id, save_id=db_save.id
|
||||
return _build_save_schema(db_save, _syncs_for_save(db_save.id, device), device)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.put,
|
||||
"/{id}/visibility",
|
||||
[Scope.ASSETS_WRITE],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
def update_save_visibility(
|
||||
request: Request,
|
||||
id: int,
|
||||
is_public: Annotated[bool, Body(embed=True)],
|
||||
) -> SaveSchema:
|
||||
"""Toggle a save's public/private visibility (owner only)."""
|
||||
save = db_save_handler.get_save_by_id(id)
|
||||
if not save or save.user_id != request.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Save with ID {id} not found",
|
||||
)
|
||||
return _build_save_schema(db_save, device, sync)
|
||||
|
||||
updated = db_save_handler.update_save(id, {"is_public": is_public})
|
||||
return _build_save_schema(updated)
|
||||
|
||||
|
||||
@protected_route(
|
||||
@@ -660,11 +703,11 @@ def track_save(
|
||||
)
|
||||
|
||||
device = _resolve_device(device_id, request.user.id)
|
||||
sync = db_device_save_sync_handler.set_untracked(
|
||||
db_device_save_sync_handler.set_untracked(
|
||||
device_id=device_id, save_id=id, untracked=False
|
||||
)
|
||||
|
||||
return _build_save_schema(save, device, sync)
|
||||
return _build_save_schema(save, _syncs_for_save(save.id, device), device)
|
||||
|
||||
|
||||
@protected_route(router.post, "/{id}/untrack", [Scope.DEVICES_WRITE])
|
||||
@@ -682,8 +725,8 @@ def untrack_save(
|
||||
)
|
||||
|
||||
device = _resolve_device(device_id, request.user.id)
|
||||
sync = db_device_save_sync_handler.set_untracked(
|
||||
db_device_save_sync_handler.set_untracked(
|
||||
device_id=device_id, save_id=id, untracked=True
|
||||
)
|
||||
|
||||
return _build_save_schema(save, device, sync)
|
||||
return _build_save_schema(save, _syncs_for_save(save.id, device), device)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
from fastapi import File, HTTPException, Request, UploadFile, status
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Body, File, HTTPException
|
||||
from fastapi import Path as PathVar
|
||||
from fastapi import Request, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
|
||||
from decorators.auth import protected_route
|
||||
from endpoints.responses.assets import ScreenshotSchema
|
||||
@@ -12,6 +17,10 @@ from logger.formatter import highlight as hl
|
||||
from logger.logger import log
|
||||
from utils.filesystem import sanitize_filename
|
||||
from utils.router import APIRouter
|
||||
from utils.screenshots import (
|
||||
ALLOWED_SCREENSHOT_EXTENSIONS,
|
||||
is_allowed_screenshot_file,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/screenshots",
|
||||
@@ -27,12 +36,18 @@ async def add_screenshot(
|
||||
rom_id: int,
|
||||
screenshotFile: UploadFile = SCREENSHOT_FILE_UPLOAD,
|
||||
) -> ScreenshotSchema:
|
||||
"""Upload a per-user gallery screenshot for a ROM.
|
||||
|
||||
Stored under the user's asset folder (not the ROM folder) and flagged
|
||||
`is_gallery=True` so it surfaces in the gallery; `is_public=False` so it
|
||||
stays private until the owner shares it.
|
||||
"""
|
||||
rom = db_rom_handler.get_rom(id=rom_id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(rom_id)
|
||||
|
||||
current_user = request.user
|
||||
log.info(f"Uploading screenshots to {hl(str(rom.name), color=BLUE)}")
|
||||
log.info(f"Uploading screenshot to {hl(str(rom.name), color=BLUE)}")
|
||||
|
||||
screenshots_path = fs_asset_handler.build_screenshots_file_path(
|
||||
user=request.user, platform_fs_slug=rom.platform_slug, rom_id=rom.id
|
||||
@@ -53,6 +68,15 @@ async def add_screenshot(
|
||||
detail=f"Invalid screenshot filename: {str(exc)}",
|
||||
) from exc
|
||||
|
||||
if not is_allowed_screenshot_file(sanitized_screenshot_filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"Unsupported image file type. Allowed: "
|
||||
f"{', '.join(sorted(ALLOWED_SCREENSHOT_EXTENSIONS))}"
|
||||
),
|
||||
)
|
||||
|
||||
await fs_asset_handler.write_file(
|
||||
file=screenshotFile,
|
||||
path=screenshots_path,
|
||||
@@ -74,17 +98,73 @@ async def add_screenshot(
|
||||
if db_screenshot:
|
||||
db_screenshot = db_screenshot_handler.update_screenshot(
|
||||
db_screenshot.id,
|
||||
{"file_size_bytes": scanned_screenshot.file_size_bytes},
|
||||
{
|
||||
"file_size_bytes": scanned_screenshot.file_size_bytes,
|
||||
"is_gallery": True,
|
||||
"missing_from_fs": False,
|
||||
},
|
||||
)
|
||||
else:
|
||||
scanned_screenshot.rom_id = rom.id
|
||||
scanned_screenshot.user_id = current_user.id
|
||||
scanned_screenshot.is_gallery = True
|
||||
db_screenshot = db_screenshot_handler.add_screenshot(
|
||||
screenshot=scanned_screenshot
|
||||
)
|
||||
|
||||
rom = db_rom_handler.get_rom(id=rom_id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(rom_id)
|
||||
|
||||
return ScreenshotSchema.model_validate(db_screenshot)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.put,
|
||||
"/{id}",
|
||||
[Scope.ASSETS_WRITE],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def update_screenshot(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Screenshot internal id.", ge=1)],
|
||||
is_public: Annotated[bool, Body(embed=True)],
|
||||
) -> ScreenshotSchema:
|
||||
"""Toggle a gallery screenshot's public/private visibility (owner only)."""
|
||||
screenshot = db_screenshot_handler.get_screenshot_by_id(id)
|
||||
if not screenshot or screenshot.user_id != request.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Screenshot not found",
|
||||
)
|
||||
|
||||
updated = db_screenshot_handler.update_screenshot(id, {"is_public": is_public})
|
||||
return ScreenshotSchema.model_validate(updated)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.delete,
|
||||
"/{id}",
|
||||
[Scope.ASSETS_WRITE],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def delete_screenshot(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Screenshot internal id.", ge=1)],
|
||||
) -> Response:
|
||||
"""Delete a gallery screenshot — its file and DB row (owner only)."""
|
||||
screenshot = db_screenshot_handler.get_screenshot_by_id(id)
|
||||
if not screenshot or screenshot.user_id != request.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Screenshot not found",
|
||||
)
|
||||
|
||||
try:
|
||||
await fs_asset_handler.remove_file(file_path=screenshot.full_path)
|
||||
except FileNotFoundError:
|
||||
log.warning(
|
||||
f"Screenshot file {hl(screenshot.full_path)} not found on disk; "
|
||||
f"removing DB row anyway"
|
||||
)
|
||||
|
||||
db_screenshot_handler.delete_screenshot(screenshot.id)
|
||||
log.info(f"Deleted screenshot {hl(screenshot.file_name)}")
|
||||
|
||||
return Response()
|
||||
|
||||
@@ -100,7 +100,7 @@ async def search_rom(
|
||||
if search_by.lower() == "id":
|
||||
try:
|
||||
igdb_rom, moby_rom, ss_rom, lb_rom = await asyncio.gather(
|
||||
meta_igdb_handler.get_matched_rom_by_id(int(search_term)),
|
||||
meta_igdb_handler.get_matched_rom_by_id(rom, int(search_term)),
|
||||
meta_moby_handler.get_matched_rom_by_id(int(search_term)),
|
||||
meta_ss_handler.get_matched_rom_by_id(rom, int(search_term)),
|
||||
meta_launchbox_handler.get_matched_rom_by_id(int(search_term)),
|
||||
@@ -125,7 +125,7 @@ async def search_rom(
|
||||
launchbox_matched_roms,
|
||||
) = await asyncio.gather(
|
||||
meta_igdb_handler.get_matched_roms_by_name(
|
||||
search_term, get_main_platform_igdb_id(rom.platform)
|
||||
rom, search_term, get_main_platform_igdb_id(rom.platform)
|
||||
),
|
||||
meta_moby_handler.get_matched_roms_by_name(
|
||||
search_term, rom.platform.moby_id
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Socket.IO events for real-time user game activity.
|
||||
|
||||
Handles:
|
||||
- activity:start - client reports starting a game (emits activity:update)
|
||||
- activity:heartbeat - client refreshes TTL while playing (emits activity:update)
|
||||
- activity:stop - client reports stopping (emits activity:clear)
|
||||
- disconnect - safety net: clears any activity registered for the socket
|
||||
|
||||
The acting user is never taken from the client payload — it is resolved from
|
||||
the authenticated socket session (stored on connect, see ``endpoints.sockets``)
|
||||
so a client cannot broadcast a "now playing" session on behalf of another user.
|
||||
Only ``rom_id`` / ``device_id`` come from the client.
|
||||
|
||||
All events broadcast to every connected client on the main `/ws` namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from endpoints.responses.activity import ActivityClearSchema
|
||||
from handler.activity_handler import ActivityEntry, activity_handler
|
||||
from handler.database import (
|
||||
db_device_handler,
|
||||
db_rom_handler,
|
||||
db_save_handler,
|
||||
db_user_handler,
|
||||
)
|
||||
from handler.socket_handler import socket_handler
|
||||
from logger.logger import log
|
||||
from utils.screenshots import continue_playing_screenshot
|
||||
|
||||
# Socket-session key holding the authenticated user id, written by the connect
|
||||
# handler. Identity for every activity event is derived from this, not payload.
|
||||
AUTH_USER_SESSION_KEY = "activity_auth_user_id"
|
||||
|
||||
|
||||
class ActivityEventPayload(TypedDict, total=False):
|
||||
rom_id: int
|
||||
device_id: str
|
||||
|
||||
|
||||
async def _session(sid: str) -> dict[str, Any]:
|
||||
try:
|
||||
return await socket_handler.socket_server.get_session(sid) or {}
|
||||
except KeyError:
|
||||
return {}
|
||||
|
||||
|
||||
async def store_authenticated_user(sid: str, user_id: int) -> None:
|
||||
"""Record the authenticated user for a socket so activity events trust it.
|
||||
|
||||
Called from the connect handler once the session has been resolved. Without
|
||||
this, activity events have no identity to act on and are ignored.
|
||||
"""
|
||||
existing = await _session(sid)
|
||||
existing[AUTH_USER_SESSION_KEY] = user_id
|
||||
await socket_handler.socket_server.save_session(sid, existing)
|
||||
|
||||
|
||||
async def _authenticated_user_id(sid: str) -> int | None:
|
||||
"""Return the user id resolved at connect time, or ``None`` if unauthenticated."""
|
||||
user_id = (await _session(sid)).get(AUTH_USER_SESSION_KEY)
|
||||
return int(user_id) if user_id is not None else None
|
||||
|
||||
|
||||
async def _store_session(sid: str, user_id: int, device_id: str) -> None:
|
||||
"""Remember the user/device associated with a socket for disconnect cleanup."""
|
||||
existing = await _session(sid)
|
||||
existing["activity_user_id"] = user_id
|
||||
existing["activity_device_id"] = device_id
|
||||
await socket_handler.socket_server.save_session(sid, existing)
|
||||
|
||||
|
||||
async def _build_entry(
|
||||
*, user_id: int, device_id: str, rom_id: int, preserve_started_at: bool
|
||||
) -> ActivityEntry | None:
|
||||
"""Look up DB info and assemble an ActivityEntry. Returns None if invalid."""
|
||||
user = db_user_handler.get_user(user_id)
|
||||
if user is None:
|
||||
log.debug(f"activity: unknown user_id {user_id}")
|
||||
return None
|
||||
|
||||
rom = db_rom_handler.get_rom(rom_id)
|
||||
if rom is None:
|
||||
log.debug(f"activity: unknown rom_id {rom_id}")
|
||||
return None
|
||||
|
||||
platform = rom.platform
|
||||
started_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
if preserve_started_at:
|
||||
existing = await activity_handler.get_active(user_id, device_id)
|
||||
if existing:
|
||||
started_at = existing["started_at"]
|
||||
|
||||
device = db_device_handler.get_device(device_id=device_id, user_id=user_id)
|
||||
device_type = device.client if device else None
|
||||
|
||||
# "Where they are" image — the player's latest save screenshot, else the
|
||||
# title screen / first gameplay screenshot (frontend falls back to cover).
|
||||
latest_save = db_save_handler.get_latest_saves_for_roms(
|
||||
user_id=user_id, rom_ids=[rom_id]
|
||||
).get(rom_id)
|
||||
screenshot_path = continue_playing_screenshot(rom, latest_save) or ""
|
||||
|
||||
return ActivityEntry(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
avatar_path=user.avatar_path or "",
|
||||
rom_id=rom.id,
|
||||
rom_name=rom.name or rom.fs_name,
|
||||
rom_cover_path=rom.path_cover_s or "",
|
||||
screenshot_path=screenshot_path,
|
||||
platform_slug=platform.slug if platform else "",
|
||||
platform_name=((platform.custom_name or platform.name) if platform else ""),
|
||||
device_id=device_id,
|
||||
device_type=device_type or "web",
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
|
||||
def _extract_payload(data: object) -> tuple[str | None, int | None]:
|
||||
"""Return ``(device_id, rom_id)`` parsed from an event payload.
|
||||
|
||||
``user_id`` is deliberately not read from the client — identity comes from
|
||||
the authenticated socket session, never the payload.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None, None
|
||||
device_id = data.get("device_id")
|
||||
if not isinstance(device_id, str) or not device_id:
|
||||
device_id = None
|
||||
try:
|
||||
data_rom_id = data.get("rom_id")
|
||||
rom_id = int(data_rom_id) if data_rom_id else None
|
||||
except (TypeError, ValueError):
|
||||
rom_id = None
|
||||
return device_id, rom_id
|
||||
|
||||
|
||||
@socket_handler.socket_server.on("activity:start") # type: ignore
|
||||
async def activity_start(sid: str, data: ActivityEventPayload) -> None:
|
||||
user_id = await _authenticated_user_id(sid)
|
||||
device_id, rom_id = _extract_payload(data)
|
||||
if user_id is None or device_id is None or rom_id is None:
|
||||
log.debug(f"activity:start ignored (unauthenticated or invalid): {data}")
|
||||
return
|
||||
|
||||
entry = await _build_entry(
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
rom_id=rom_id,
|
||||
preserve_started_at=False,
|
||||
)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
await activity_handler.set_active(entry)
|
||||
await _store_session(sid, user_id, device_id)
|
||||
await socket_handler.socket_server.emit("activity:update", dict(entry))
|
||||
|
||||
|
||||
@socket_handler.socket_server.on("activity:heartbeat") # type: ignore
|
||||
async def activity_heartbeat(sid: str, data: ActivityEventPayload) -> None:
|
||||
user_id = await _authenticated_user_id(sid)
|
||||
device_id, rom_id = _extract_payload(data)
|
||||
if user_id is None or device_id is None or rom_id is None:
|
||||
return
|
||||
|
||||
entry = await _build_entry(
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
rom_id=rom_id,
|
||||
preserve_started_at=True,
|
||||
)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
await activity_handler.set_active(entry)
|
||||
await _store_session(sid, user_id, device_id)
|
||||
await socket_handler.socket_server.emit("activity:update", dict(entry))
|
||||
|
||||
|
||||
@socket_handler.socket_server.on("activity:stop") # type: ignore
|
||||
async def activity_stop(sid: str, data: ActivityEventPayload | None = None) -> None:
|
||||
user_id = await _authenticated_user_id(sid)
|
||||
|
||||
device_id: str | None = None
|
||||
if data:
|
||||
device_id, _ = _extract_payload(data)
|
||||
|
||||
# Fall back to the device stored at start time if the payload omits it.
|
||||
if not device_id:
|
||||
device_id = (await _session(sid)).get("activity_device_id")
|
||||
|
||||
if user_id is None or not device_id:
|
||||
return
|
||||
|
||||
rom_id = await activity_handler.clear_active(int(user_id), device_id)
|
||||
if rom_id is None:
|
||||
return
|
||||
|
||||
await socket_handler.socket_server.emit(
|
||||
"activity:clear",
|
||||
ActivityClearSchema(
|
||||
user_id=int(user_id), device_id=device_id, rom_id=rom_id
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@socket_handler.socket_server.on("disconnect") # type: ignore
|
||||
async def activity_on_disconnect(sid: str) -> None:
|
||||
"""Safety net: clear any activity tied to a disconnecting socket."""
|
||||
session = await _session(sid)
|
||||
user_id = session.get("activity_user_id")
|
||||
device_id = session.get("activity_device_id")
|
||||
if user_id is None or not device_id:
|
||||
return
|
||||
|
||||
rom_id = await activity_handler.clear_active(int(user_id), device_id)
|
||||
if rom_id is None:
|
||||
return
|
||||
|
||||
await socket_handler.socket_server.emit(
|
||||
"activity:clear",
|
||||
ActivityClearSchema(
|
||||
user_id=int(user_id), device_id=device_id, rom_id=rom_id
|
||||
).model_dump(),
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Real-time backend log streaming over Socket.IO (admin only).
|
||||
|
||||
Pieces:
|
||||
- ``connect`` handler on the main socket server: resolves the session user and,
|
||||
if they are an admin, joins them to the ``admin`` room. It never rejects a
|
||||
connection, so the existing scan/sync sockets keep working for everyone.
|
||||
- ``start_log_forwarder``: a single background task (Redis-lock guarded) that
|
||||
subscribes to the ``romm:logs`` pub/sub channel — fed by ``LogStreamHandler``
|
||||
in every backend process — and relays each line to the ``admin`` room.
|
||||
- ``get_recent_logs``: reads the capped ring buffer for backfill on view open.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any, Final
|
||||
|
||||
import socketio # type: ignore
|
||||
|
||||
from config import DISABLE_LOGS_VIEWER, REDIS_URL
|
||||
from endpoints.sockets.activity import store_authenticated_user
|
||||
from handler.database import db_user_handler
|
||||
from handler.redis_handler import async_cache
|
||||
from handler.socket_handler import socket_handler
|
||||
from logger.log_stream_handler import LOG_BUFFER_KEY, LOG_CHANNEL
|
||||
from logger.logger import log
|
||||
from models.user import Role
|
||||
from utils import json_module
|
||||
from utils.auth import get_session_from_environ
|
||||
|
||||
ADMIN_ROOM: Final = "admin"
|
||||
FORWARDER_LOCK_KEY: Final = "romm:logs:forwarder"
|
||||
FORWARDER_LOCK_TTL: Final = 30 # seconds
|
||||
|
||||
|
||||
@socket_handler.socket_server.on("connect") # type: ignore
|
||||
async def connect(sid: str, environ: dict[str, Any], auth: Any = None) -> None:
|
||||
"""Resolve the authenticated user on socket connect.
|
||||
|
||||
Stores the user id in the socket session so activity events can trust the
|
||||
server-resolved identity instead of a client-supplied ``user_id``, and joins
|
||||
admin users to the log-streaming room. Always returns ``None`` (accepts the
|
||||
connection) — only identity storage and room membership are gated, so the
|
||||
existing scan/sync sockets keep working for everyone.
|
||||
"""
|
||||
try:
|
||||
session = await get_session_from_environ(environ)
|
||||
if session.get("iss") != "romm:auth":
|
||||
return
|
||||
|
||||
username = session.get("sub")
|
||||
if not username:
|
||||
return
|
||||
|
||||
user = db_user_handler.get_user_by_username(username)
|
||||
if not user or not user.enabled:
|
||||
return
|
||||
|
||||
await store_authenticated_user(sid, user.id)
|
||||
|
||||
if not DISABLE_LOGS_VIEWER and user.role == Role.ADMIN:
|
||||
await socket_handler.socket_server.enter_room(sid, ADMIN_ROOM)
|
||||
except Exception: # noqa: BLE001 - never let auth resolution refuse a socket
|
||||
log.exception("Failed to resolve user on socket connect")
|
||||
|
||||
|
||||
async def get_recent_logs(limit: int) -> list[dict[str, Any]]:
|
||||
"""Return the most recent buffered log lines in chronological order."""
|
||||
raw = await async_cache.lrange(LOG_BUFFER_KEY, 0, max(0, limit - 1))
|
||||
entries: list[dict[str, Any]] = []
|
||||
for item in raw:
|
||||
try:
|
||||
entries.append(json_module.loads(item))
|
||||
except Exception: # noqa: BLE001 - skip malformed entry # nosec B112
|
||||
continue
|
||||
# The buffer is newest-first (LPUSH); callers want oldest-first.
|
||||
entries.reverse()
|
||||
return entries
|
||||
|
||||
|
||||
async def start_log_forwarder() -> None:
|
||||
"""Relay log lines from Redis pub/sub to admin Socket.IO clients.
|
||||
|
||||
Guarded by a Redis lock so that, when more than one web worker is running
|
||||
(WEB_SERVER_CONCURRENCY > 1), exactly one forwards and clients don't receive
|
||||
duplicate lines.
|
||||
"""
|
||||
if DISABLE_LOGS_VIEWER:
|
||||
return
|
||||
|
||||
lock_id = str(uuid.uuid4())
|
||||
pubsub = None
|
||||
# Write-only manager for emitting — the same proven path scan/sync use. It
|
||||
# publishes the room emit to Redis; the main socket server's read-side
|
||||
# manager resolves `admin` room membership and delivers. Created inside the
|
||||
# running loop (like sync.py) rather than at import time.
|
||||
socket_manager = socketio.AsyncRedisManager(REDIS_URL, write_only=True)
|
||||
try:
|
||||
while True:
|
||||
got_lock = await async_cache.set(
|
||||
FORWARDER_LOCK_KEY, lock_id, nx=True, ex=FORWARDER_LOCK_TTL
|
||||
)
|
||||
if not got_lock:
|
||||
# Another worker owns the forwarder; check back periodically in
|
||||
# case it dies and the lock expires.
|
||||
await asyncio.sleep(FORWARDER_LOCK_TTL / 2)
|
||||
continue
|
||||
|
||||
pubsub = async_cache.pubsub()
|
||||
await pubsub.subscribe(LOG_CHANNEL)
|
||||
log.info("Log stream forwarder started")
|
||||
try:
|
||||
while True:
|
||||
# Heartbeat the lock, but only while we still own it. If
|
||||
# another worker took it (after a stall / Redis hiccup),
|
||||
# stop forwarding to avoid duplicate lines — the outer loop
|
||||
# will then contend for the lock again.
|
||||
if await async_cache.get(FORWARDER_LOCK_KEY) != lock_id:
|
||||
break
|
||||
await async_cache.set(
|
||||
FORWARDER_LOCK_KEY, lock_id, ex=FORWARDER_LOCK_TTL
|
||||
)
|
||||
message = await pubsub.get_message(
|
||||
ignore_subscribe_messages=True,
|
||||
timeout=FORWARDER_LOCK_TTL / 3,
|
||||
)
|
||||
if not message:
|
||||
continue
|
||||
data = message.get("data")
|
||||
if not data:
|
||||
continue
|
||||
# One bad message or emit must not kill the forwarder —
|
||||
# swallow per-line and keep relaying.
|
||||
try:
|
||||
payload = json_module.loads(data)
|
||||
await socket_manager.emit(
|
||||
"logs:entry", payload, room=ADMIN_ROOM
|
||||
)
|
||||
except Exception: # noqa: BLE001 - keep forwarding # nosec B112
|
||||
continue
|
||||
finally:
|
||||
await pubsub.unsubscribe(LOG_CHANNEL)
|
||||
await pubsub.aclose() # type: ignore[attr-defined]
|
||||
pubsub = None
|
||||
except asyncio.CancelledError:
|
||||
if pubsub is not None:
|
||||
await pubsub.aclose() # type: ignore[attr-defined]
|
||||
# Release the lock on shutdown so a restart (e.g. uvicorn --reload)
|
||||
# resumes forwarding immediately instead of waiting out the TTL.
|
||||
await _release_lock(lock_id)
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - never let the forwarder kill the app
|
||||
log.exception("Log forwarder crashed")
|
||||
await _release_lock(lock_id)
|
||||
|
||||
|
||||
async def _release_lock(lock_id: str) -> None:
|
||||
"""Delete the forwarder lock only if we still own it."""
|
||||
try:
|
||||
current = await async_cache.get(FORWARDER_LOCK_KEY)
|
||||
if current == lock_id:
|
||||
await async_cache.delete(FORWARDER_LOCK_KEY)
|
||||
except Exception: # noqa: BLE001 - best-effort cleanup # nosec B110
|
||||
pass
|
||||
@@ -172,7 +172,7 @@ async def webrtc_signal(sid: str, data: WebRTCSignalData):
|
||||
)
|
||||
else:
|
||||
if not target:
|
||||
return # drop message—no recipient
|
||||
return # drop message, no recipient
|
||||
await netplay_socket_handler.socket_server.emit(
|
||||
"webrtc-signal",
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ from rq import Worker
|
||||
from rq.job import Job
|
||||
|
||||
from config import DEV_MODE, REDIS_URL, SCAN_TIMEOUT, SCAN_WORKERS, TASK_RESULT_TTL
|
||||
from config.config_manager import MetadataMediaType
|
||||
from config.config_manager import config_manager as cm
|
||||
from endpoints.responses import TaskType
|
||||
from endpoints.responses.platform import PlatformSchema
|
||||
@@ -31,11 +32,12 @@ from handler.filesystem import (
|
||||
)
|
||||
from handler.filesystem.roms_handler import FSRom
|
||||
from handler.metadata import meta_gamelist_handler, meta_hltb_handler
|
||||
from handler.metadata.ss_handler import get_preferred_media_types
|
||||
from handler.metadata.ss_handler import add_ss_auth_to_url, get_preferred_media_types
|
||||
from handler.redis_handler import get_job_func_name, high_prio_queue, redis_client
|
||||
from handler.scan_handler import (
|
||||
MetadataSource,
|
||||
ScanType,
|
||||
persist_soundtrack_cover,
|
||||
scan_firmware,
|
||||
scan_platform,
|
||||
scan_rom,
|
||||
@@ -159,6 +161,10 @@ def _should_scan_rom(
|
||||
metadata_sources (list[str]): List of metadata sources to be used.
|
||||
"""
|
||||
|
||||
# When roms_ids is provided, the scan is scoped to those roms only
|
||||
if roms_ids:
|
||||
return bool(rom and rom.id in roms_ids)
|
||||
|
||||
# This logic is tricky so only touch it if you know what you're doing"""
|
||||
should_scan = bool(
|
||||
# Any new roms should be scanned
|
||||
@@ -170,19 +176,21 @@ def _should_scan_rom(
|
||||
or (
|
||||
rom
|
||||
and (
|
||||
# Selected ROMs are always scanned
|
||||
(rom.id in roms_ids)
|
||||
# Update scan should scan ROMs identified by the selected metadata sources
|
||||
or (
|
||||
(
|
||||
scan_type == ScanType.UPDATE
|
||||
and rom.is_identified
|
||||
and any(getattr(rom, f"{source}_id") for source in metadata_sources)
|
||||
and any(
|
||||
getattr(rom, f"{source}_id", None)
|
||||
for source in metadata_sources
|
||||
)
|
||||
)
|
||||
# Unmatched scan should scan ROMs that are not identified by the selected metadata sources
|
||||
or (
|
||||
scan_type == ScanType.UNMATCHED
|
||||
and any(
|
||||
not getattr(rom, f"{source}_id") for source in metadata_sources
|
||||
not getattr(rom, f"{source}_id", None)
|
||||
for source in metadata_sources
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -206,12 +214,6 @@ def _should_get_rom_files(
|
||||
newly_added (bool): Whether the rom is newly added.
|
||||
roms_ids (list[int]): List of selected roms to be scanned.
|
||||
"""
|
||||
# Get hash calculation setting from config
|
||||
calculate_hashes = not cm.get_config().SKIP_HASH_CALCULATION
|
||||
|
||||
# Skip file processing entirely if hashes are disabled (except for HASHES scan type)
|
||||
if not calculate_hashes and scan_type != ScanType.HASHES:
|
||||
return False
|
||||
|
||||
return bool(
|
||||
newly_added
|
||||
@@ -235,9 +237,9 @@ async def _identify_rom(
|
||||
roms_ids: list[int],
|
||||
metadata_sources: list[str],
|
||||
launchbox_remote_enabled: bool,
|
||||
playmatch_enabled: bool,
|
||||
socket_manager: socketio.AsyncRedisManager,
|
||||
scan_stats: ScanStats,
|
||||
calculate_hashes: bool = True,
|
||||
) -> None:
|
||||
# Break early if the flag is set
|
||||
if redis_client.get(STOP_SCAN_FLAG):
|
||||
@@ -254,13 +256,6 @@ async def _identify_rom(
|
||||
Rom(
|
||||
fs_name=fs_rom["fs_name"],
|
||||
fs_path=roms_path,
|
||||
fs_name_no_tags=fs_rom_handler.get_file_name_with_no_tags(
|
||||
fs_rom["fs_name"]
|
||||
),
|
||||
fs_name_no_ext=fs_rom_handler.get_file_name_with_no_extension(
|
||||
fs_rom["fs_name"]
|
||||
),
|
||||
fs_extension=fs_rom_handler.parse_file_extension(fs_rom["fs_name"]),
|
||||
regions=parsed_tags.regions,
|
||||
revision=parsed_tags.revision,
|
||||
version=parsed_tags.version,
|
||||
@@ -300,6 +295,36 @@ async def _identify_rom(
|
||||
}
|
||||
)
|
||||
|
||||
# For a COMPLETE rescan, wipe all downloaded resources before re-fetching so
|
||||
# stale files (e.g. a cover from the wrong region) can't be reused. The
|
||||
# post-scan download steps below skip downloads when a file already exists or
|
||||
# when the source URL is unchanged, so the on-disk files must be removed here.
|
||||
if not newly_added and scan_type == ScanType.COMPLETE:
|
||||
try:
|
||||
await fs_resource_handler.remove_cover(rom)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
await fs_resource_handler.remove_manual(rom)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
await fs_resource_handler.remove_directory(
|
||||
f"{rom.fs_resources_path}/screenshots"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
for media_type in MetadataMediaType:
|
||||
try:
|
||||
await fs_resource_handler.remove_media_resources_path(
|
||||
platform.id, rom.id, media_type
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
log.debug(f"Scanning {rom.fs_name}...")
|
||||
scanned_rom = await scan_rom(
|
||||
scan_type=scan_type,
|
||||
@@ -309,6 +334,7 @@ async def _identify_rom(
|
||||
metadata_sources=metadata_sources,
|
||||
newly_added=newly_added,
|
||||
launchbox_remote_enabled=launchbox_remote_enabled,
|
||||
playmatch_enabled=playmatch_enabled,
|
||||
socket_manager=socket_manager,
|
||||
)
|
||||
|
||||
@@ -331,6 +357,7 @@ async def _identify_rom(
|
||||
"rom_user",
|
||||
"last_modified",
|
||||
"files",
|
||||
"sibling_roms",
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -348,15 +375,18 @@ async def _identify_rom(
|
||||
file_size_bytes=file.file_size_bytes,
|
||||
last_modified=file.last_modified,
|
||||
category=file.category,
|
||||
audio_meta=file.audio_meta,
|
||||
crc_hash=file.crc_hash,
|
||||
md5_hash=file.md5_hash,
|
||||
sha1_hash=file.sha1_hash,
|
||||
ra_hash=file.ra_hash,
|
||||
chd_sha1_hash=file.chd_sha1_hash,
|
||||
)
|
||||
for file in fs_rom["files"]
|
||||
]
|
||||
for new_rom_file in new_rom_files:
|
||||
db_rom_handler.add_rom_file(new_rom_file)
|
||||
saved = db_rom_handler.add_rom_file(new_rom_file)
|
||||
persist_soundtrack_cover(saved, _added_rom)
|
||||
|
||||
# Short circuit if the scan type is hashes
|
||||
if scan_type == ScanType.HASHES:
|
||||
@@ -365,22 +395,23 @@ async def _identify_rom(
|
||||
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
|
||||
entity=_added_rom,
|
||||
overwrite=_added_rom.url_cover != rom.url_cover,
|
||||
url_cover=_added_rom.url_cover,
|
||||
url_cover=add_ss_auth_to_url(_added_rom.url_cover),
|
||||
)
|
||||
|
||||
path_manual = await fs_resource_handler.get_manual(
|
||||
rom=_added_rom,
|
||||
overwrite=_added_rom.url_manual != rom.url_manual,
|
||||
url_manual=_added_rom.url_manual,
|
||||
url_manual=add_ss_auth_to_url(_added_rom.url_manual),
|
||||
)
|
||||
|
||||
screenshots_changed = pydash.xor(
|
||||
_added_rom.url_screenshots or [], rom.url_screenshots or []
|
||||
)
|
||||
url_screenshots = _added_rom.url_screenshots or []
|
||||
path_screenshots = await fs_resource_handler.get_rom_screenshots(
|
||||
rom=_added_rom,
|
||||
overwrite=bool(screenshots_changed),
|
||||
url_screenshots=_added_rom.url_screenshots,
|
||||
url_screenshots=[add_ss_auth_to_url(u) for u in url_screenshots],
|
||||
)
|
||||
|
||||
_added_rom.path_cover_s = path_cover_s
|
||||
@@ -403,10 +434,12 @@ async def _identify_rom(
|
||||
if _added_rom.ss_metadata and MetadataSource.SS in metadata_sources:
|
||||
preferred_media_types = get_preferred_media_types()
|
||||
for media_type in preferred_media_types:
|
||||
if _added_rom.ss_metadata.get(f"{media_type.value}_path"):
|
||||
media_path = _added_rom.ss_metadata.get(f"{media_type.value}_path")
|
||||
media_url = _added_rom.ss_metadata.get(f"{media_type.value}_url")
|
||||
if media_path and media_url:
|
||||
await fs_resource_handler.store_media_file(
|
||||
_added_rom.ss_metadata[f"{media_type.value}_url"],
|
||||
_added_rom.ss_metadata[f"{media_type.value}_path"],
|
||||
add_ss_auth_to_url(media_url),
|
||||
media_path,
|
||||
)
|
||||
|
||||
# Handle special media files from ES-DE gamelist.xml
|
||||
@@ -419,6 +452,16 @@ async def _identify_rom(
|
||||
_added_rom.gamelist_metadata[f"{media_type.value}_path"],
|
||||
)
|
||||
|
||||
# Handle special media files from LaunchBox
|
||||
if _added_rom.launchbox_metadata and MetadataSource.LAUNCHBOX in metadata_sources:
|
||||
preferred_media_types = get_preferred_media_types()
|
||||
for media_type in preferred_media_types:
|
||||
if _added_rom.launchbox_metadata.get(f"{media_type.value}_path"):
|
||||
await fs_resource_handler.store_media_file(
|
||||
_added_rom.launchbox_metadata[f"{media_type.value}_url"],
|
||||
_added_rom.launchbox_metadata[f"{media_type.value}_path"],
|
||||
)
|
||||
|
||||
# Store normal and locked badges
|
||||
if _added_rom.ra_metadata and MetadataSource.RA in metadata_sources:
|
||||
for ach in _added_rom.ra_metadata.get("achievements", []):
|
||||
@@ -436,7 +479,14 @@ async def _identify_rom(
|
||||
await socket_manager.emit(
|
||||
"scan:scanning_rom",
|
||||
SimpleRomSchema.from_orm_with_factory(_added_rom).model_dump(
|
||||
exclude={"created_at", "updated_at", "rom_user", "last_modified", "files"}
|
||||
exclude={
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"rom_user",
|
||||
"last_modified",
|
||||
"files",
|
||||
"sibling_roms",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -448,9 +498,9 @@ async def _identify_platform(
|
||||
roms_ids: list[int],
|
||||
metadata_sources: list[str],
|
||||
launchbox_remote_enabled: bool,
|
||||
playmatch_enabled: bool,
|
||||
socket_manager: socketio.AsyncRedisManager,
|
||||
scan_stats: ScanStats,
|
||||
calculate_hashes: bool = True,
|
||||
) -> ScanStats:
|
||||
# Stop the scan if the flag is set
|
||||
if redis_client.get(STOP_SCAN_FLAG):
|
||||
@@ -546,9 +596,9 @@ async def _identify_platform(
|
||||
roms_ids=roms_ids,
|
||||
metadata_sources=metadata_sources,
|
||||
launchbox_remote_enabled=launchbox_remote_enabled,
|
||||
playmatch_enabled=playmatch_enabled,
|
||||
socket_manager=socket_manager,
|
||||
scan_stats=scan_stats,
|
||||
calculate_hashes=calculate_hashes,
|
||||
)
|
||||
|
||||
for fs_roms_batch in batched(fs_roms, 200, strict=False):
|
||||
@@ -619,6 +669,7 @@ async def scan_platforms(
|
||||
scan_type: ScanType = ScanType.QUICK,
|
||||
roms_ids: list[int] | None = None,
|
||||
launchbox_remote_enabled: bool = True,
|
||||
playmatch_enabled: bool = True,
|
||||
) -> ScanStats:
|
||||
"""Scan all the listed platforms and fetch metadata from different sources
|
||||
|
||||
@@ -628,10 +679,6 @@ async def scan_platforms(
|
||||
scan_type (ScanType): Type of scan to be performed.
|
||||
roms_ids (list[int], optional): List of selected roms to be scanned.
|
||||
"""
|
||||
|
||||
# Get hash calculation setting from config
|
||||
calculate_hashes = not cm.get_config().SKIP_HASH_CALCULATION
|
||||
|
||||
if not roms_ids:
|
||||
roms_ids = []
|
||||
|
||||
@@ -698,9 +745,9 @@ async def scan_platforms(
|
||||
roms_ids=roms_ids,
|
||||
metadata_sources=metadata_sources,
|
||||
launchbox_remote_enabled=launchbox_remote_enabled,
|
||||
playmatch_enabled=playmatch_enabled,
|
||||
socket_manager=socket_manager,
|
||||
scan_stats=scan_stats,
|
||||
calculate_hashes=calculate_hashes,
|
||||
)
|
||||
|
||||
missed_platforms = db_platform_handler.mark_missing_platforms(fs_platforms)
|
||||
@@ -711,6 +758,9 @@ async def scan_platforms(
|
||||
|
||||
log.info(f"{emoji.EMOJI_CHECK_MARK} Scan completed")
|
||||
|
||||
# The library changed; drop cached filter values.
|
||||
db_rom_handler.invalidate_filter_values_cache()
|
||||
|
||||
# Export metadata files if enabled in config
|
||||
config = cm.get_config()
|
||||
platforms_by_slug = {p.fs_slug: p for p in db_platform_handler.get_platforms()}
|
||||
@@ -783,6 +833,7 @@ async def scan_handler(_sid: str, options: dict[str, Any]):
|
||||
roms_ids = options.get("roms_ids", [])
|
||||
metadata_sources = options.get("apis", [])
|
||||
launchbox_remote_enabled = bool(options.get("launchbox_remote_enabled", True))
|
||||
playmatch_enabled = bool(options.get("playmatch_enabled", True))
|
||||
|
||||
if DEV_MODE:
|
||||
return await scan_platforms(
|
||||
@@ -791,6 +842,7 @@ async def scan_handler(_sid: str, options: dict[str, Any]):
|
||||
scan_type=scan_type,
|
||||
roms_ids=roms_ids,
|
||||
launchbox_remote_enabled=launchbox_remote_enabled,
|
||||
playmatch_enabled=playmatch_enabled,
|
||||
)
|
||||
|
||||
return high_prio_queue.enqueue(
|
||||
@@ -800,6 +852,7 @@ async def scan_handler(_sid: str, options: dict[str, Any]):
|
||||
scan_type=scan_type,
|
||||
roms_ids=roms_ids,
|
||||
launchbox_remote_enabled=launchbox_remote_enabled,
|
||||
playmatch_enabled=playmatch_enabled,
|
||||
job_timeout=SCAN_TIMEOUT, # Timeout (default of 4 hours)
|
||||
result_ttl=TASK_RESULT_TTL,
|
||||
meta={
|
||||
|
||||
@@ -285,6 +285,29 @@ async def update_state(
|
||||
return StateSchema.model_validate(db_state)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.put,
|
||||
"/{id}/visibility",
|
||||
[Scope.ASSETS_WRITE],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
def update_state_visibility(
|
||||
request: Request,
|
||||
id: int,
|
||||
is_public: Annotated[bool, Body(embed=True)],
|
||||
) -> StateSchema:
|
||||
"""Toggle a state's public/private visibility (owner only)."""
|
||||
state = db_state_handler.get_state_by_id(id)
|
||||
if not state or state.user_id != request.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"State with ID {id} not found",
|
||||
)
|
||||
|
||||
updated = db_state_handler.update_state(id, {"is_public": is_public})
|
||||
return StateSchema.model_validate(updated)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/delete",
|
||||
|
||||
+26
-12
@@ -50,7 +50,7 @@ class ClientSaveState(BaseModel):
|
||||
|
||||
|
||||
class SyncNegotiatePayload(BaseModel):
|
||||
device_id: str
|
||||
device_id: str | None = None
|
||||
saves: list[ClientSaveState]
|
||||
|
||||
|
||||
@@ -86,13 +86,23 @@ def negotiate_sync(
|
||||
The client sends its current save state, and the server returns a list of
|
||||
operations (upload, download, conflict, no_op) to bring both sides in sync.
|
||||
"""
|
||||
device = db_device_handler.get_device(
|
||||
device_id=payload.device_id, user_id=request.user.id
|
||||
device_id: str | None = payload.device_id or getattr(
|
||||
request.state, "device_id", None
|
||||
)
|
||||
if not device_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
"device_id is required (either in the request payload or "
|
||||
"implicit via a device-bound client token)"
|
||||
),
|
||||
)
|
||||
|
||||
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
|
||||
if not device:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Device with ID {payload.device_id} not found",
|
||||
detail=f"Device with ID {device_id} not found",
|
||||
)
|
||||
|
||||
if not device.sync_enabled:
|
||||
@@ -115,12 +125,16 @@ def negotiate_sync(
|
||||
|
||||
operations: list[SyncOperationSchema] = []
|
||||
|
||||
# Build a set of server saves for this user, keyed by (rom_id, file_name)
|
||||
# We'll also track which server saves were mentioned by the client
|
||||
server_saves = db_save_handler.get_saves(user_id=request.user.id)
|
||||
server_save_map: dict[tuple[int, str], Save] = {}
|
||||
# Pair on (rom_id, slot), keeping the newest row per slot: slot uploads are datetime-tagged (spec) so tagged filenames never equal the client's untagged name, and a slot accrues many rows over time. Null-slot rows stay archival-only.
|
||||
server_saves = db_save_handler.get_saves(
|
||||
user_id=request.user.id, slot_not_null=True
|
||||
)
|
||||
server_save_map: dict[tuple[int, str | None], Save] = {}
|
||||
for save in server_saves:
|
||||
server_save_map[(save.rom_id, save.file_name)] = save
|
||||
key = (save.rom_id, save.slot)
|
||||
current = server_save_map.get(key)
|
||||
if current is None or to_utc(save.updated_at) > to_utc(current.updated_at):
|
||||
server_save_map[key] = save
|
||||
|
||||
# Get all sync records for this device
|
||||
all_save_ids = [s.id for s in server_saves]
|
||||
@@ -134,7 +148,7 @@ def negotiate_sync(
|
||||
|
||||
# Process each client save
|
||||
for client_save in payload.saves:
|
||||
key = (client_save.rom_id, client_save.file_name)
|
||||
key = (client_save.rom_id, client_save.slot)
|
||||
server_save = server_save_map.get(key)
|
||||
|
||||
if server_save is None:
|
||||
@@ -192,8 +206,8 @@ def negotiate_sync(
|
||||
)
|
||||
)
|
||||
|
||||
# Check for server saves the client didn't mention
|
||||
for save in server_saves:
|
||||
# Check for current saves the client didn't mention (superseded older rows per slot are history, not downloads)
|
||||
for save in server_save_map.values():
|
||||
if save.id in matched_server_save_ids:
|
||||
continue
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ from handler.redis_handler import (
|
||||
)
|
||||
from tasks.manual.cleanup_missing_roms import cleanup_missing_roms_task
|
||||
from tasks.manual.cleanup_orphaned_resources import cleanup_orphaned_resources_task
|
||||
from tasks.manual.recompute_save_content_hashes import (
|
||||
recompute_save_content_hashes_task,
|
||||
)
|
||||
from tasks.manual.sync_folder_scan import sync_folder_scan_task
|
||||
from tasks.scheduled.convert_images_to_webp import convert_images_to_webp_task
|
||||
from tasks.scheduled.scan_library import scan_library_task
|
||||
@@ -116,6 +119,13 @@ manual_tasks: list[ManualTask] = [
|
||||
"task": sync_folder_scan_task,
|
||||
}
|
||||
),
|
||||
ManualTask(
|
||||
{
|
||||
"name": "recompute_save_content_hashes",
|
||||
"type": TaskType.CLEANUP,
|
||||
"task": recompute_save_content_hashes_task,
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -147,7 +157,7 @@ def _build_task_status_response(
|
||||
|
||||
common_data = {
|
||||
"task_name": task_name,
|
||||
"task_id": job.get_id(),
|
||||
"task_id": job.id,
|
||||
"status": job.get_status(),
|
||||
"created_at": created_at,
|
||||
"enqueued_at": enqueued_at,
|
||||
@@ -386,7 +396,7 @@ async def run_single_task(
|
||||
|
||||
return {
|
||||
"task_name": task_instance.title,
|
||||
"task_id": job.get_id(),
|
||||
"task_id": job.id,
|
||||
"status": job.get_status() or JobStatus.QUEUED,
|
||||
"created_at": (
|
||||
job.created_at.isoformat()
|
||||
|
||||
@@ -12,6 +12,7 @@ from handler.auth import auth_handler
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_user_handler
|
||||
from handler.filesystem import fs_asset_handler
|
||||
from handler.filesystem.assets_handler import validate_image_upload
|
||||
from handler.metadata import meta_ra_handler
|
||||
from handler.metadata.ra_handler import RAUserProgression
|
||||
from logger.logger import log
|
||||
@@ -387,9 +388,10 @@ async def update_user(
|
||||
) from exc
|
||||
|
||||
if form_data.avatar is not None and form_data.avatar.filename is not None:
|
||||
safe_extension = validate_image_upload(form_data.avatar, label="Avatar")
|
||||
|
||||
user_avatar_path = fs_asset_handler.build_avatar_path(user=db_user)
|
||||
file_extension = form_data.avatar.filename.split(".")[-1]
|
||||
file_name = f"avatar.{file_extension}"
|
||||
file_name = f"avatar.{safe_extension}"
|
||||
|
||||
await fs_asset_handler.write_file(
|
||||
file=form_data.avatar.file, path=user_avatar_path, filename=file_name
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Real-time user game activity tracking.
|
||||
|
||||
Stores ephemeral "currently playing" state in Redis. Each active session is a
|
||||
Redis key with a short TTL, refreshed by periodic heartbeats from the client
|
||||
(browser) or the device. When the TTL expires (no heartbeat received), the
|
||||
session is considered ended automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TypedDict
|
||||
|
||||
from handler.redis_handler import async_cache
|
||||
from logger.logger import log
|
||||
|
||||
|
||||
class ActivityEntry(TypedDict):
|
||||
user_id: int
|
||||
username: str
|
||||
avatar_path: str
|
||||
rom_id: int
|
||||
rom_name: str
|
||||
rom_cover_path: str # small cover path, may be empty
|
||||
screenshot_path: str # "where they are" image (save/title/screenshot), may be empty
|
||||
platform_slug: str
|
||||
platform_name: str
|
||||
device_id: str
|
||||
device_type: str # "web", "grout", "argosy-launcher", etc.
|
||||
started_at: str # ISO 8601 timestamp
|
||||
|
||||
|
||||
class ActivityHandler:
|
||||
"""Redis-backed store for currently active game play sessions."""
|
||||
|
||||
ACTIVITY_TTL = 90 # seconds; refreshed by heartbeats
|
||||
ROM_INDEX_TTL = 120 # slightly longer than ACTIVITY_TTL
|
||||
KEY_PREFIX = "activity:user:"
|
||||
ROM_INDEX_PREFIX = "activity:rom:"
|
||||
|
||||
def _activity_key(self, user_id: int, device_id: str) -> str:
|
||||
return f"{self.KEY_PREFIX}{user_id}:{device_id}"
|
||||
|
||||
def _rom_index_key(self, rom_id: int) -> str:
|
||||
return f"{self.ROM_INDEX_PREFIX}{rom_id}"
|
||||
|
||||
def _member(self, user_id: int, device_id: str) -> str:
|
||||
return f"{user_id}:{device_id}"
|
||||
|
||||
async def set_active(self, entry: ActivityEntry) -> None:
|
||||
"""Store or refresh a user's active play session."""
|
||||
key = self._activity_key(entry["user_id"], entry["device_id"])
|
||||
rom_key = self._rom_index_key(entry["rom_id"])
|
||||
member = self._member(entry["user_id"], entry["device_id"])
|
||||
|
||||
async with async_cache.pipeline() as pipe:
|
||||
await pipe.set(key, json.dumps(entry), ex=self.ACTIVITY_TTL)
|
||||
await pipe.sadd(rom_key, member)
|
||||
await pipe.expire(rom_key, self.ROM_INDEX_TTL)
|
||||
await pipe.execute()
|
||||
|
||||
async def clear_active(self, user_id: int, device_id: str) -> int | None:
|
||||
"""Clear a user's active play session. Returns the rom_id that was cleared, or None."""
|
||||
key = self._activity_key(user_id, device_id)
|
||||
raw = await async_cache.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
rom_id = int(entry["rom_id"])
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
log.warning(f"Failed to parse activity entry for cleanup: {e}")
|
||||
await async_cache.delete(key)
|
||||
return None
|
||||
|
||||
member = self._member(user_id, device_id)
|
||||
async with async_cache.pipeline() as pipe:
|
||||
await pipe.delete(key)
|
||||
await pipe.srem(self._rom_index_key(rom_id), member)
|
||||
await pipe.execute()
|
||||
return rom_id
|
||||
|
||||
async def get_active(self, user_id: int, device_id: str) -> ActivityEntry | None:
|
||||
"""Get a single active session by user and device."""
|
||||
key = self._activity_key(user_id, device_id)
|
||||
raw = await async_cache.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
async def get_all_active(self) -> list[ActivityEntry]:
|
||||
"""Get all currently active play sessions across all users."""
|
||||
pattern = f"{self.KEY_PREFIX}*"
|
||||
keys = [key async for key in async_cache.scan_iter(match=pattern)]
|
||||
if not keys:
|
||||
return []
|
||||
|
||||
# Single round-trip for every value instead of a GET per key.
|
||||
entries: list[ActivityEntry] = []
|
||||
for raw in await async_cache.mget(keys):
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(raw))
|
||||
except ValueError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
async def get_active_for_rom(self, rom_id: int) -> list[ActivityEntry]:
|
||||
"""Get all active play sessions for a specific ROM."""
|
||||
rom_key = self._rom_index_key(rom_id)
|
||||
members = await async_cache.smembers(rom_key)
|
||||
entries: list[ActivityEntry] = []
|
||||
stale_members: list[str] = []
|
||||
|
||||
for member in members:
|
||||
try:
|
||||
user_id_str, device_id = member.rsplit(":", 1)
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, AttributeError):
|
||||
stale_members.append(member)
|
||||
continue
|
||||
|
||||
raw = await async_cache.get(self._activity_key(user_id, device_id))
|
||||
if not raw:
|
||||
# Key expired; clean up the stale set member.
|
||||
stale_members.append(member)
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(raw))
|
||||
except ValueError:
|
||||
stale_members.append(member)
|
||||
|
||||
if stale_members:
|
||||
await async_cache.srem(rom_key, *stale_members)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
activity_handler = ActivityHandler()
|
||||
@@ -13,6 +13,7 @@ from starlette.requests import HTTPConnection
|
||||
|
||||
from config import (
|
||||
INVITE_TOKEN_EXPIRY_SECONDS,
|
||||
OIDC_ALLOW_REGISTRATION,
|
||||
OIDC_CLAIM_ROLES,
|
||||
OIDC_ENABLED,
|
||||
OIDC_ROLE_ADMIN,
|
||||
@@ -436,6 +437,15 @@ class OpenIDHandler:
|
||||
|
||||
user = db_user_handler.get_user_by_email(email)
|
||||
if user is None:
|
||||
if not OIDC_ALLOW_REGISTRATION:
|
||||
log.error(
|
||||
"User with email '%s' not found and OIDC registration is disabled",
|
||||
hl(email, color=CYAN),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User registration is disabled. Please contact an administrator to create an account.",
|
||||
)
|
||||
log.info(
|
||||
"User with email '%s' not found, creating new user",
|
||||
hl(email, color=CYAN),
|
||||
|
||||
@@ -4,6 +4,9 @@ from typing import Final
|
||||
|
||||
ALGORITHM: Final = "HS256"
|
||||
DEFAULT_OAUTH_TOKEN_EXPIRY: Final = timedelta(minutes=15)
|
||||
# Name of the session cookie set by RedisSessionMiddleware (see main.py). Shared
|
||||
# so the socket log-stream handshake reads the same cookie the middleware writes.
|
||||
SESSION_COOKIE_NAME: Final = "romm_session"
|
||||
|
||||
|
||||
class Scope(enum.StrEnum):
|
||||
@@ -26,6 +29,7 @@ class Scope(enum.StrEnum):
|
||||
USERS_READ = "users.read"
|
||||
USERS_WRITE = "users.write"
|
||||
TASKS_RUN = "tasks.run"
|
||||
LOGS_READ = "logs.read"
|
||||
|
||||
|
||||
READ_SCOPES_MAP: Final = {
|
||||
@@ -57,6 +61,7 @@ FULL_SCOPES_MAP: Final = {
|
||||
Scope.USERS_READ: "View users",
|
||||
Scope.USERS_WRITE: "Modify users",
|
||||
Scope.TASKS_RUN: "Run tasks",
|
||||
Scope.LOGS_READ: "View backend logs",
|
||||
}
|
||||
|
||||
READ_SCOPES: Final = list(READ_SCOPES_MAP.keys())
|
||||
|
||||
@@ -6,7 +6,11 @@ from starlette.requests import HTTPConnection
|
||||
|
||||
from config import KIOSK_MODE
|
||||
from handler.auth import auth_handler, oauth_handler
|
||||
from handler.database import db_client_token_handler, db_user_handler
|
||||
from handler.database import (
|
||||
db_client_token_handler,
|
||||
db_device_handler,
|
||||
db_user_handler,
|
||||
)
|
||||
from models.user import User
|
||||
from utils.datetime import to_utc
|
||||
|
||||
@@ -25,7 +29,11 @@ class HybridAuthBackend(AuthenticationBackend):
|
||||
|
||||
# Check if Authorization header exists
|
||||
if "Authorization" in conn.headers:
|
||||
scheme, token = conn.headers["Authorization"].split()
|
||||
auth_header_parts = conn.headers["Authorization"].split()
|
||||
if len(auth_header_parts) != 2:
|
||||
return None
|
||||
|
||||
scheme, token = auth_header_parts
|
||||
|
||||
# Check if basic auth header is valid
|
||||
if scheme.lower() == "basic":
|
||||
@@ -66,6 +74,11 @@ class HybridAuthBackend(AuthenticationBackend):
|
||||
db_client_token_handler.update_last_used(client_token.id)
|
||||
user.set_last_active()
|
||||
conn.state.client_token_id = client_token.id
|
||||
conn.state.device_id = client_token.device_id
|
||||
if client_token.device_id:
|
||||
db_device_handler.update_last_seen_debounced(
|
||||
client_token.device_id
|
||||
)
|
||||
return (AuthCredentials(effective_scopes), user)
|
||||
|
||||
# OAuth JWT bearer tokens
|
||||
|
||||
@@ -90,18 +90,21 @@ class CSRFMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
async def send(self, message: Message, send: Send, scope: Scope) -> None:
|
||||
if message["type"] != "http.response.start":
|
||||
await send(message)
|
||||
return
|
||||
|
||||
request = Request(scope)
|
||||
csrf_cookie = request.cookies.get(self.cookie_name)
|
||||
current_user_id = request.user.id if request.user.is_authenticated else None
|
||||
|
||||
if csrf_cookie is None:
|
||||
if not self._csrf_cookie_has_user(csrf_cookie, current_user_id):
|
||||
message.setdefault("headers", [])
|
||||
headers = MutableHeaders(scope=message)
|
||||
|
||||
cookie: http.cookies.BaseCookie = http.cookies.SimpleCookie()
|
||||
cookie_name = self.cookie_name
|
||||
cookie[cookie_name] = self._generate_csrf_token(
|
||||
request.user.id if request.user.is_authenticated else None
|
||||
)
|
||||
cookie[cookie_name] = self._generate_csrf_token(current_user_id)
|
||||
cookie[cookie_name]["path"] = self.cookie_path
|
||||
cookie[cookie_name]["secure"] = self.cookie_secure
|
||||
cookie[cookie_name]["httponly"] = self.cookie_httponly
|
||||
@@ -143,6 +146,19 @@ class CSRFMiddleware:
|
||||
obj = {"token": secrets.token_urlsafe(128), "user_id": user_id}
|
||||
return cast(str, self.serializer.dumps(obj))
|
||||
|
||||
def _csrf_cookie_has_user(
|
||||
self, csrf_cookie: str | None, user_id: int | None
|
||||
) -> bool:
|
||||
if csrf_cookie is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
decoded_csrf_cookie = self.serializer.loads(csrf_cookie)
|
||||
except (TypeError, BadSignature):
|
||||
return False
|
||||
|
||||
return decoded_csrf_cookie.get("user_id") == user_id
|
||||
|
||||
def _csrf_tokens_match(
|
||||
self, document_cookie: str, header_cookie: str, user_id: int | None
|
||||
) -> bool:
|
||||
|
||||
@@ -1,342 +1,412 @@
|
||||
import functools
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, insert, literal, or_, select, update
|
||||
from sqlalchemy.orm import (
|
||||
Query,
|
||||
QueryableAttribute,
|
||||
Session,
|
||||
load_only,
|
||||
noload,
|
||||
selectinload,
|
||||
)
|
||||
|
||||
from decorators.database import begin_session
|
||||
from models.collection import (
|
||||
Collection,
|
||||
CollectionRom,
|
||||
SmartCollection,
|
||||
VirtualCollection,
|
||||
)
|
||||
from models.rom import Rom
|
||||
|
||||
from .base_handler import DBBaseHandler
|
||||
|
||||
|
||||
def with_roms(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
kwargs["query"] = select(Collection).options(
|
||||
selectinload(Collection.roms)
|
||||
.load_only(
|
||||
Rom.id,
|
||||
Rom.path_cover_s,
|
||||
Rom.path_cover_l,
|
||||
)
|
||||
.options(noload(Rom.platform), noload(Rom.metadatum))
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class DBCollectionsHandler(DBBaseHandler):
|
||||
@begin_session
|
||||
@with_roms
|
||||
def add_collection(
|
||||
self,
|
||||
collection: Collection,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection:
|
||||
collection = session.merge(collection)
|
||||
session.flush()
|
||||
|
||||
return session.scalar(query.filter_by(id=collection.id).limit(1))
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def get_collection(
|
||||
self,
|
||||
id: int,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection | None:
|
||||
return session.scalar(query.filter_by(id=id).limit(1))
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def get_collection_by_name(
|
||||
self,
|
||||
name: str,
|
||||
user_id: int,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection | None:
|
||||
return session.scalar(query.filter_by(name=name, user_id=user_id).limit(1))
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def get_favorite_collection(
|
||||
self,
|
||||
user_id: int,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection | None:
|
||||
return session.scalar(
|
||||
query.filter_by(is_favorite=True, user_id=user_id).limit(1)
|
||||
)
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def get_collections(
|
||||
self,
|
||||
updated_after: datetime | None = None,
|
||||
only_fields: Sequence[QueryableAttribute] | None = None,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[Collection]:
|
||||
if updated_after:
|
||||
query = query.filter(Collection.updated_at > updated_after)
|
||||
|
||||
if only_fields:
|
||||
query = query.options(load_only(*only_fields))
|
||||
|
||||
return session.scalars(query.order_by(Collection.name.asc())).unique().all()
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def update_collection(
|
||||
self,
|
||||
id: int,
|
||||
data: dict,
|
||||
rom_ids: list[int] | None = None,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection:
|
||||
session.execute(
|
||||
update(Collection)
|
||||
.where(Collection.id == id)
|
||||
.values(**data)
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
if rom_ids is not None:
|
||||
# Delete all existing CollectionRom entries for this collection
|
||||
session.execute(
|
||||
delete(CollectionRom).where(CollectionRom.collection_id == id)
|
||||
)
|
||||
# Insert new CollectionRom entries for this collection
|
||||
if rom_ids:
|
||||
# Filter out rom_ids that no longer exist in the roms table to
|
||||
# avoid foreign key constraint violations (e.g. after a rescan)
|
||||
valid_rom_ids = set(
|
||||
session.scalars(select(Rom.id).where(Rom.id.in_(rom_ids))).all()
|
||||
)
|
||||
if valid_rom_ids:
|
||||
session.execute(
|
||||
insert(CollectionRom),
|
||||
[
|
||||
{"collection_id": id, "rom_id": rom_id}
|
||||
for rom_id in valid_rom_ids
|
||||
],
|
||||
)
|
||||
|
||||
return session.scalar(query.filter_by(id=id).limit(1))
|
||||
|
||||
@begin_session
|
||||
def delete_collection(
|
||||
self,
|
||||
id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> None:
|
||||
session.execute(
|
||||
delete(Collection)
|
||||
.where(Collection.id == id)
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
# Virtual collections
|
||||
@begin_session
|
||||
def get_virtual_collection(
|
||||
self,
|
||||
id: str,
|
||||
session: Session = None, # type: ignore
|
||||
) -> VirtualCollection | None:
|
||||
name, type = VirtualCollection.from_id(id)
|
||||
return session.scalar(
|
||||
select(VirtualCollection).filter_by(name=name, type=type).limit(1)
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def get_virtual_collections(
|
||||
self,
|
||||
type: str,
|
||||
limit: int | None = None,
|
||||
only_fields: Sequence[QueryableAttribute] | None = None,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[VirtualCollection]:
|
||||
query = (
|
||||
select(VirtualCollection)
|
||||
.filter(or_(VirtualCollection.type == type, literal(type == "all")))
|
||||
.limit(limit)
|
||||
.order_by(VirtualCollection.name.asc())
|
||||
)
|
||||
|
||||
if only_fields:
|
||||
query = query.options(load_only(*only_fields))
|
||||
|
||||
return session.scalars(query).unique().all()
|
||||
|
||||
# Smart collections
|
||||
@begin_session
|
||||
def add_smart_collection(
|
||||
self,
|
||||
smart_collection: SmartCollection,
|
||||
session: Session = None, # type: ignore
|
||||
) -> SmartCollection:
|
||||
smart_collection = session.merge(smart_collection)
|
||||
session.flush()
|
||||
|
||||
return session.query(SmartCollection).filter_by(id=smart_collection.id).one()
|
||||
|
||||
@begin_session
|
||||
def get_smart_collection(
|
||||
self,
|
||||
id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> SmartCollection | None:
|
||||
return session.scalar(select(SmartCollection).filter_by(id=id).limit(1))
|
||||
|
||||
@begin_session
|
||||
def get_smart_collection_by_name(
|
||||
self,
|
||||
name: str,
|
||||
user_id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> SmartCollection | None:
|
||||
return session.scalar(
|
||||
select(SmartCollection).filter_by(name=name, user_id=user_id).limit(1)
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def get_smart_collections(
|
||||
self,
|
||||
user_id: int | None = None,
|
||||
updated_after: datetime | None = None,
|
||||
only_fields: Sequence[QueryableAttribute] | None = None,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[SmartCollection]:
|
||||
query = select(SmartCollection).order_by(SmartCollection.name.asc())
|
||||
|
||||
if user_id is not None:
|
||||
# Get user's smart collections and public ones
|
||||
query = query.filter(
|
||||
(SmartCollection.user_id == user_id) | SmartCollection.is_public
|
||||
)
|
||||
|
||||
if updated_after:
|
||||
query = query.filter(SmartCollection.updated_at > updated_after)
|
||||
|
||||
if only_fields:
|
||||
query = query.options(load_only(*only_fields))
|
||||
|
||||
return session.scalars(query).unique().all()
|
||||
|
||||
@begin_session
|
||||
def update_smart_collection(
|
||||
self,
|
||||
id: int,
|
||||
data: dict[str, Any],
|
||||
session: Session = None, # type: ignore
|
||||
) -> SmartCollection:
|
||||
session.execute(
|
||||
update(SmartCollection)
|
||||
.where(SmartCollection.id == id)
|
||||
.values(**data)
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
return session.query(SmartCollection).filter_by(id=id).one()
|
||||
|
||||
@begin_session
|
||||
def delete_smart_collection(
|
||||
self,
|
||||
id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> None:
|
||||
session.execute(
|
||||
delete(SmartCollection)
|
||||
.where(SmartCollection.id == id)
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
def get_smart_collection_roms(
|
||||
self, smart_collection: SmartCollection, user_id: int | None = None
|
||||
) -> Sequence["Rom"]:
|
||||
"""Get ROMs that match the smart collection's filter criteria."""
|
||||
from handler.database import db_rom_handler
|
||||
|
||||
# Extract filter criteria
|
||||
criteria = smart_collection.filter_criteria
|
||||
|
||||
# Convert legacy single-value criteria to arrays for backward compatibility
|
||||
def convert_legacy_filter(new_key: str, old_key: str) -> list[str] | None:
|
||||
"""Convert legacy single-value filter to array format."""
|
||||
if new_value := criteria.get(new_key):
|
||||
return new_value if isinstance(new_value, list) else [new_value]
|
||||
if old_value := criteria.get(old_key):
|
||||
return [old_value]
|
||||
return None
|
||||
|
||||
# Apply conversions
|
||||
genres = convert_legacy_filter("genres", "selected_genre")
|
||||
franchises = convert_legacy_filter("franchises", "selected_franchise")
|
||||
collections = convert_legacy_filter("collections", "selected_collection")
|
||||
companies = convert_legacy_filter("companies", "selected_company")
|
||||
age_ratings = convert_legacy_filter("age_ratings", "selected_age_rating")
|
||||
regions = convert_legacy_filter("regions", "selected_region")
|
||||
languages = convert_legacy_filter("languages", "selected_language")
|
||||
statuses = convert_legacy_filter("statuses", "selected_status")
|
||||
|
||||
# Use the existing filter_roms method with the stored criteria
|
||||
platform_ids = criteria.get("platform_ids")
|
||||
if platform_ids is None:
|
||||
if platform_id := criteria.get("platform_id"):
|
||||
platform_ids = [platform_id]
|
||||
|
||||
return db_rom_handler.get_roms_scalar(
|
||||
platform_ids=platform_ids,
|
||||
collection_id=criteria.get("collection_id"),
|
||||
virtual_collection_id=criteria.get("virtual_collection_id"),
|
||||
search_term=criteria.get("search_term"),
|
||||
matched=criteria.get("matched"),
|
||||
favorite=criteria.get("favorite"),
|
||||
duplicate=criteria.get("duplicate"),
|
||||
playable=criteria.get("playable"),
|
||||
has_ra=criteria.get("has_ra"),
|
||||
missing=criteria.get("missing"),
|
||||
verified=criteria.get("verified"),
|
||||
genres=genres,
|
||||
franchises=franchises,
|
||||
collections=collections,
|
||||
companies=companies,
|
||||
age_ratings=age_ratings,
|
||||
statuses=statuses,
|
||||
regions=regions,
|
||||
languages=languages,
|
||||
# Logic operators for multi-value filters
|
||||
genres_logic=criteria.get("genres_logic", "any"),
|
||||
franchises_logic=criteria.get("franchises_logic", "any"),
|
||||
collections_logic=criteria.get("collections_logic", "any"),
|
||||
companies_logic=criteria.get("companies_logic", "any"),
|
||||
age_ratings_logic=criteria.get("age_ratings_logic", "any"),
|
||||
regions_logic=criteria.get("regions_logic", "any"),
|
||||
languages_logic=criteria.get("languages_logic", "any"),
|
||||
statuses_logic=criteria.get("statuses_logic", "any"),
|
||||
user_id=user_id,
|
||||
order_by=criteria.get("order_by", "name"),
|
||||
order_dir=criteria.get("order_dir", "asc"),
|
||||
)
|
||||
import functools
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, insert, literal, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import (
|
||||
Query,
|
||||
QueryableAttribute,
|
||||
Session,
|
||||
load_only,
|
||||
noload,
|
||||
selectinload,
|
||||
)
|
||||
|
||||
from decorators.database import begin_session
|
||||
from models.collection import (
|
||||
Collection,
|
||||
CollectionRom,
|
||||
SmartCollection,
|
||||
VirtualCollection,
|
||||
)
|
||||
from models.rom import Rom
|
||||
|
||||
from .base_handler import DBBaseHandler
|
||||
|
||||
|
||||
def with_roms(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
kwargs["query"] = select(Collection).options(
|
||||
selectinload(Collection.roms)
|
||||
.load_only(
|
||||
Rom.id,
|
||||
Rom.path_cover_s,
|
||||
Rom.path_cover_l,
|
||||
)
|
||||
.options(noload(Rom.platform), noload(Rom.metadatum))
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class DBCollectionsHandler(DBBaseHandler):
|
||||
@begin_session
|
||||
@with_roms
|
||||
def add_collection(
|
||||
self,
|
||||
collection: Collection,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection:
|
||||
collection = session.merge(collection)
|
||||
session.flush()
|
||||
|
||||
return session.scalar(query.filter_by(id=collection.id).limit(1))
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def get_collection(
|
||||
self,
|
||||
id: int,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection | None:
|
||||
return session.scalar(query.filter_by(id=id).limit(1))
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def get_collection_by_name(
|
||||
self,
|
||||
name: str,
|
||||
user_id: int,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection | None:
|
||||
return session.scalar(query.filter_by(name=name, user_id=user_id).limit(1))
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def get_favorite_collection(
|
||||
self,
|
||||
user_id: int,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection | None:
|
||||
return session.scalar(
|
||||
query.filter_by(is_favorite=True, user_id=user_id).limit(1)
|
||||
)
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def get_collections(
|
||||
self,
|
||||
updated_after: datetime | None = None,
|
||||
only_fields: Sequence[QueryableAttribute] | None = None,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[Collection]:
|
||||
if updated_after:
|
||||
query = query.filter(Collection.updated_at > updated_after)
|
||||
|
||||
if only_fields:
|
||||
query = query.options(load_only(*only_fields))
|
||||
|
||||
return session.scalars(query.order_by(Collection.name.asc())).unique().all()
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def update_collection(
|
||||
self,
|
||||
id: int,
|
||||
data: dict,
|
||||
rom_ids: list[int] | None = None,
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection:
|
||||
session.execute(
|
||||
update(Collection)
|
||||
.where(Collection.id == id)
|
||||
.values(**data)
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
if rom_ids is not None:
|
||||
# Delete all existing CollectionRom entries for this collection
|
||||
session.execute(
|
||||
delete(CollectionRom).where(CollectionRom.collection_id == id)
|
||||
)
|
||||
# Insert new CollectionRom entries for this collection
|
||||
if rom_ids:
|
||||
# Filter out rom_ids that no longer exist in the roms table to
|
||||
# avoid foreign key constraint violations (e.g. after a rescan)
|
||||
valid_rom_ids = set(
|
||||
session.scalars(select(Rom.id).where(Rom.id.in_(rom_ids))).all()
|
||||
)
|
||||
if valid_rom_ids:
|
||||
session.execute(
|
||||
insert(CollectionRom),
|
||||
[
|
||||
{"collection_id": id, "rom_id": rom_id}
|
||||
for rom_id in valid_rom_ids
|
||||
],
|
||||
)
|
||||
|
||||
return session.scalar(query.filter_by(id=id).limit(1))
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def add_roms_to_collection(
|
||||
self,
|
||||
id: int,
|
||||
rom_ids: list[int],
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection:
|
||||
if rom_ids:
|
||||
valid_rom_ids = set(
|
||||
session.scalars(select(Rom.id).where(Rom.id.in_(rom_ids))).all()
|
||||
)
|
||||
existing_ids = set(
|
||||
session.scalars(
|
||||
select(CollectionRom.rom_id).where(
|
||||
CollectionRom.collection_id == id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
new_ids = valid_rom_ids - existing_ids
|
||||
if new_ids:
|
||||
try:
|
||||
with session.begin_nested():
|
||||
session.execute(
|
||||
insert(CollectionRom),
|
||||
[
|
||||
{"collection_id": id, "rom_id": rom_id}
|
||||
for rom_id in new_ids
|
||||
],
|
||||
)
|
||||
except IntegrityError:
|
||||
# Concurrent request inserted the same rows; data is consistent
|
||||
pass
|
||||
session.execute(
|
||||
update(Collection)
|
||||
.where(Collection.id == id)
|
||||
.values(updated_at=datetime.now(timezone.utc))
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
return session.scalar(query.filter_by(id=id).limit(1))
|
||||
|
||||
@begin_session
|
||||
@with_roms
|
||||
def remove_roms_from_collection(
|
||||
self,
|
||||
id: int,
|
||||
rom_ids: list[int],
|
||||
query: Query = None, # type: ignore
|
||||
session: Session = None, # type: ignore
|
||||
) -> Collection:
|
||||
if rom_ids:
|
||||
result = session.execute(
|
||||
delete(CollectionRom).where(
|
||||
CollectionRom.collection_id == id,
|
||||
CollectionRom.rom_id.in_(rom_ids),
|
||||
)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
session.execute(
|
||||
update(Collection)
|
||||
.where(Collection.id == id)
|
||||
.values(updated_at=datetime.now(timezone.utc))
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
return session.scalar(query.filter_by(id=id).limit(1))
|
||||
|
||||
@begin_session
|
||||
def delete_collection(
|
||||
self,
|
||||
id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> None:
|
||||
session.execute(
|
||||
delete(Collection)
|
||||
.where(Collection.id == id)
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
# Virtual collections
|
||||
@begin_session
|
||||
def get_virtual_collection(
|
||||
self,
|
||||
id: str,
|
||||
session: Session = None, # type: ignore
|
||||
) -> VirtualCollection | None:
|
||||
name, type = VirtualCollection.from_id(id)
|
||||
return session.scalar(
|
||||
select(VirtualCollection).filter_by(name=name, type=type).limit(1)
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def get_virtual_collections(
|
||||
self,
|
||||
type: str,
|
||||
limit: int | None = None,
|
||||
only_fields: Sequence[QueryableAttribute] | None = None,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[VirtualCollection]:
|
||||
query = (
|
||||
select(VirtualCollection)
|
||||
.filter(or_(VirtualCollection.type == type, literal(type == "all")))
|
||||
.limit(limit)
|
||||
.order_by(VirtualCollection.name.asc())
|
||||
)
|
||||
|
||||
if only_fields:
|
||||
query = query.options(load_only(*only_fields))
|
||||
|
||||
return session.scalars(query).unique().all()
|
||||
|
||||
# Smart collections
|
||||
@begin_session
|
||||
def add_smart_collection(
|
||||
self,
|
||||
smart_collection: SmartCollection,
|
||||
session: Session = None, # type: ignore
|
||||
) -> SmartCollection:
|
||||
smart_collection = session.merge(smart_collection)
|
||||
session.flush()
|
||||
|
||||
return session.query(SmartCollection).filter_by(id=smart_collection.id).one()
|
||||
|
||||
@begin_session
|
||||
def get_smart_collection(
|
||||
self,
|
||||
id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> SmartCollection | None:
|
||||
return session.scalar(select(SmartCollection).filter_by(id=id).limit(1))
|
||||
|
||||
@begin_session
|
||||
def get_smart_collection_by_name(
|
||||
self,
|
||||
name: str,
|
||||
user_id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> SmartCollection | None:
|
||||
return session.scalar(
|
||||
select(SmartCollection).filter_by(name=name, user_id=user_id).limit(1)
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def get_smart_collections(
|
||||
self,
|
||||
user_id: int | None = None,
|
||||
updated_after: datetime | None = None,
|
||||
only_fields: Sequence[QueryableAttribute] | None = None,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[SmartCollection]:
|
||||
query = select(SmartCollection).order_by(SmartCollection.name.asc())
|
||||
|
||||
if user_id is not None:
|
||||
# Get user's smart collections and public ones
|
||||
query = query.filter(
|
||||
(SmartCollection.user_id == user_id) | SmartCollection.is_public
|
||||
)
|
||||
|
||||
if updated_after:
|
||||
query = query.filter(SmartCollection.updated_at > updated_after)
|
||||
|
||||
if only_fields:
|
||||
query = query.options(load_only(*only_fields))
|
||||
|
||||
return session.scalars(query).unique().all()
|
||||
|
||||
@begin_session
|
||||
def update_smart_collection(
|
||||
self,
|
||||
id: int,
|
||||
data: dict[str, Any],
|
||||
session: Session = None, # type: ignore
|
||||
) -> SmartCollection:
|
||||
session.execute(
|
||||
update(SmartCollection)
|
||||
.where(SmartCollection.id == id)
|
||||
.values(**data)
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
return session.query(SmartCollection).filter_by(id=id).one()
|
||||
|
||||
@begin_session
|
||||
def delete_smart_collection(
|
||||
self,
|
||||
id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> None:
|
||||
session.execute(
|
||||
delete(SmartCollection)
|
||||
.where(SmartCollection.id == id)
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
def get_smart_collection_roms(
|
||||
self, smart_collection: SmartCollection, user_id: int | None = None
|
||||
) -> Sequence["Rom"]:
|
||||
"""Get ROMs that match the smart collection's filter criteria."""
|
||||
from handler.database import db_rom_handler
|
||||
|
||||
# Extract filter criteria
|
||||
criteria = smart_collection.filter_criteria
|
||||
|
||||
# Convert legacy single-value criteria to arrays for backward compatibility
|
||||
def convert_legacy_filter(new_key: str, old_key: str) -> list[str] | None:
|
||||
"""Convert legacy single-value filter to array format."""
|
||||
if new_value := criteria.get(new_key):
|
||||
return new_value if isinstance(new_value, list) else [new_value]
|
||||
if old_value := criteria.get(old_key):
|
||||
return old_value if isinstance(old_value, list) else [old_value]
|
||||
return None
|
||||
|
||||
# Apply conversions
|
||||
genres = convert_legacy_filter("genres", "selected_genre")
|
||||
franchises = convert_legacy_filter("franchises", "selected_franchise")
|
||||
collections = convert_legacy_filter("collections", "selected_collection")
|
||||
companies = convert_legacy_filter("companies", "selected_company")
|
||||
age_ratings = convert_legacy_filter("age_ratings", "selected_age_rating")
|
||||
regions = convert_legacy_filter("regions", "selected_region")
|
||||
languages = convert_legacy_filter("languages", "selected_language")
|
||||
statuses = convert_legacy_filter("statuses", "selected_status")
|
||||
|
||||
# Use the existing filter_roms method with the stored criteria
|
||||
platform_ids = criteria.get("platform_ids")
|
||||
if platform_ids is None:
|
||||
if platform_id := criteria.get("platform_id"):
|
||||
platform_ids = [platform_id]
|
||||
|
||||
return db_rom_handler.get_roms_scalar(
|
||||
platform_ids=platform_ids,
|
||||
collection_id=criteria.get("collection_id"),
|
||||
virtual_collection_id=criteria.get("virtual_collection_id"),
|
||||
search_term=criteria.get("search_term"),
|
||||
matched=criteria.get("matched"),
|
||||
favorite=criteria.get("favorite"),
|
||||
duplicate=criteria.get("duplicate"),
|
||||
playable=criteria.get("playable"),
|
||||
has_ra=criteria.get("has_ra"),
|
||||
missing=criteria.get("missing"),
|
||||
verified=criteria.get("verified"),
|
||||
genres=genres,
|
||||
franchises=franchises,
|
||||
collections=collections,
|
||||
companies=companies,
|
||||
age_ratings=age_ratings,
|
||||
statuses=statuses,
|
||||
regions=regions,
|
||||
languages=languages,
|
||||
# Logic operators for multi-value filters
|
||||
genres_logic=criteria.get("genres_logic", "any"),
|
||||
franchises_logic=criteria.get("franchises_logic", "any"),
|
||||
collections_logic=criteria.get("collections_logic", "any"),
|
||||
companies_logic=criteria.get("companies_logic", "any"),
|
||||
age_ratings_logic=criteria.get("age_ratings_logic", "any"),
|
||||
regions_logic=criteria.get("regions_logic", "any"),
|
||||
languages_logic=criteria.get("languages_logic", "any"),
|
||||
statuses_logic=criteria.get("statuses_logic", "any"),
|
||||
user_id=user_id,
|
||||
order_by=criteria.get("order_by", "name"),
|
||||
order_dir=criteria.get("order_dir", "asc"),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from decorators.database import begin_session
|
||||
from models.device import Device
|
||||
from models.device_save_sync import DeviceSaveSync
|
||||
|
||||
from .base_handler import DBBaseHandler
|
||||
@@ -40,6 +41,31 @@ class DBDeviceSaveSyncHandler(DBBaseHandler):
|
||||
)
|
||||
).all()
|
||||
|
||||
@begin_session
|
||||
def get_syncs_for_saves(
|
||||
self,
|
||||
save_ids: list[int],
|
||||
session: Session = None, # type: ignore
|
||||
) -> dict[int, list[tuple[DeviceSaveSync, str | None]]]:
|
||||
"""Fetch every device sync row for the given saves, grouped by save id.
|
||||
|
||||
Each row is paired with its device name so callers can attribute a save
|
||||
to the device that created it without triggering the lazy-raise
|
||||
``DeviceSaveSync.device`` relationship.
|
||||
"""
|
||||
if not save_ids:
|
||||
return {}
|
||||
rows = session.execute(
|
||||
select(DeviceSaveSync, Device.name)
|
||||
.join(Device, DeviceSaveSync.device_id == Device.id)
|
||||
.filter(DeviceSaveSync.save_id.in_(save_ids))
|
||||
.order_by(DeviceSaveSync.last_synced_at.desc(), DeviceSaveSync.device_id)
|
||||
).all()
|
||||
grouped: dict[int, list[tuple[DeviceSaveSync, str | None]]] = {}
|
||||
for sync, device_name in rows:
|
||||
grouped.setdefault(sync.save_id, []).append((sync, device_name))
|
||||
return grouped
|
||||
|
||||
@begin_session
|
||||
def upsert_sync(
|
||||
self,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from decorators.database import begin_session
|
||||
from models.device import Device, SyncMode
|
||||
from utils.datetime import to_utc
|
||||
|
||||
from .base_handler import DBBaseHandler
|
||||
|
||||
LAST_SEEN_DEBOUNCE = timedelta(minutes=5)
|
||||
|
||||
|
||||
class DBDevicesHandler(DBBaseHandler):
|
||||
@begin_session
|
||||
@@ -74,6 +77,25 @@ class DBDevicesHandler(DBBaseHandler):
|
||||
"""Get a device by ID without user filtering (for server-side operations)."""
|
||||
return session.scalar(select(Device).filter_by(id=device_id).limit(1))
|
||||
|
||||
@begin_session
|
||||
def get_device_by_client_identifier(
|
||||
self,
|
||||
user_id: int,
|
||||
client_device_identifier: str,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Device | None:
|
||||
"""Find a device by its client-supplied stable identifier, scoped to a user."""
|
||||
if not client_device_identifier:
|
||||
return None
|
||||
return session.scalar(
|
||||
select(Device)
|
||||
.filter_by(
|
||||
user_id=user_id,
|
||||
client_device_identifier=client_device_identifier,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def get_devices(
|
||||
self,
|
||||
@@ -123,6 +145,32 @@ class DBDevicesHandler(DBBaseHandler):
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def update_last_seen_debounced(
|
||||
self,
|
||||
device_id: str,
|
||||
session: Session = None, # type: ignore
|
||||
) -> None:
|
||||
"""Bump last_seen on the device, skipping if updated within the debounce window.
|
||||
|
||||
Intended for the auth hot path -- called on every authenticated client-token
|
||||
request. Mirrors the debounce used by ClientToken.update_last_used.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
device = session.get(Device, device_id)
|
||||
if device is None:
|
||||
return
|
||||
|
||||
if device.last_seen and (now - to_utc(device.last_seen)) < LAST_SEEN_DEBOUNCE:
|
||||
return
|
||||
|
||||
session.execute(
|
||||
update(Device)
|
||||
.where(Device.id == device_id)
|
||||
.values(last_seen=now)
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def delete_device(
|
||||
self,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import functools
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterable, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from redis.exceptions import WatchError
|
||||
from sqlalchemy import (
|
||||
Integer,
|
||||
Row,
|
||||
String,
|
||||
Text,
|
||||
and_,
|
||||
@@ -29,6 +31,7 @@ from sqlalchemy.orm import (
|
||||
load_only,
|
||||
noload,
|
||||
selectinload,
|
||||
undefer,
|
||||
)
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
@@ -36,9 +39,20 @@ from sqlalchemy.sql.selectable import Select
|
||||
from config import ROMM_DB_DRIVER
|
||||
from decorators.database import begin_session
|
||||
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
|
||||
from handler.redis_handler import sync_cache
|
||||
from models.assets import Save, Screenshot, State
|
||||
from models.base import compute_file_name_parts
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom, RomFile, RomMetadata, RomNote, RomUser
|
||||
from models.rom import (
|
||||
Rom,
|
||||
RomFile,
|
||||
RomFileCategory,
|
||||
RomMetadata,
|
||||
RomNote,
|
||||
RomUser,
|
||||
SiblingRom,
|
||||
compute_name_sort_key,
|
||||
)
|
||||
from utils.database import (
|
||||
json_array_contains_all,
|
||||
json_array_contains_any,
|
||||
@@ -101,7 +115,53 @@ RUFFLE_SUPPORTED_PLATFORMS = [
|
||||
UPS.BROWSER,
|
||||
]
|
||||
|
||||
STRIP_ARTICLES_REGEX = r"^(the|a|an)\s+"
|
||||
# Used to remove native full-text SQL operators
|
||||
FULLTEXT_BOOLEAN_OPERATORS_REGEX = re.compile(r'[+\-~<>()"@*]')
|
||||
|
||||
# 3 is the default minimum size in InnoDB
|
||||
FULLTEXT_MIN_TOKEN_SIZE = 3
|
||||
|
||||
# Cached ROM filter values (genres/franchises/etc.) so it doesn't get
|
||||
# recomputed on every call to /api/roms
|
||||
ROM_FILTERS_CACHE_VERSION_KEY = "filter_values:ver"
|
||||
ROM_FILTERS_CACHE_KEYS_PREFIX = "filter_values:keys"
|
||||
ROM_FILTERS_CACHE_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
def _cache_value_to_str(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value.decode()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _filter_values_cache_version() -> str:
|
||||
return _cache_value_to_str(sync_cache.get(ROM_FILTERS_CACHE_VERSION_KEY)) or "0"
|
||||
|
||||
|
||||
def _filter_values_cache_keys_key(version: str) -> str:
|
||||
return f"{ROM_FILTERS_CACHE_KEYS_PREFIX}:v{version}"
|
||||
|
||||
|
||||
def _store_versioned_cache(redis_key: str, version: str, result: Any) -> None:
|
||||
version_keys_set = _filter_values_cache_keys_key(version)
|
||||
with sync_cache.pipeline() as pipe:
|
||||
try:
|
||||
pipe.watch(ROM_FILTERS_CACHE_VERSION_KEY)
|
||||
current_version = (
|
||||
_cache_value_to_str(pipe.get(ROM_FILTERS_CACHE_VERSION_KEY)) or "0"
|
||||
)
|
||||
if current_version != version:
|
||||
pipe.unwatch()
|
||||
else:
|
||||
pipe.multi()
|
||||
pipe.set(redis_key, json.dumps(result), ex=ROM_FILTERS_CACHE_TTL)
|
||||
pipe.sadd(version_keys_set, redis_key)
|
||||
pipe.expire(version_keys_set, ROM_FILTERS_CACHE_TTL)
|
||||
pipe.execute()
|
||||
except WatchError:
|
||||
pass
|
||||
|
||||
|
||||
def _create_metadata_id_case(
|
||||
@@ -142,14 +202,31 @@ def with_details(func):
|
||||
),
|
||||
selectinload(Rom.rom_users).options(noload(RomUser.rom)),
|
||||
selectinload(Rom.metadatum).options(noload(RomMetadata.rom)),
|
||||
# Multi-file downloads, 3DS QR codes, and metadata matching
|
||||
selectinload(Rom.files).options(
|
||||
joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name)
|
||||
),
|
||||
selectinload(Rom.sibling_roms).options(
|
||||
noload(Rom.platform), noload(Rom.metadatum)
|
||||
noload(Rom.platform),
|
||||
noload(Rom.metadatum),
|
||||
# Per-sibling is_main_sibling resolution for the
|
||||
# SiblingRomSchema needs each sibling's RomUser for the
|
||||
# request user — the relationship is `lazy="raise"`, so
|
||||
# it has to be eager-loaded here.
|
||||
selectinload(Rom.rom_users).options(noload(RomUser.rom)),
|
||||
load_only(
|
||||
Rom.id,
|
||||
Rom.name,
|
||||
Rom.fs_name_no_tags,
|
||||
Rom.fs_name_no_ext,
|
||||
),
|
||||
),
|
||||
selectinload(Rom.collections),
|
||||
selectinload(Rom.notes),
|
||||
undefer(Rom.multi_file),
|
||||
undefer(Rom.top_level_file_count),
|
||||
undefer(Rom.has_manual_files),
|
||||
undefer(Rom.has_soundtrack),
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@@ -195,6 +272,85 @@ class DBRomsHandler(DBBaseHandler):
|
||||
return []
|
||||
return session.scalars(query.filter(Rom.id.in_(ids))).all()
|
||||
|
||||
def get_files_for_roms(
|
||||
self,
|
||||
rom_ids: list[int],
|
||||
*,
|
||||
session: Session,
|
||||
) -> dict[int, list[RomFile]]:
|
||||
"""Return {rom_id: [RomFile, ...]} for the given rom IDs in a single query.
|
||||
|
||||
Used by the list endpoint to serialize files without relying on the
|
||||
query's relationship eager-load surviving pagination.
|
||||
"""
|
||||
if not rom_ids:
|
||||
return {}
|
||||
|
||||
files = session.scalars(
|
||||
select(RomFile).where(RomFile.rom_id.in_(rom_ids))
|
||||
).all()
|
||||
|
||||
buckets: dict[int, list[RomFile]] = {rom_id: [] for rom_id in rom_ids}
|
||||
for file in files:
|
||||
buckets[file.rom_id].append(file)
|
||||
|
||||
return buckets
|
||||
|
||||
def get_siblings_for_roms(
|
||||
self,
|
||||
rom_ids: list[int],
|
||||
user_id: int,
|
||||
*,
|
||||
session: Session,
|
||||
) -> dict[int, list[tuple[Rom, bool]]]:
|
||||
"""Return {rom_id: [(sibling Rom, is_main_sibling), ...]} in a single query.
|
||||
|
||||
Joins sibling_roms → roms (only the columns SiblingRomSchema needs) and
|
||||
left-joins rom_user for the requesting user, so the per-user
|
||||
`is_main_sibling` flag is resolved without hydrating the wide roms table
|
||||
or its JSON metadata on every page.
|
||||
"""
|
||||
if not rom_ids:
|
||||
return {}
|
||||
|
||||
rows = session.execute(
|
||||
select(
|
||||
SiblingRom.rom_id,
|
||||
Rom,
|
||||
func.coalesce(RomUser.is_main_sibling, false()).label(
|
||||
"is_main_sibling"
|
||||
),
|
||||
)
|
||||
.join(Rom, Rom.id == SiblingRom.sibling_rom_id)
|
||||
.outerjoin(
|
||||
RomUser,
|
||||
and_(
|
||||
RomUser.rom_id == SiblingRom.sibling_rom_id,
|
||||
RomUser.user_id == user_id,
|
||||
),
|
||||
)
|
||||
.where(SiblingRom.rom_id.in_(rom_ids))
|
||||
.options(
|
||||
load_only(
|
||||
Rom.name,
|
||||
Rom.fs_name_no_tags,
|
||||
Rom.fs_name_no_ext,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
# Dedupe by (parent rom, sibling id) so a duplicate join row doesn't
|
||||
# surface the same sibling twice on the wire.
|
||||
seen: dict[int, set[int]] = {rom_id: set() for rom_id in rom_ids}
|
||||
buckets: dict[int, list[tuple[Rom, bool]]] = {rom_id: [] for rom_id in rom_ids}
|
||||
for rom_id, sibling, is_main in rows:
|
||||
if sibling.id in seen[rom_id]:
|
||||
continue
|
||||
seen[rom_id].add(sibling.id)
|
||||
buckets[rom_id].append((sibling, bool(is_main)))
|
||||
|
||||
return buckets
|
||||
|
||||
def filter_by_platform_id(self, query: Query, platform_id: int):
|
||||
return query.filter(Rom.platform_id == platform_id)
|
||||
|
||||
@@ -242,19 +398,53 @@ class DBRomsHandler(DBBaseHandler):
|
||||
return query.filter(Rom.id.in_(smart_collection.rom_ids))
|
||||
return query
|
||||
|
||||
def _build_fulltext_boolean_query(self, term: str) -> str | None:
|
||||
words = FULLTEXT_BOOLEAN_OPERATORS_REGEX.sub(" ", term).split()
|
||||
if not words or any(len(word) < FULLTEXT_MIN_TOKEN_SIZE for word in words):
|
||||
return None
|
||||
return " ".join(f"+{word}*" for word in words)
|
||||
|
||||
def _build_fulltext_relevance(self, search_term: str) -> str | None:
|
||||
parts: list[str] = []
|
||||
for term in search_term.split("|"):
|
||||
words = FULLTEXT_BOOLEAN_OPERATORS_REGEX.sub(" ", term).split()
|
||||
if len(words) > 1:
|
||||
parts.append('"' + " ".join(words) + '"')
|
||||
return " ".join(parts) if parts else None
|
||||
|
||||
def _filter_by_search_term(self, query: Query, search_term: str):
|
||||
terms = [term.strip() for term in search_term.split("|")]
|
||||
conditions = [
|
||||
condition
|
||||
for term in terms
|
||||
for condition in (
|
||||
Rom.fs_name.ilike(f"%{term}%"),
|
||||
Rom.name.ilike(f"%{term}%"),
|
||||
)
|
||||
if term
|
||||
]
|
||||
terms = [term for term in terms if term]
|
||||
if not terms:
|
||||
return query
|
||||
|
||||
return query.filter(or_(*conditions))
|
||||
if ROMM_DB_DRIVER in ("mariadb", "mysql"):
|
||||
match_clauses: list[Any] = []
|
||||
for idx, term in enumerate(terms):
|
||||
boolean_query = self._build_fulltext_boolean_query(term)
|
||||
if boolean_query is None:
|
||||
match_clauses = []
|
||||
break
|
||||
param = f"fulltext_search_{idx}"
|
||||
match_clauses.append(
|
||||
text(
|
||||
f"MATCH(roms.name, roms.fs_name) "
|
||||
f"AGAINST(:{param} IN BOOLEAN MODE)"
|
||||
).bindparams(**{param: boolean_query})
|
||||
)
|
||||
if match_clauses:
|
||||
return query.filter(or_(*match_clauses))
|
||||
|
||||
# psql and full-text fallback
|
||||
term_conditions = []
|
||||
for term in terms:
|
||||
word_conditions = [
|
||||
or_(Rom.fs_name.ilike(f"%{word}%"), Rom.name.ilike(f"%{word}%"))
|
||||
for word in term.split()
|
||||
]
|
||||
if word_conditions:
|
||||
term_conditions.append(and_(*word_conditions))
|
||||
return query.filter(or_(*term_conditions))
|
||||
|
||||
def _filter_by_matched(self, query: Query, value: bool) -> Query:
|
||||
"""Filter based on whether the rom is matched to a metadata provider.
|
||||
@@ -549,6 +739,8 @@ class DBRomsHandler(DBBaseHandler):
|
||||
player_counts_logic: str = "any",
|
||||
user_id: int | None = None,
|
||||
updated_after: datetime | None = None,
|
||||
include_file_stats: bool = False,
|
||||
include_files: bool = False,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Query[Rom]:
|
||||
from handler.scan_handler import MetadataSource
|
||||
@@ -560,18 +752,35 @@ class DBRomsHandler(DBBaseHandler):
|
||||
selectinload(Rom.rom_users).options(noload(RomUser.rom)),
|
||||
# Sort table by metadata (first_release_date)
|
||||
selectinload(Rom.metadatum).options(noload(RomMetadata.rom)),
|
||||
# Required for multi-file ROM actions and 3DS QR code
|
||||
selectinload(Rom.files).options(
|
||||
joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name)
|
||||
),
|
||||
# Show sibling rom badges on cards
|
||||
selectinload(Rom.sibling_roms).options(
|
||||
noload(Rom.platform), noload(Rom.metadatum)
|
||||
),
|
||||
# Show notes indicator on cards
|
||||
# Notes indicator on cards
|
||||
selectinload(Rom.notes),
|
||||
)
|
||||
|
||||
# Only load files (and the RomFile.rom backref needed by `is_top_level` /
|
||||
# `file_name_for_download`) when the caller iterates them — e.g. the
|
||||
# feed endpoints. The gallery/list and filter-value paths serialize
|
||||
# SimpleRomSchema without files, so they skip this entirely.
|
||||
if include_files:
|
||||
query = query.options(
|
||||
selectinload(Rom.files).options(
|
||||
joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name)
|
||||
)
|
||||
)
|
||||
|
||||
# Correlated subqueries and only undefer when the caller serializes the
|
||||
# gallery-card flags. Feeds and filter-value lookups don't need them.
|
||||
if include_file_stats:
|
||||
query = query.options(
|
||||
undefer(Rom.multi_file),
|
||||
undefer(Rom.top_level_file_count),
|
||||
undefer(Rom.has_manual_files),
|
||||
undefer(Rom.has_soundtrack),
|
||||
)
|
||||
|
||||
# Handle platform filtering - platform filtering always uses OR logic since ROMs belong to only one platform
|
||||
if platform_ids:
|
||||
query = self._filter_by_platform_ids(query, platform_ids)
|
||||
@@ -776,8 +985,9 @@ class DBRomsHandler(DBBaseHandler):
|
||||
def get_roms_query(
|
||||
self,
|
||||
*,
|
||||
order_by: str = "name",
|
||||
order_by: str = "",
|
||||
order_dir: str = "asc",
|
||||
search_term: str | None = None,
|
||||
user_id: int | None = None,
|
||||
session: Session = None, # type: ignore
|
||||
) -> tuple[Query[Rom], Any]:
|
||||
@@ -799,26 +1009,38 @@ class DBRomsHandler(DBBaseHandler):
|
||||
else:
|
||||
order_attr = Rom.name
|
||||
|
||||
# Use indexed `name_sort_key` to have fast access to names without
|
||||
# articles (the, a, an) and leading digits. The key is derived from
|
||||
# `name` at write time, or holds a custom override when one is set.
|
||||
if order_attr is Rom.name:
|
||||
order_attr = Rom.name_sort_key
|
||||
|
||||
order_attr_column = order_attr
|
||||
|
||||
# Ignore case when the order attribute is a number
|
||||
if isinstance(order_attr.type, (String, Text)):
|
||||
# Remove any leading articles
|
||||
order_attr = func.trim(
|
||||
func.lower(order_attr).regexp_replace(STRIP_ARTICLES_REGEX, "")
|
||||
)
|
||||
|
||||
# Pad numbers with leading zeros to ensure natural sorting
|
||||
order_attr = order_attr.regexp_replace(
|
||||
r"(\d+)", r"00000000000\1"
|
||||
).regexp_replace(r"0*(\d{12})", r"\1")
|
||||
|
||||
if order_dir.lower() == "desc":
|
||||
order_attr = order_attr.desc()
|
||||
else:
|
||||
order_attr = order_attr.asc()
|
||||
|
||||
return query.order_by(order_attr), order_attr_column # type: ignore
|
||||
relevance_clause = None
|
||||
if search_term and ROMM_DB_DRIVER in ("mariadb", "mysql"):
|
||||
relevance = self._build_fulltext_relevance(search_term)
|
||||
if relevance:
|
||||
relevance_clause = text(
|
||||
"MATCH(roms.name, roms.fs_name) "
|
||||
"AGAINST(:relevance IN BOOLEAN MODE) DESC"
|
||||
).bindparams(relevance=relevance)
|
||||
|
||||
if order_by: # explicit sort wins, relevance breaks ties
|
||||
order_clauses = [order_attr]
|
||||
if relevance_clause is not None:
|
||||
order_clauses.append(relevance_clause)
|
||||
else: # no sort selected: relevance leads, name is the tiebreaker
|
||||
order_clauses = [order_attr]
|
||||
if relevance_clause is not None:
|
||||
order_clauses.insert(0, relevance_clause)
|
||||
|
||||
return query.order_by(*order_clauses), order_attr_column # type: ignore
|
||||
|
||||
@begin_session
|
||||
def get_roms_scalar(
|
||||
@@ -829,8 +1051,9 @@ class DBRomsHandler(DBBaseHandler):
|
||||
**kwargs,
|
||||
) -> Sequence[Rom]:
|
||||
query, _ = self.get_roms_query(
|
||||
order_by=kwargs.get("order_by", "name"),
|
||||
order_by=kwargs.get("order_by", ""),
|
||||
order_dir=kwargs.get("order_dir", "asc"),
|
||||
search_term=kwargs.get("search_term", None),
|
||||
user_id=kwargs.get("user_id", None),
|
||||
)
|
||||
|
||||
@@ -872,6 +1095,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
player_counts_logic=kwargs.get("player_counts_logic", "any"),
|
||||
user_id=kwargs.get("user_id", None),
|
||||
group_by_meta_id=kwargs.get("group_by_meta_id", False),
|
||||
include_files=kwargs.get("include_files", False),
|
||||
)
|
||||
return session.scalars(roms).all()
|
||||
|
||||
@@ -880,22 +1104,35 @@ class DBRomsHandler(DBBaseHandler):
|
||||
self,
|
||||
query: Query,
|
||||
order_by_attr: Any,
|
||||
*,
|
||||
cache_key: str | None = None,
|
||||
order_dir: str = "asc",
|
||||
session: Session = None, # type: ignore
|
||||
) -> list[Row[tuple[str, int]]]:
|
||||
if isinstance(order_by_attr.type, (String, Text)):
|
||||
# Remove any leading articles
|
||||
order_by_attr = func.trim(
|
||||
func.lower(order_by_attr).regexp_replace(STRIP_ARTICLES_REGEX, "")
|
||||
)
|
||||
else:
|
||||
order_by_attr = func.trim(
|
||||
func.lower(Rom.name).regexp_replace(STRIP_ARTICLES_REGEX, "")
|
||||
)
|
||||
) -> list[tuple[str, int]]:
|
||||
redis_key: str | None = None
|
||||
version: str | None = None
|
||||
if cache_key:
|
||||
version = _filter_values_cache_version()
|
||||
redis_key = f"char_index:{cache_key}:v{version}"
|
||||
cached = sync_cache.get(redis_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
# Pad numbers with leading zeros to ensure natural sorting
|
||||
order_by_attr = order_by_attr.regexp_replace(
|
||||
r"(\d+)", r"00000000000\1"
|
||||
).regexp_replace(r"0*(\d{12})", r"\1")
|
||||
# Drop any ordering carried over from the main query (e.g. search relevance).
|
||||
# This builds its own positional ordering below.
|
||||
query = query.order_by(None)
|
||||
|
||||
if not isinstance(order_by_attr.type, (String, Text)):
|
||||
order_by_attr = Rom.name_sort_key
|
||||
|
||||
# Apply the same direction the main query uses so the position
|
||||
# numbers we emit (and the per-letter min position downstream)
|
||||
# match the actual order the client paginates over. Without this
|
||||
# the frontend AlphaStrip would highlight the wrong letter when
|
||||
# order_dir=desc.
|
||||
order_window = (
|
||||
order_by_attr.desc() if order_dir.lower() == "desc" else order_by_attr.asc()
|
||||
)
|
||||
|
||||
# Get the row number and first letter for each item
|
||||
subquery = (
|
||||
@@ -906,21 +1143,27 @@ class DBRomsHandler(DBBaseHandler):
|
||||
1,
|
||||
1,
|
||||
).label("letter"),
|
||||
func.row_number().over(order_by=order_by_attr).label("position"),
|
||||
func.row_number().over(order_by=order_window).label("position"),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Get the minimum position for each letter
|
||||
return (
|
||||
rows = (
|
||||
session.query(
|
||||
subquery.c.letter, func.min(subquery.c.position - 1).label("position")
|
||||
)
|
||||
.filter(subquery.c.letter.isnot(None))
|
||||
.group_by(subquery.c.letter)
|
||||
.order_by(subquery.c.letter)
|
||||
.all()
|
||||
)
|
||||
|
||||
result = [(letter, int(position)) for letter, position in rows]
|
||||
if redis_key is not None and version is not None:
|
||||
_store_versioned_cache(redis_key, version, result)
|
||||
return result
|
||||
|
||||
@begin_session
|
||||
def get_roms_by_fs_name(
|
||||
self,
|
||||
@@ -931,8 +1174,8 @@ class DBRomsHandler(DBBaseHandler):
|
||||
"""Retrieve a dictionary of roms by their filesystem names.
|
||||
|
||||
Eager-loads only `platform` (used downstream by the scan loop via
|
||||
`rom.platform_slug` / `rom.platform.fs_slug`) — deliberately
|
||||
avoiding `with_details`, whose full relationship eager-load is
|
||||
`rom.platform_slug` / `rom.platform.fs_slug`). This deliberately
|
||||
avoids `with_details`, whose full relationship eager-load is
|
||||
wasted work for the scan-skip decision on large platforms.
|
||||
"""
|
||||
roms = (
|
||||
@@ -940,9 +1183,6 @@ class DBRomsHandler(DBBaseHandler):
|
||||
select(Rom)
|
||||
.options(
|
||||
selectinload(Rom.platform),
|
||||
selectinload(Rom.files).options(
|
||||
joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name)
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
@@ -964,6 +1204,26 @@ class DBRomsHandler(DBBaseHandler):
|
||||
data: dict,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Rom:
|
||||
if "name" in data and "name_sort_key" not in data:
|
||||
# Re-derive the key from the new name, but only when the stored key
|
||||
# is still the derived value (i.e. not a manual override). Mirrors
|
||||
# the `@validates` logic, which the bulk update() bypasses.
|
||||
existing = session.query(Rom).filter_by(id=id).one()
|
||||
if (
|
||||
existing.name_sort_key is None
|
||||
or existing.name_sort_key == compute_name_sort_key(existing.name)
|
||||
):
|
||||
data = {**data, "name_sort_key": compute_name_sort_key(data["name"])}
|
||||
|
||||
if "fs_name" in data:
|
||||
parts = compute_file_name_parts(data["fs_name"])
|
||||
data = {
|
||||
**data,
|
||||
"fs_name_no_tags": parts.no_tags,
|
||||
"fs_name_no_ext": parts.no_ext,
|
||||
"fs_extension": parts.extension,
|
||||
}
|
||||
|
||||
session.execute(
|
||||
update(Rom)
|
||||
.where(Rom.id == id)
|
||||
@@ -1066,7 +1326,9 @@ class DBRomsHandler(DBBaseHandler):
|
||||
user_id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> RomUser:
|
||||
return session.merge(RomUser(rom_id=rom_id, user_id=user_id))
|
||||
rom_user = session.merge(RomUser(rom_id=rom_id, user_id=user_id))
|
||||
session.flush()
|
||||
return rom_user
|
||||
|
||||
@begin_session
|
||||
def get_rom_user(
|
||||
@@ -1131,7 +1393,9 @@ class DBRomsHandler(DBBaseHandler):
|
||||
rom_file: RomFile,
|
||||
session: Session = None, # type: ignore
|
||||
) -> RomFile:
|
||||
return session.merge(rom_file)
|
||||
merged = session.merge(rom_file)
|
||||
session.flush()
|
||||
return merged
|
||||
|
||||
@begin_session
|
||||
def get_rom_file_by_id(
|
||||
@@ -1141,13 +1405,62 @@ class DBRomsHandler(DBBaseHandler):
|
||||
) -> RomFile | None:
|
||||
return session.scalar(select(RomFile).filter_by(id=id).limit(1))
|
||||
|
||||
@begin_session
|
||||
def get_rom_file_by_path(
|
||||
self,
|
||||
rom_id: int,
|
||||
file_path: str,
|
||||
file_name: str,
|
||||
session: Session = None, # type: ignore
|
||||
) -> RomFile | None:
|
||||
return session.scalar(
|
||||
select(RomFile)
|
||||
.filter_by(rom_id=rom_id, file_path=file_path, file_name=file_name)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def get_rom_files_by_category(
|
||||
self,
|
||||
rom_id: int,
|
||||
category: RomFileCategory,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[RomFile]:
|
||||
"""Return the ROM's files for a single category, ordered by file_name."""
|
||||
return (
|
||||
session.scalars(
|
||||
select(RomFile)
|
||||
.filter_by(rom_id=rom_id, category=category)
|
||||
.order_by(RomFile.file_name.asc())
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def rom_files_for_rom_id(
|
||||
self,
|
||||
rom_id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> list[RomFile]:
|
||||
"""Fetch a ROM's files on demand, with the `RomFile.rom` backref loaded."""
|
||||
return list(
|
||||
session.scalars(
|
||||
select(RomFile)
|
||||
.filter_by(rom_id=rom_id)
|
||||
.options(joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name))
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def update_rom_file(
|
||||
self,
|
||||
id: int,
|
||||
data: dict,
|
||||
session: Session = None, # type: ignore
|
||||
) -> RomFile:
|
||||
) -> RomFile | None:
|
||||
session.execute(
|
||||
update(RomFile)
|
||||
.where(RomFile.id == id)
|
||||
@@ -1155,7 +1468,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
return session.query(RomFile).filter_by(id=id).one()
|
||||
return session.query(RomFile).filter_by(id=id).one_or_none()
|
||||
|
||||
@begin_session
|
||||
def purge_rom_files(
|
||||
@@ -1173,6 +1486,18 @@ class DBRomsHandler(DBBaseHandler):
|
||||
)
|
||||
return purged_rom_files
|
||||
|
||||
@begin_session
|
||||
def delete_rom_file(
|
||||
self,
|
||||
id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> None:
|
||||
session.execute(
|
||||
delete(RomFile)
|
||||
.where(RomFile.id == id)
|
||||
.execution_options(synchronize_session="evaluate")
|
||||
)
|
||||
|
||||
# Note management methods
|
||||
@begin_session
|
||||
def get_rom_notes(
|
||||
@@ -1429,16 +1754,39 @@ class DBRomsHandler(DBBaseHandler):
|
||||
"platforms": sorted(platforms),
|
||||
}
|
||||
|
||||
def invalidate_filter_values_cache(self) -> None:
|
||||
old_version = str(int(sync_cache.incr(ROM_FILTERS_CACHE_VERSION_KEY)) - 1)
|
||||
old_keys_set = _filter_values_cache_keys_key(old_version)
|
||||
old_cache_keys = [
|
||||
key
|
||||
for raw_key in sync_cache.smembers(old_keys_set)
|
||||
if (key := _cache_value_to_str(raw_key)) is not None
|
||||
]
|
||||
if old_cache_keys:
|
||||
sync_cache.delete(*old_cache_keys)
|
||||
sync_cache.delete(old_keys_set)
|
||||
|
||||
@begin_session
|
||||
def with_filter_values(
|
||||
self,
|
||||
query: Query,
|
||||
*,
|
||||
cache_key: str | None = None,
|
||||
session: Session = None, # type: ignore
|
||||
) -> dict:
|
||||
"""
|
||||
Returns the list of filters given the current subset of ROMs in the query
|
||||
"""
|
||||
ids_subq = query.with_only_columns(Rom.id).scalar_subquery() # type: ignore
|
||||
redis_key: str | None = None
|
||||
version: str | None = None
|
||||
if cache_key:
|
||||
version = _filter_values_cache_version()
|
||||
redis_key = f"filter_values:{cache_key}:v{version}"
|
||||
cached = sync_cache.get(redis_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
ids_subq = query.order_by(None).with_only_columns(Rom.id).scalar_subquery() # type: ignore
|
||||
|
||||
statement = (
|
||||
select(
|
||||
@@ -1458,7 +1806,10 @@ class DBRomsHandler(DBBaseHandler):
|
||||
.where(Rom.id.in_(ids_subq))
|
||||
)
|
||||
|
||||
return self._collect_filter_values(session, statement)
|
||||
result = self._collect_filter_values(session, statement)
|
||||
if redis_key is not None and version is not None:
|
||||
_store_versioned_cache(redis_key, version, result)
|
||||
return result
|
||||
|
||||
@begin_session
|
||||
def get_rom_filters(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import and_, asc, delete, desc, select, update
|
||||
from sqlalchemy import and_, asc, delete, desc, func, or_, select, update
|
||||
from sqlalchemy.orm import QueryableAttribute, Session, load_only
|
||||
|
||||
from decorators.database import begin_session
|
||||
@@ -35,13 +35,15 @@ class DBSavesHandler(DBBaseHandler):
|
||||
user_id: int,
|
||||
rom_id: int,
|
||||
file_name: str,
|
||||
slot: str | None = None,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Save | None:
|
||||
return session.scalars(
|
||||
select(Save)
|
||||
.filter_by(rom_id=rom_id, user_id=user_id, file_name=file_name)
|
||||
.limit(1)
|
||||
).first()
|
||||
query = select(Save).filter_by(
|
||||
rom_id=rom_id, user_id=user_id, file_name=file_name
|
||||
)
|
||||
if slot is not None:
|
||||
query = query.filter(Save.slot == slot)
|
||||
return session.scalars(query.limit(1)).first()
|
||||
|
||||
@begin_session
|
||||
def get_save_by_content_hash(
|
||||
@@ -49,13 +51,15 @@ class DBSavesHandler(DBBaseHandler):
|
||||
user_id: int,
|
||||
rom_id: int,
|
||||
content_hash: str,
|
||||
slot: str | None = None,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Save | None:
|
||||
return session.scalar(
|
||||
select(Save)
|
||||
.filter_by(rom_id=rom_id, user_id=user_id, content_hash=content_hash)
|
||||
.limit(1)
|
||||
query = select(Save).filter_by(
|
||||
rom_id=rom_id, user_id=user_id, content_hash=content_hash
|
||||
)
|
||||
if slot is not None:
|
||||
query = query.filter(Save.slot == slot)
|
||||
return session.scalar(query.limit(1))
|
||||
|
||||
@begin_session
|
||||
def get_saves(
|
||||
@@ -64,6 +68,7 @@ class DBSavesHandler(DBBaseHandler):
|
||||
rom_id: int | None = None,
|
||||
platform_id: int | None = None,
|
||||
slot: str | None = None,
|
||||
slot_not_null: bool = False,
|
||||
order_by: Literal["updated_at", "created_at"] | None = None,
|
||||
order_dir: Literal["asc", "desc"] = "desc",
|
||||
only_fields: Sequence[QueryableAttribute] | None = None,
|
||||
@@ -82,6 +87,9 @@ class DBSavesHandler(DBBaseHandler):
|
||||
if slot is not None:
|
||||
query = query.filter(Save.slot == slot)
|
||||
|
||||
if slot_not_null:
|
||||
query = query.filter(Save.slot.is_not(None))
|
||||
|
||||
if order_by:
|
||||
order_col = getattr(Save, order_by)
|
||||
order_fn = asc if order_dir == "asc" else desc
|
||||
@@ -92,6 +100,65 @@ class DBSavesHandler(DBBaseHandler):
|
||||
|
||||
return session.scalars(query).all()
|
||||
|
||||
@begin_session
|
||||
def get_save_by_id(
|
||||
self,
|
||||
id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Save | None:
|
||||
"""Fetch a save by id without scoping to an owner. Used for the
|
||||
visibility toggle and community downloads, where the caller may not own
|
||||
the save. Mirrors db_screenshot_handler.get_screenshot_by_id."""
|
||||
return session.get(Save, id)
|
||||
|
||||
@begin_session
|
||||
def get_rom_shared_saves(
|
||||
self,
|
||||
rom_id: int,
|
||||
user_id: int,
|
||||
public_only: bool = False,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[Save]:
|
||||
"""Saves for a ROM visible to the requesting user: own (public +
|
||||
private) plus other users' public ones. Mirrors
|
||||
db_screenshot_handler.get_rom_gallery_screenshots."""
|
||||
query = select(Save).filter(Save.rom_id == rom_id)
|
||||
|
||||
if public_only:
|
||||
query = query.filter(Save.is_public)
|
||||
else:
|
||||
query = query.filter(or_(Save.user_id == user_id, Save.is_public))
|
||||
|
||||
query = query.order_by(desc(Save.updated_at))
|
||||
return session.scalars(query).all()
|
||||
|
||||
@begin_session
|
||||
def get_latest_saves_for_roms(
|
||||
self,
|
||||
user_id: int,
|
||||
rom_ids: Sequence[int],
|
||||
session: Session = None, # type: ignore
|
||||
) -> dict[int, Save]:
|
||||
"""The most recent save per ROM for a user, keyed by `rom_id`.
|
||||
|
||||
Batched for the continue-playing rail, which enriches each card with
|
||||
the in-game screenshot captured alongside the user's latest save.
|
||||
"""
|
||||
if not rom_ids:
|
||||
return {}
|
||||
|
||||
saves = session.scalars(
|
||||
select(Save)
|
||||
.filter(Save.user_id == user_id, Save.rom_id.in_(rom_ids))
|
||||
.order_by(desc(Save.updated_at))
|
||||
).all()
|
||||
|
||||
latest: dict[int, Save] = {}
|
||||
for save in saves:
|
||||
# Saves come newest-first, so the first one seen per ROM wins.
|
||||
latest.setdefault(save.rom_id, save)
|
||||
return latest
|
||||
|
||||
@begin_session
|
||||
def update_save(
|
||||
self,
|
||||
@@ -176,3 +243,34 @@ class DBSavesHandler(DBBaseHandler):
|
||||
"total_count": len(saves),
|
||||
"slots": list(slots_data.values()),
|
||||
}
|
||||
|
||||
@begin_session
|
||||
def count_saves_missing_content_hash(
|
||||
self,
|
||||
session: Session = None, # type: ignore
|
||||
) -> int:
|
||||
"""Number of Save rows whose content_hash is NULL. Used at startup to
|
||||
decide whether the one-shot recompute task needs to be enqueued."""
|
||||
return (
|
||||
session.scalar(
|
||||
select(func.count(Save.id)).where(Save.content_hash.is_(None))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def get_saves_after_id(
|
||||
self,
|
||||
after_id: int,
|
||||
limit: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[Save]:
|
||||
"""Page Save rows by primary key. Returns up to ``limit`` rows with
|
||||
``id > after_id``, ordered by id. Used by the
|
||||
recompute_save_content_hashes maintenance task to walk every row in
|
||||
bounded-memory batches: streaming via ``yield_per`` is incompatible
|
||||
with the per-call session lifetime that ``@begin_session`` enforces,
|
||||
so the caller drives pagination with this method instead."""
|
||||
return session.scalars(
|
||||
select(Save).where(Save.id > after_id).order_by(asc(Save.id)).limit(limit)
|
||||
).all()
|
||||
|
||||
@@ -82,6 +82,33 @@ class DBScreenshotsHandler(DBBaseHandler):
|
||||
) -> Screenshot | None:
|
||||
return session.get(Screenshot, id)
|
||||
|
||||
@begin_session
|
||||
def get_rom_gallery_screenshots(
|
||||
self,
|
||||
rom_id: int,
|
||||
user_id: int,
|
||||
public_only: bool = False,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[Screenshot]:
|
||||
"""Gallery (intentionally-uploaded) screenshots for a ROM, visible to
|
||||
the requesting user. Mirrors `db_rom_handler.get_rom_notes`: own
|
||||
screenshots (public + private) plus other users' public ones. Excludes
|
||||
the auto-captured save/state thumbnails (`is_gallery == False`)."""
|
||||
query = select(Screenshot).filter(
|
||||
Screenshot.rom_id == rom_id,
|
||||
Screenshot.is_gallery,
|
||||
)
|
||||
|
||||
if public_only:
|
||||
query = query.filter(Screenshot.is_public)
|
||||
else:
|
||||
query = query.filter(
|
||||
or_(Screenshot.user_id == user_id, Screenshot.is_public)
|
||||
)
|
||||
|
||||
query = query.order_by(Screenshot.created_at.desc())
|
||||
return session.scalars(query).all()
|
||||
|
||||
@begin_session
|
||||
def update_screenshot(
|
||||
self,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import and_, delete, select, update
|
||||
from sqlalchemy import and_, delete, desc, or_, select, update
|
||||
from sqlalchemy.orm import QueryableAttribute, Session, load_only
|
||||
|
||||
from decorators.database import begin_session
|
||||
@@ -66,6 +66,38 @@ class DBStatesHandler(DBBaseHandler):
|
||||
|
||||
return session.scalars(query).all()
|
||||
|
||||
@begin_session
|
||||
def get_state_by_id(
|
||||
self,
|
||||
id: int,
|
||||
session: Session = None, # type: ignore
|
||||
) -> State | None:
|
||||
"""Fetch a state by id without scoping to an owner. Used for the
|
||||
visibility toggle and community downloads, where the caller may not own
|
||||
the state. Mirrors db_screenshot_handler.get_screenshot_by_id."""
|
||||
return session.get(State, id)
|
||||
|
||||
@begin_session
|
||||
def get_rom_shared_states(
|
||||
self,
|
||||
rom_id: int,
|
||||
user_id: int,
|
||||
public_only: bool = False,
|
||||
session: Session = None, # type: ignore
|
||||
) -> Sequence[State]:
|
||||
"""States for a ROM visible to the requesting user: own (public +
|
||||
private) plus other users' public ones. Mirrors
|
||||
db_screenshot_handler.get_rom_gallery_screenshots."""
|
||||
query = select(State).filter(State.rom_id == rom_id)
|
||||
|
||||
if public_only:
|
||||
query = query.filter(State.is_public)
|
||||
else:
|
||||
query = query.filter(or_(State.user_id == user_id, State.is_public))
|
||||
|
||||
query = query.order_by(desc(State.updated_at))
|
||||
return session.scalars(query).all()
|
||||
|
||||
@begin_session
|
||||
def update_state(
|
||||
self,
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
from .assets_handler import FSAssetsHandler
|
||||
from .firmware_handler import FSFirmwareHandler
|
||||
from .launchbox_handler import FSLaunchboxHandler, get_fs_launchbox_handler
|
||||
from .platforms_handler import FSPlatformsHandler
|
||||
from .resources_handler import FSResourcesHandler
|
||||
from .roms_handler import FSRomsHandler
|
||||
from .sync_handler import FSSyncHandler
|
||||
from .sync_handler import FSSyncHandler, get_fs_sync_handler
|
||||
|
||||
fs_asset_handler = FSAssetsHandler()
|
||||
fs_firmware_handler = FSFirmwareHandler()
|
||||
fs_platform_handler = FSPlatformsHandler()
|
||||
fs_rom_handler = FSRomsHandler()
|
||||
fs_resource_handler = FSResourcesHandler()
|
||||
fs_sync_handler = FSSyncHandler()
|
||||
|
||||
__all__ = [
|
||||
"FSAssetsHandler",
|
||||
"FSFirmwareHandler",
|
||||
"FSLaunchboxHandler",
|
||||
"FSPlatformsHandler",
|
||||
"FSResourcesHandler",
|
||||
"FSRomsHandler",
|
||||
"FSSyncHandler",
|
||||
"fs_asset_handler",
|
||||
"fs_firmware_handler",
|
||||
"fs_platform_handler",
|
||||
"fs_resource_handler",
|
||||
"fs_rom_handler",
|
||||
"get_fs_launchbox_handler",
|
||||
"get_fs_sync_handler",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import hashlib
|
||||
import os
|
||||
import threading
|
||||
import zipfile
|
||||
from typing import Final
|
||||
|
||||
import magic
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
|
||||
from config import ASSETS_BASE_PATH
|
||||
from logger.logger import log
|
||||
@@ -8,6 +13,57 @@ from models.user import User
|
||||
|
||||
from .base_handler import FSHandler
|
||||
|
||||
# Image MIME types we trust to (a) accept as avatar uploads and (b) serve
|
||||
# inline from the raw asset endpoint
|
||||
SAFE_IMAGE_MIME_TYPES: Final[dict[str, str]] = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
}
|
||||
|
||||
# libmagic loads its database on construction (~few MB read from disk), so we
|
||||
# share a single Magic instance across requests. The underlying magic_t handle
|
||||
# is not thread-safe, so guard from_buffer with a lock. Endpoints that call
|
||||
# this validator may execute in worker threads under sync routes.
|
||||
_MIME_DETECTOR = magic.Magic(mime=True)
|
||||
_MIME_DETECTOR_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def validate_image_upload(upload: UploadFile, *, label: str = "Image") -> str:
|
||||
"""Validate that an uploaded file is one of the safe image types.
|
||||
|
||||
Sniffs the leading bytes with libmagic and returns the trusted extension
|
||||
matching the detected MIME type. Raises HTTPException(400) if the file
|
||||
is not a recognized PNG/JPEG/WebP/GIF, or if MIME sniffing fails.
|
||||
Leaves the file cursor at 0.
|
||||
"""
|
||||
upload.file.seek(0)
|
||||
header = upload.file.read(4096)
|
||||
upload.file.seek(0)
|
||||
|
||||
try:
|
||||
with _MIME_DETECTOR_LOCK:
|
||||
detected_mime = _MIME_DETECTOR.from_buffer(header)
|
||||
except magic.MagicException as exc:
|
||||
log.error(f"libmagic failed to sniff uploaded {label.lower()}: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Could not determine {label.lower()} file type",
|
||||
) from exc
|
||||
|
||||
safe_extension = SAFE_IMAGE_MIME_TYPES.get(detected_mime)
|
||||
if not safe_extension:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"{label} must be a PNG, JPEG, WebP, or GIF image "
|
||||
f"(detected {detected_mime or 'unknown type'})"
|
||||
),
|
||||
)
|
||||
|
||||
return safe_extension
|
||||
|
||||
|
||||
class FSAssetsHandler(FSHandler):
|
||||
def __init__(self) -> None:
|
||||
@@ -76,7 +132,7 @@ class FSAssetsHandler(FSHandler):
|
||||
return hash_obj.hexdigest()
|
||||
|
||||
async def _compute_zip_hash(self, zip_path: str) -> str:
|
||||
with zipfile.ZipFile(f"{self.base_path}/{zip_path}", "r") as zf:
|
||||
with zipfile.ZipFile(self.base_path / zip_path, "r") as zf:
|
||||
file_hashes = []
|
||||
for name in sorted(zf.namelist()):
|
||||
if not name.endswith("/"):
|
||||
@@ -88,7 +144,8 @@ class FSAssetsHandler(FSHandler):
|
||||
|
||||
async def compute_content_hash(self, file_path: str) -> str | None:
|
||||
try:
|
||||
if zipfile.is_zipfile(file_path):
|
||||
full_path = self.base_path / file_path
|
||||
if zipfile.is_zipfile(full_path):
|
||||
return await self._compute_zip_hash(file_path)
|
||||
return await self._compute_file_hash(file_path)
|
||||
except Exception as e:
|
||||
|
||||
@@ -16,11 +16,14 @@ from anyio import open_file
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from config.config_manager import config_manager as cm
|
||||
from models.base import FILE_NAME_MAX_LENGTH
|
||||
from utils.filesystem import iter_directories, iter_files
|
||||
from models.base import (
|
||||
FILE_NAME_MAX_LENGTH,
|
||||
compute_file_extension,
|
||||
compute_file_name_no_ext,
|
||||
compute_file_name_no_tags,
|
||||
)
|
||||
from utils.filesystem import iter_directories, iter_files, link_or_copy_file
|
||||
|
||||
TAG_REGEX = re.compile(r"\(([^)]+)\)|\[([^]]+)\]")
|
||||
EXTENSION_REGEX = re.compile(r"\.(([a-z]+\.)*\w+)$")
|
||||
UUID_V4_REGEX = re.compile(
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}",
|
||||
re.IGNORECASE,
|
||||
@@ -88,6 +91,50 @@ REGIONS = (
|
||||
REGIONS_BY_SHORTCODE = {region[0]: region[1] for region in REGIONS}
|
||||
REGIONS_NAME_KEYS = frozenset(region[1].lower() for region in REGIONS)
|
||||
|
||||
# Maps full REGIONS names to lowercase shortcodes used by metadata providers
|
||||
REGION_NAME_TO_PROVIDER_SHORTCODE: dict[str, str] = {
|
||||
"Australia": "au",
|
||||
"Asia": "asi",
|
||||
"Brazil": "br",
|
||||
"Canada": "ca",
|
||||
"China": "cn",
|
||||
"England": "uk",
|
||||
"Europe": "eu",
|
||||
"Finland": "fi",
|
||||
"France": "fr",
|
||||
"Germany": "de",
|
||||
"Greece": "gr",
|
||||
"Holland": "nl",
|
||||
"Hong Kong": "hk",
|
||||
"Italy": "it",
|
||||
"Japan": "jp",
|
||||
"Korea": "kr",
|
||||
"Netherlands": "nl",
|
||||
"Norway": "no",
|
||||
"Russia": "ru",
|
||||
"Spain": "sp",
|
||||
"Sweden": "se",
|
||||
"Taiwan": "tw",
|
||||
"USA": "us",
|
||||
"World": "wor",
|
||||
}
|
||||
|
||||
_REGION_NAME_TO_PROVIDER_SHORTCODE_CI = {
|
||||
k.lower(): v for k, v in REGION_NAME_TO_PROVIDER_SHORTCODE.items()
|
||||
}
|
||||
|
||||
|
||||
def region_name_to_provider_shortcode(region_name: str | None) -> str | None:
|
||||
"""Look up a provider shortcode for a region name (case-insensitive).
|
||||
|
||||
ROM filename parsing can leave region names in their raw casing
|
||||
(e.g. "europe", "EUROPE"), so callers must normalize before lookup.
|
||||
"""
|
||||
if not region_name:
|
||||
return None
|
||||
return _REGION_NAME_TO_PROVIDER_SHORTCODE_CI.get(region_name.lower())
|
||||
|
||||
|
||||
LANGUAGES_BY_SHORTCODE = {lang[0]: lang[1] for lang in LANGUAGES}
|
||||
LANGUAGES_NAME_KEYS = frozenset(lang[1].lower() for lang in LANGUAGES)
|
||||
|
||||
@@ -108,8 +155,6 @@ class FSHandler:
|
||||
self.base_path = Path(base_path).resolve()
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
self._lock_mutex = asyncio.Lock()
|
||||
|
||||
# Create base directory synchronously during initialization
|
||||
self.base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def _get_file_lock(self, file_path: str) -> asyncio.Lock:
|
||||
@@ -158,11 +203,24 @@ class FSHandler:
|
||||
full_path = base_path_obj / path_path
|
||||
|
||||
try:
|
||||
if full_path.is_symlink():
|
||||
# For symlinks, ensure the symlink itself is within base directory
|
||||
# Detect a symlink anywhere in the path, not just at the leaf —
|
||||
# users may symlink an intermediate directory (e.g. the library
|
||||
# root) to point at storage on another filesystem.
|
||||
has_symlink_in_path = full_path.is_symlink()
|
||||
if not has_symlink_in_path:
|
||||
for parent in full_path.parents:
|
||||
if parent == base_path_obj:
|
||||
break
|
||||
if parent.is_symlink():
|
||||
has_symlink_in_path = True
|
||||
break
|
||||
|
||||
if has_symlink_in_path:
|
||||
# Validate lexically — `..` and absolute paths are already
|
||||
# rejected above, so the symlink target is reachable only via
|
||||
# an intentionally-configured link.
|
||||
full_path.relative_to(base_path_obj)
|
||||
else:
|
||||
# For regular files/dirs, ensure resolved path is within base directory
|
||||
full_path.resolve().relative_to(base_path_obj)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
@@ -195,37 +253,37 @@ class FSHandler:
|
||||
raise
|
||||
|
||||
def get_file_name_with_no_extension(self, file_name: str) -> str:
|
||||
return EXTENSION_REGEX.sub("", file_name).strip()
|
||||
return compute_file_name_no_ext(file_name)
|
||||
|
||||
def get_file_name_with_no_tags(self, file_name: str) -> str:
|
||||
file_name_no_extension = self.get_file_name_with_no_extension(file_name)
|
||||
return TAG_REGEX.split(file_name_no_extension)[0].strip()
|
||||
return compute_file_name_no_tags(file_name)
|
||||
|
||||
def parse_file_extension(self, file_name: str) -> str:
|
||||
match = EXTENSION_REGEX.search(file_name)
|
||||
return match.group(1) if match else ""
|
||||
return compute_file_extension(file_name)
|
||||
|
||||
def extract_uuid_v4_from_filename(self, file_name: str) -> str:
|
||||
match = UUID_V4_REGEX.search(file_name)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
def exclude_single_files(self, files: list[str]) -> list[str]:
|
||||
excluded_extensions = cm.get_config().EXCLUDED_SINGLE_EXT
|
||||
excluded_names = cm.get_config().EXCLUDED_SINGLE_FILES
|
||||
cnfg = cm.get_config()
|
||||
excluded_extensions = cnfg.EXCLUDED_SINGLE_EXT
|
||||
excluded_names = cnfg.EXCLUDED_SINGLE_FILES
|
||||
excluded_files: list[str] = []
|
||||
|
||||
for file_name in files:
|
||||
# Split the file name to get the extension.
|
||||
ext = self.parse_file_extension(file_name)
|
||||
file_name_lower = file_name.lower()
|
||||
|
||||
# Exclude the file if it has no extension or the extension is in the excluded list.
|
||||
if ext and ext.lower() in excluded_extensions:
|
||||
# Check whether the filename ends with any excluded extension entry.
|
||||
if any(file_name_lower.endswith("." + ext) for ext in excluded_extensions):
|
||||
excluded_files.append(file_name)
|
||||
continue
|
||||
|
||||
# Additionally, check if the file name mathes a pattern in the excluded list.
|
||||
for name in excluded_names:
|
||||
if file_name == name or fnmatch.fnmatch(file_name, name):
|
||||
excluded_files.append(file_name)
|
||||
# Check if the file name matches a pattern in the excluded list.
|
||||
if file_name in excluded_names or any(
|
||||
fnmatch.fnmatch(file_name, name) for name in excluded_names
|
||||
):
|
||||
excluded_files.append(file_name)
|
||||
|
||||
# Return files that are not in the filtered list.
|
||||
return [f for f in files if f not in excluded_files]
|
||||
@@ -441,13 +499,21 @@ class FSHandler:
|
||||
|
||||
return await open_file(full_path, "rb")
|
||||
|
||||
async def copy_file(self, source_full_path: Path, dest_path: str) -> None:
|
||||
async def copy_file(
|
||||
self,
|
||||
source_full_path: Path,
|
||||
dest_path: str,
|
||||
allow_link: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Copy a file from source to destination.
|
||||
|
||||
Args:
|
||||
source_full_path: Absolute path to the source file
|
||||
dest_path: Relative path to the destination file
|
||||
allow_link: Try a hardlink first and fall back to
|
||||
a copy when the link isn't possible (cross-device,
|
||||
unsupported filesystem, etc.)
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If source file does not exist
|
||||
@@ -472,7 +538,10 @@ class FSHandler:
|
||||
# Create destination directory if needed
|
||||
dest_parent_anyio_path = AnyioPath(str(dest_full_path.parent))
|
||||
await dest_parent_anyio_path.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(source_full_path), str(dest_full_path))
|
||||
if allow_link:
|
||||
link_or_copy_file(source_full_path, dest_full_path)
|
||||
else:
|
||||
shutil.copy2(str(source_full_path), str(dest_full_path))
|
||||
|
||||
async def move_file_or_folder(self, source_path: str, dest_path: str) -> None:
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import binascii
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from config import LIBRARY_BASE_PATH
|
||||
from config.config_manager import config_manager as cm
|
||||
@@ -17,9 +16,9 @@ class FSFirmwareHandler(FSHandler):
|
||||
def get_firmware_fs_structure(self, fs_slug: str) -> str:
|
||||
cnfg = cm.get_config()
|
||||
return (
|
||||
f"{cnfg.FIRMWARE_FOLDER_NAME}/{fs_slug}"
|
||||
if os.path.exists(cnfg.HIGH_PRIO_STRUCTURE_PATH)
|
||||
else f"{fs_slug}/{cnfg.FIRMWARE_FOLDER_NAME}"
|
||||
f"{fs_slug}/{cnfg.FIRMWARE_FOLDER_NAME}"
|
||||
if cnfg.has_structure_path_b
|
||||
else f"{cnfg.FIRMWARE_FOLDER_NAME}/{fs_slug}"
|
||||
)
|
||||
|
||||
async def get_firmware(self, platform_fs_slug: str):
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import functools
|
||||
|
||||
from config import LAUNCHBOX_BASE_PATH
|
||||
from handler.filesystem.base_handler import FSHandler
|
||||
|
||||
|
||||
class FSLaunchboxHandler(FSHandler):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(base_path=LAUNCHBOX_BASE_PATH)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_fs_launchbox_handler() -> FSLaunchboxHandler:
|
||||
"""Lazily instantiate the LaunchBox handler on first use.
|
||||
|
||||
Deferred so that startup doesn't fail when the LaunchBox feature is
|
||||
unconfigured or its base path is not writable.
|
||||
"""
|
||||
return FSLaunchboxHandler()
|
||||
@@ -4,10 +4,8 @@ from anyio import Path as AnyioPath
|
||||
|
||||
from config import LIBRARY_BASE_PATH
|
||||
from config.config_manager import config_manager as cm
|
||||
from exceptions.fs_exceptions import (
|
||||
FolderStructureNotMatchException,
|
||||
PlatformAlreadyExistsException,
|
||||
)
|
||||
from exceptions.fs_exceptions import PlatformAlreadyExistsException
|
||||
from logger.logger import log
|
||||
|
||||
from .base_handler import FSHandler, LibraryStructure
|
||||
|
||||
@@ -33,49 +31,45 @@ class FSPlatformsHandler(FSHandler):
|
||||
def detect_library_structure(self) -> LibraryStructure | None:
|
||||
"""Detects the library structure type.
|
||||
|
||||
Structure A ({roms_folder}/{platform}) takes priority over Structure B
|
||||
({platform}/{roms_folder}) so that existing libraries are not broken when a
|
||||
stray {platform}/{roms_folder} directory happens to exist alongside them.
|
||||
|
||||
Returns:
|
||||
"LibraryStructure.A" for Structure A (roms/{platform})
|
||||
"LibraryStructure.B" for Structure B ({platform}/roms)
|
||||
None if no structure detected
|
||||
"LibraryStructure.A" for Structure A (roms/{platform}) when the
|
||||
top-level roms folder exists.
|
||||
"LibraryStructure.B" for Structure B ({platform}/roms) when no
|
||||
top-level roms folder exists but at least one platform has a
|
||||
roms subfolder.
|
||||
None if no structure detected.
|
||||
"""
|
||||
cnfg = cm.get_config()
|
||||
|
||||
# Check if the roms folder exists (Structure A indicator)
|
||||
roms_path = os.path.join(LIBRARY_BASE_PATH, cnfg.ROMS_FOLDER_NAME)
|
||||
if os.path.exists(roms_path):
|
||||
if cnfg.has_structure_path_a:
|
||||
return LibraryStructure.A
|
||||
|
||||
# Check if any platform folders with roms subfolders exist (Structure B)
|
||||
try:
|
||||
library_contents = os.listdir(LIBRARY_BASE_PATH)
|
||||
for item in library_contents:
|
||||
item_path = os.path.join(LIBRARY_BASE_PATH, item)
|
||||
roms_subfolder = os.path.join(item_path, cnfg.ROMS_FOLDER_NAME)
|
||||
if os.path.isdir(item_path) and os.path.exists(roms_subfolder):
|
||||
return LibraryStructure.B
|
||||
except (OSError, FileNotFoundError):
|
||||
pass
|
||||
if cnfg.has_structure_path_b:
|
||||
return LibraryStructure.B
|
||||
|
||||
return None
|
||||
|
||||
def get_platforms_directory(self) -> str:
|
||||
cnfg = cm.get_config()
|
||||
|
||||
# Fallback to config hint when detection is inconclusive
|
||||
return (
|
||||
cnfg.ROMS_FOLDER_NAME
|
||||
if os.path.exists(cnfg.HIGH_PRIO_STRUCTURE_PATH)
|
||||
else ""
|
||||
)
|
||||
# Fallback to config hint when detection is inconclusive: default to
|
||||
# Structure A (roms/{platform}) so the bare library root is not treated
|
||||
# as a flat list of platforms. When the roms folder is missing entirely,
|
||||
# get_platforms() bootstraps it instead of failing.
|
||||
return "" if cnfg.has_structure_path_b else cnfg.ROMS_FOLDER_NAME
|
||||
|
||||
def get_platform_fs_structure(self, fs_slug: str) -> str:
|
||||
cnfg = cm.get_config()
|
||||
|
||||
# Fallback to config hint when detection is inconclusive
|
||||
return (
|
||||
f"{cnfg.ROMS_FOLDER_NAME}/{fs_slug}"
|
||||
if os.path.exists(cnfg.HIGH_PRIO_STRUCTURE_PATH)
|
||||
else f"{fs_slug}/{cnfg.ROMS_FOLDER_NAME}"
|
||||
f"{fs_slug}/{cnfg.ROMS_FOLDER_NAME}"
|
||||
if cnfg.has_structure_path_b
|
||||
else f"{cnfg.ROMS_FOLDER_NAME}/{fs_slug}"
|
||||
)
|
||||
|
||||
async def add_platform(self, fs_slug: str) -> None:
|
||||
@@ -94,6 +88,11 @@ class FSPlatformsHandler(FSHandler):
|
||||
async def get_platforms(self) -> list[str]:
|
||||
"""Retrieves all platforms from the filesystem.
|
||||
|
||||
If no library structure exists yet (neither Structure A's top-level roms
|
||||
folder nor a Structure B {platform}/roms folder), defaults to Structure A
|
||||
by creating the roms folder and returns an empty list, so RomM starts
|
||||
cleanly with an empty library instead of failing.
|
||||
|
||||
Returns:
|
||||
List of platform slugs.
|
||||
"""
|
||||
@@ -101,8 +100,19 @@ class FSPlatformsHandler(FSHandler):
|
||||
|
||||
try:
|
||||
platforms = await self.list_directories(path=self.get_platforms_directory())
|
||||
except FileNotFoundError as e:
|
||||
raise FolderStructureNotMatchException() from e
|
||||
except FileNotFoundError:
|
||||
# The platforms directory does not exist, which means no library
|
||||
# structure has been set up yet. Bootstrap Structure A so the
|
||||
# filesystem is in a valid state and report an empty library.
|
||||
log.warning(
|
||||
"No library structure found; creating default Structure A "
|
||||
"(roms folder) and starting with an empty library."
|
||||
)
|
||||
try:
|
||||
self.create_library_structure()
|
||||
except OSError:
|
||||
log.error("Failed to create default library structure", exc_info=True)
|
||||
return []
|
||||
|
||||
# For Structure B, only include directories that have a roms subfolder
|
||||
structure = self.detect_library_structure()
|
||||
|
||||
@@ -15,10 +15,37 @@ from models.collection import Collection
|
||||
from models.rom import Rom
|
||||
from tasks.scheduled.convert_images_to_webp import ImageConverter
|
||||
from utils.context import ctx_httpx_client
|
||||
from utils.validation import validate_url_for_http_request
|
||||
|
||||
from .base_handler import CoverSize, FSHandler
|
||||
|
||||
LOCAL_FILE_SCHEMES = ("file://", "launchbox-file://")
|
||||
|
||||
|
||||
def _resolve_local_file_uri(uri: str) -> Path | None:
|
||||
"""Resolve a local-file URI to an absolute Path, or None if unsafe/unknown.
|
||||
|
||||
`file://` resolves under the ROM library root. `launchbox-file://` resolves
|
||||
under the LaunchBox data root, since LaunchBox metadata produces paths
|
||||
relative to `/romm/launchbox`, which is not the same as the library root.
|
||||
"""
|
||||
from handler.filesystem import fs_rom_handler, get_fs_launchbox_handler
|
||||
|
||||
if uri.startswith("launchbox-file://"):
|
||||
try:
|
||||
return get_fs_launchbox_handler().validate_path(
|
||||
uri[len("launchbox-file://") :]
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if uri.startswith("file://"):
|
||||
try:
|
||||
return fs_rom_handler.validate_path(uri[len("file://") :])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _content_type_essence(header_value: str) -> str:
|
||||
"""Return the MIME type token (before parameters), lowercased."""
|
||||
@@ -95,34 +122,28 @@ class FSResourcesHandler(FSHandler):
|
||||
cover_file = f"{entity.fs_resources_path}/cover"
|
||||
await self.make_directory(cover_file)
|
||||
|
||||
# Handle file:// URLs for gamelist.xml
|
||||
if url_cover.startswith("file://"):
|
||||
# Handle local-file URIs from metadata handlers (gamelist, LaunchBox)
|
||||
if url_cover.startswith(LOCAL_FILE_SCHEMES):
|
||||
try:
|
||||
from handler.filesystem import fs_rom_handler
|
||||
|
||||
validated = fs_rom_handler.validate_path(
|
||||
url_cover[7:] # Remove "file://" prefix
|
||||
)
|
||||
if await AnyioPath(validated).exists():
|
||||
# Copy the file to the resources directory
|
||||
dest_path = f"{cover_file}/{size.value}.png"
|
||||
await self.copy_file(validated, dest_path)
|
||||
|
||||
if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
|
||||
self.image_converter.convert_to_webp(
|
||||
self.validate_path(f"{cover_file}/{size.value}.png"),
|
||||
force=True,
|
||||
)
|
||||
else:
|
||||
log.warning(f"Cover file not found: {str(validated)}")
|
||||
resolved = _resolve_local_file_uri(url_cover)
|
||||
if resolved is None or not await AnyioPath(resolved).exists():
|
||||
log.warning(f"Cover file not found: {url_cover}")
|
||||
return None
|
||||
dest_path = f"{cover_file}/{size.value}.png"
|
||||
# Small-size covers get resized in place, which would mutate
|
||||
# the user's source image if the destination were a hardlink.
|
||||
await self.copy_file(resolved, dest_path, allow_link=False)
|
||||
|
||||
if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
|
||||
self.image_converter.convert_to_webp(
|
||||
self.validate_path(f"{cover_file}/{size.value}.png"),
|
||||
force=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error(f"Unable to copy cover file {url_cover}: {str(exc)}")
|
||||
return None
|
||||
else:
|
||||
# Handle HTTP URLs
|
||||
validate_url_for_http_request(url_cover, "url_cover")
|
||||
|
||||
httpx_client = ctx_httpx_client.get()
|
||||
try:
|
||||
async with httpx_client.stream(
|
||||
@@ -275,27 +296,21 @@ class FSResourcesHandler(FSHandler):
|
||||
screenshot_path = f"{rom.fs_resources_path}/screenshots"
|
||||
await self.make_directory(screenshot_path)
|
||||
|
||||
# Handle file:// URLs for gamelist.xml
|
||||
if url_screenhot.startswith("file://"):
|
||||
# Handle local-file URIs from metadata handlers (gamelist, LaunchBox)
|
||||
if url_screenhot.startswith(LOCAL_FILE_SCHEMES):
|
||||
try:
|
||||
from handler.filesystem import fs_rom_handler
|
||||
|
||||
validated = fs_rom_handler.validate_path(
|
||||
url_screenhot[7:] # Remove "file://" prefix
|
||||
)
|
||||
if await AnyioPath(validated).exists():
|
||||
# Copy the file to the resources directory
|
||||
await self.copy_file(validated, f"{screenshot_path}/{idx}.jpg")
|
||||
else:
|
||||
log.warning(f"Screenshot file not found: {str(validated)}")
|
||||
resolved = _resolve_local_file_uri(url_screenhot)
|
||||
if resolved is None or not await AnyioPath(resolved).exists():
|
||||
log.warning(f"Screenshot file not found: {url_screenhot}")
|
||||
return None
|
||||
await self.copy_file(
|
||||
resolved, f"{screenshot_path}/{idx}.jpg", allow_link=True
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error(f"Unable to copy screenshot file {url_screenhot}: {str(exc)}")
|
||||
return None
|
||||
else:
|
||||
# Handle HTTP URLs
|
||||
validate_url_for_http_request(url_screenhot, "url_screenshot")
|
||||
|
||||
httpx_client = ctx_httpx_client.get()
|
||||
try:
|
||||
async with httpx_client.stream(
|
||||
@@ -395,27 +410,21 @@ class FSResourcesHandler(FSHandler):
|
||||
manual_path = f"{rom.fs_resources_path}/manual"
|
||||
await self.make_directory(manual_path)
|
||||
|
||||
# Handle file:// URLs for gamelist.xml
|
||||
if url_manual.startswith("file://"):
|
||||
# Handle local-file URIs from metadata handlers (gamelist, LaunchBox)
|
||||
if url_manual.startswith(LOCAL_FILE_SCHEMES):
|
||||
try:
|
||||
from handler.filesystem import fs_rom_handler
|
||||
|
||||
validated = fs_rom_handler.validate_path(
|
||||
url_manual[7:] # Remove "file://" prefix
|
||||
)
|
||||
if await AnyioPath(validated).exists():
|
||||
# Copy the file to the resources directory
|
||||
await self.copy_file(validated, f"{manual_path}/{rom.id}.pdf")
|
||||
else:
|
||||
log.warning(f"Manual file not found: {str(validated)}")
|
||||
resolved = _resolve_local_file_uri(url_manual)
|
||||
if resolved is None or not await AnyioPath(resolved).exists():
|
||||
log.warning(f"Manual file not found: {url_manual}")
|
||||
return None
|
||||
await self.copy_file(
|
||||
resolved, f"{manual_path}/{rom.id}.pdf", allow_link=True
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error(f"Unable to copy manual file {url_manual}: {str(exc)}")
|
||||
return None
|
||||
else:
|
||||
# Handle HTTP URL
|
||||
validate_url_for_http_request(url_manual, "url_manual")
|
||||
|
||||
httpx_client = ctx_httpx_client.get()
|
||||
try:
|
||||
async with httpx_client.stream(
|
||||
@@ -485,8 +494,6 @@ class FSResourcesHandler(FSHandler):
|
||||
|
||||
# Retroachievements
|
||||
async def store_ra_badge(self, url: str, path: str) -> None:
|
||||
validate_url_for_http_request(url, "url_badge")
|
||||
|
||||
httpx_client = ctx_httpx_client.get()
|
||||
directory, filename = os.path.split(path)
|
||||
|
||||
@@ -542,24 +549,17 @@ class FSResourcesHandler(FSHandler):
|
||||
# Ensure destination directory exists
|
||||
await self.make_directory(directory)
|
||||
|
||||
# Handle file:// URLs for gamelist.xml
|
||||
if url_media.startswith("file://"):
|
||||
# Handle local-file URIs from metadata handlers (gamelist, LaunchBox)
|
||||
if url_media.startswith(LOCAL_FILE_SCHEMES):
|
||||
try:
|
||||
from handler.filesystem import fs_rom_handler
|
||||
|
||||
validated = fs_rom_handler.validate_path(
|
||||
url_media[7:] # Remove "file://" prefix
|
||||
)
|
||||
file_path = AnyioPath(validated)
|
||||
if await file_path.exists():
|
||||
await self.copy_file(Path(str(file_path)), dest_path)
|
||||
resolved = _resolve_local_file_uri(url_media)
|
||||
if resolved is not None and await AnyioPath(resolved).exists():
|
||||
await self.copy_file(resolved, dest_path, allow_link=True)
|
||||
except Exception as exc:
|
||||
log.error(f"Unable to copy media file {url_media}: {str(exc)}")
|
||||
return None
|
||||
else:
|
||||
# Handle HTTP URLs
|
||||
validate_url_for_http_request(url_media, "url_media")
|
||||
|
||||
httpx_client = ctx_httpx_client.get()
|
||||
try:
|
||||
async with httpx_client.stream(
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import asyncio
|
||||
import binascii
|
||||
import bz2
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import tarfile
|
||||
import zipfile
|
||||
import zlib
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import IO, Any, Final, Literal, TypedDict, cast
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import magic
|
||||
import zipfile_inflate64 # trunk-ignore(ruff/F401): Patches zipfile to support Enhanced Deflate
|
||||
from anyio import Path as AnyioPath
|
||||
|
||||
from config import LIBRARY_BASE_PATH
|
||||
from config.config_manager import (
|
||||
DEFAULT_EXCLUDED_EXTENSIONS,
|
||||
DEFAULT_EXCLUDED_FILES,
|
||||
)
|
||||
from config.config_manager import config_manager as cm
|
||||
from exceptions.fs_exceptions import (
|
||||
RomAlreadyExistsException,
|
||||
@@ -25,8 +24,22 @@ from exceptions.fs_exceptions import (
|
||||
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom, RomFile, RomFileCategory
|
||||
from utils.archive_7zip import process_file_7z
|
||||
from utils.filesystem import COMPRESSED_FILE_EXTENSIONS, iter_files
|
||||
from utils.archives import (
|
||||
detect_mime_type,
|
||||
extract_chd_hash,
|
||||
is_chd_file,
|
||||
process_7z_file,
|
||||
read_7z_archive_files,
|
||||
read_basic_file,
|
||||
read_bz2_file,
|
||||
read_gz_file,
|
||||
read_rar_archive_files,
|
||||
read_tar_archive_files,
|
||||
read_tar_file,
|
||||
read_zip_archive_files,
|
||||
read_zip_file,
|
||||
)
|
||||
from utils.filesystem import iter_files
|
||||
from utils.hashing import crc32_to_hex
|
||||
|
||||
from .base_handler import (
|
||||
@@ -34,37 +47,13 @@ from .base_handler import (
|
||||
LANGUAGES_NAME_KEYS,
|
||||
REGIONS_BY_SHORTCODE,
|
||||
REGIONS_NAME_KEYS,
|
||||
TAG_REGEX,
|
||||
FSHandler,
|
||||
)
|
||||
|
||||
# Known compressed file MIME types
|
||||
COMPRESSED_MIME_TYPES: Final = frozenset(
|
||||
(
|
||||
"application/x-7z-compressed",
|
||||
"application/x-bzip2",
|
||||
"application/x-gzip",
|
||||
"application/x-tar",
|
||||
"application/zip",
|
||||
)
|
||||
)
|
||||
|
||||
# PICO-8 cartridges are often stored as PNG files
|
||||
PICO8_CARTRIDGE_EXTENSION = ".p8.png"
|
||||
|
||||
|
||||
# CHD (Compressed Hunks of Data) v5 format constants
|
||||
# See: https://github.com/mamedev/mame/blob/master/src/lib/util/chd.h
|
||||
CHD_SIGNATURE: Final = b"MComprHD"
|
||||
CHD_SIGNATURE_LENGTH: Final = 8
|
||||
CHD_MIN_HEADER_LENGTH: Final = 16 # Minimum to read signature and version
|
||||
CHD_V5_HEADER_LENGTH: Final = 124 # Total v5 header size
|
||||
CHD_VERSION_OFFSET: Final = 12 # Bytes offset for version field
|
||||
CHD_VERSION_LENGTH: Final = 4 # Version is a uint32
|
||||
CHD_V5_SHA1_OFFSET: Final = 84 # Combined raw+meta SHA1 offset in v5
|
||||
CHD_V5_SHA1_LENGTH: Final = 20 # SHA1 is 20 bytes
|
||||
CHD_V5_VERSION: Final = 5 # CHD v5 identifier
|
||||
|
||||
NON_HASHABLE_PLATFORMS = frozenset(
|
||||
(
|
||||
UPS.AMAZON_ALEXA,
|
||||
@@ -96,8 +85,6 @@ NON_HASHABLE_PLATFORMS = frozenset(
|
||||
)
|
||||
)
|
||||
|
||||
FILE_READ_CHUNK_SIZE = 1024 * 8
|
||||
|
||||
|
||||
class FSRom(TypedDict):
|
||||
fs_name: str
|
||||
@@ -114,165 +101,7 @@ class FileHash(TypedDict):
|
||||
crc_hash: str
|
||||
md5_hash: str
|
||||
sha1_hash: str
|
||||
|
||||
|
||||
def is_compressed_file(file_path: str) -> bool:
|
||||
mime = magic.Magic(mime=True)
|
||||
file_type = mime.from_file(file_path)
|
||||
|
||||
return file_type in COMPRESSED_MIME_TYPES or file_path.lower().endswith(
|
||||
tuple(COMPRESSED_FILE_EXTENSIONS)
|
||||
)
|
||||
|
||||
|
||||
def read_basic_file(file_path: os.PathLike[str]) -> Iterator[bytes]:
|
||||
with open(file_path, "rb") as f:
|
||||
while chunk := f.read(FILE_READ_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
|
||||
def read_zip_file(file: str | os.PathLike[str] | IO[bytes]) -> Iterator[bytes]:
|
||||
try:
|
||||
with zipfile.ZipFile(file, "r") as z:
|
||||
# Find the biggest file in the archive
|
||||
largest_file = max(z.infolist(), key=lambda x: x.file_size)
|
||||
with z.open(largest_file, "r") as f:
|
||||
while chunk := f.read(FILE_READ_CHUNK_SIZE):
|
||||
yield chunk
|
||||
except zipfile.BadZipFile:
|
||||
if isinstance(file, Path):
|
||||
for chunk in read_basic_file(file):
|
||||
yield chunk
|
||||
|
||||
|
||||
def read_tar_file(
|
||||
file_path: Path, mode: Literal["r", "r:*", "r:", "r:gz", "r:bz2", "r:xz"] = "r"
|
||||
) -> Iterator[bytes]:
|
||||
try:
|
||||
with tarfile.open(file_path, mode) as f:
|
||||
regular_files = [member for member in f.getmembers() if member.isfile()]
|
||||
|
||||
# Find the largest file among regular files only
|
||||
largest_file = max(regular_files, key=lambda x: x.size)
|
||||
with f.extractfile(largest_file) as ef: # type: ignore
|
||||
while chunk := ef.read(FILE_READ_CHUNK_SIZE):
|
||||
yield chunk
|
||||
except tarfile.ReadError:
|
||||
for chunk in read_basic_file(file_path):
|
||||
yield chunk
|
||||
|
||||
|
||||
def read_gz_file(file_path: Path) -> Iterator[bytes]:
|
||||
return read_tar_file(file_path, "r:gz")
|
||||
|
||||
|
||||
def process_7z_file(
|
||||
file_path: Path,
|
||||
fn_hash_update: Callable[[bytes | bytearray], None],
|
||||
) -> None:
|
||||
processed = process_file_7z(
|
||||
file_path=file_path,
|
||||
fn_hash_update=fn_hash_update,
|
||||
)
|
||||
if not processed:
|
||||
for chunk in read_basic_file(file_path):
|
||||
fn_hash_update(chunk)
|
||||
|
||||
|
||||
def read_bz2_file(file_path: Path) -> Iterator[bytes]:
|
||||
try:
|
||||
with bz2.BZ2File(file_path, "rb") as f:
|
||||
while chunk := f.read(FILE_READ_CHUNK_SIZE):
|
||||
yield chunk
|
||||
except EOFError:
|
||||
for chunk in read_basic_file(file_path):
|
||||
yield chunk
|
||||
|
||||
|
||||
def extract_chd_hash(file_path: Path) -> str | None:
|
||||
"""
|
||||
Extract the embedded SHA1 hash from a CHD (Compressed Hunks of Data) v5 file header.
|
||||
|
||||
Only CHD v5 files are supported, matching MAMERedump's database.
|
||||
|
||||
CHD v5 files store the combined raw+meta SHA1 hash in the header.
|
||||
This hash is what ROM databases use for CHD identification, since it includes
|
||||
metadata like CD track layouts which are essential for proper disc image
|
||||
identification.
|
||||
|
||||
For reference, check out "chd.h" in the MAME source tree.
|
||||
|
||||
---------------------------------- Why? ----------------------------------
|
||||
CHDMAN does not produce nor guarantee stable, byte-for-byte identical
|
||||
outputs for a given disc image. (Including HD images.)
|
||||
|
||||
For this reason, the CHD format embeds the original source data hash in
|
||||
its header, allowing different CHD files to be verified as equivalent
|
||||
even when their compressed representations differ.
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
Args:
|
||||
file_path: Path to the CHD file
|
||||
|
||||
Returns:
|
||||
SHA1 hash as hex string, or None if file is not a valid CHD v5 file or parsing fails
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
# Read the v5 header and extract the embedded SHA1
|
||||
header = f.read(CHD_V5_HEADER_LENGTH)
|
||||
|
||||
# Check for "MComprHD" signature
|
||||
if (
|
||||
len(header) < CHD_MIN_HEADER_LENGTH
|
||||
or header[:CHD_SIGNATURE_LENGTH] != CHD_SIGNATURE
|
||||
):
|
||||
return None
|
||||
|
||||
# Extract and verify version (big-endian uint32)
|
||||
version_end = CHD_VERSION_OFFSET + CHD_VERSION_LENGTH
|
||||
version = int.from_bytes(header[CHD_VERSION_OFFSET:version_end], "big")
|
||||
|
||||
# Only support v5 CHD files
|
||||
if version != CHD_V5_VERSION:
|
||||
return None
|
||||
|
||||
# Extract combined raw+meta SHA1 from v5 header
|
||||
sha1_end = CHD_V5_SHA1_OFFSET + CHD_V5_SHA1_LENGTH
|
||||
if len(header) < sha1_end:
|
||||
return None
|
||||
sha1_bytes = header[CHD_V5_SHA1_OFFSET:sha1_end]
|
||||
return sha1_bytes.hex()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
class CHDHashWrapper:
|
||||
"""
|
||||
Wrapper class that mimics hashlib hash objects but returns a pre-computed hash.
|
||||
|
||||
This class provides a hashlib-compatible interface for pre-computed hashes
|
||||
extracted from CHD v5 file headers. It implements the same methods and attributes
|
||||
as hashlib hash objects (digest(), hexdigest(), update(), and name).
|
||||
"""
|
||||
|
||||
def __init__(self, hash_hex: str, name: str):
|
||||
self.hash_hex = hash_hex
|
||||
self.name = name
|
||||
# Store the digest as bytes
|
||||
self._digest = bytes.fromhex(hash_hex)
|
||||
|
||||
def hexdigest(self) -> str:
|
||||
"""Return the hash as a hexadecimal string."""
|
||||
return self.hash_hex
|
||||
|
||||
def digest(self) -> bytes:
|
||||
"""Return the hash as bytes."""
|
||||
return self._digest
|
||||
|
||||
def update(self, data: bytes | bytearray) -> None:
|
||||
"""No-op update method for compatibility with hashlib interface."""
|
||||
pass
|
||||
chd_sha1_hash: str
|
||||
|
||||
|
||||
def category_matches(category: str, path_parts: list[str]):
|
||||
@@ -283,6 +112,35 @@ DEFAULT_CRC_C = 0
|
||||
DEFAULT_MD5_H_DIGEST = hashlib.md5(usedforsecurity=False).digest()
|
||||
DEFAULT_SHA1_H_DIGEST = hashlib.sha1(usedforsecurity=False).digest()
|
||||
|
||||
ARCHIVE_READERS = {
|
||||
".zip": read_zip_archive_files,
|
||||
".tar": read_tar_archive_files,
|
||||
".tar.gz": read_tar_archive_files,
|
||||
".tgz": read_tar_archive_files,
|
||||
".tar.bz2": read_tar_archive_files,
|
||||
".tbz2": read_tar_archive_files,
|
||||
".tar.xz": read_tar_archive_files,
|
||||
".txz": read_tar_archive_files,
|
||||
".7z": read_7z_archive_files,
|
||||
".rar": read_rar_archive_files,
|
||||
}
|
||||
|
||||
|
||||
def _make_file_hash(
|
||||
crc_c: int, md5_h: Any, sha1_h: Any, chd_sha1_hash: str = ""
|
||||
) -> FileHash:
|
||||
"""Build a FileHash, blanking each field whose hasher state is still the default."""
|
||||
return FileHash(
|
||||
crc_hash=crc32_to_hex(crc_c) if crc_c != DEFAULT_CRC_C else "",
|
||||
md5_hash=md5_h.hexdigest() if md5_h.digest() != DEFAULT_MD5_H_DIGEST else "",
|
||||
sha1_hash=(
|
||||
sha1_h.hexdigest() if sha1_h.digest() != DEFAULT_SHA1_H_DIGEST else ""
|
||||
),
|
||||
chd_sha1_hash=chd_sha1_hash,
|
||||
)
|
||||
|
||||
|
||||
GENERIC_TAG_REGEX = re.compile(r"\(([^)]+)\)|\[([^]]+)\]")
|
||||
VERSION_TAG_REGEX = re.compile(r"^(?:version|ver|v)[\s_-]?(.*)", re.I)
|
||||
REGION_TAG_REGEX = re.compile(r"^reg[\s|-](.*)$", re.I)
|
||||
REVISION_TAG_REGEX = re.compile(r"^rev[\s|-](.*)$", re.I)
|
||||
@@ -313,15 +171,15 @@ class FSRomsHandler(FSHandler):
|
||||
def get_roms_fs_structure(self, fs_slug: str) -> str:
|
||||
cnfg = cm.get_config()
|
||||
return (
|
||||
f"{cnfg.ROMS_FOLDER_NAME}/{fs_slug}"
|
||||
if os.path.exists(cnfg.HIGH_PRIO_STRUCTURE_PATH)
|
||||
else f"{fs_slug}/{cnfg.ROMS_FOLDER_NAME}"
|
||||
f"{fs_slug}/{cnfg.ROMS_FOLDER_NAME}"
|
||||
if cnfg.has_structure_path_b
|
||||
else f"{cnfg.ROMS_FOLDER_NAME}/{fs_slug}"
|
||||
)
|
||||
|
||||
def parse_tags(self, fs_name: str) -> ParsedTags:
|
||||
tags = [
|
||||
chunk.strip()
|
||||
for tag in (m[0] or m[1] for m in TAG_REGEX.findall(fs_name))
|
||||
for tag in (m[0] or m[1] for m in GENERIC_TAG_REGEX.findall(fs_name))
|
||||
for chunk in tag.split(",")
|
||||
]
|
||||
|
||||
@@ -379,18 +237,36 @@ class FSRomsHandler(FSHandler):
|
||||
|
||||
def exclude_multi_roms(self, roms: list[str]) -> list[str]:
|
||||
excluded_names = cm.get_config().EXCLUDED_MULTI_FILES
|
||||
filtered_files: list = []
|
||||
normalized_patterns = [
|
||||
excluded_name.lower().strip() for excluded_name in excluded_names
|
||||
]
|
||||
|
||||
kept_roms: list[str] = []
|
||||
for rom in roms:
|
||||
if rom in excluded_names:
|
||||
filtered_files.append(rom)
|
||||
normalized_rom_name = rom.strip().lower()
|
||||
if normalized_rom_name in normalized_patterns:
|
||||
continue
|
||||
|
||||
return [f for f in roms if f not in filtered_files]
|
||||
if any(
|
||||
fnmatch.fnmatch(normalized_rom_name, pattern)
|
||||
for pattern in normalized_patterns
|
||||
):
|
||||
continue
|
||||
|
||||
kept_roms.append(rom)
|
||||
|
||||
return kept_roms
|
||||
|
||||
def _build_rom_file(
|
||||
self, rom: Rom, rom_path: Path, file_name: str, file_hash: FileHash
|
||||
self,
|
||||
rom: Rom,
|
||||
rom_path: Path,
|
||||
file_name: str,
|
||||
file_hash: FileHash,
|
||||
file_size_bytes: int | None = None,
|
||||
last_modified: float | None = None,
|
||||
archive_members: list[dict[str, Any]] | None = None,
|
||||
) -> RomFile:
|
||||
# Absolute path to roms
|
||||
abs_file_path = Path(self.base_path, rom_path, file_name)
|
||||
|
||||
path_parts_lower = list(map(str.lower, rom_path.parts))
|
||||
@@ -403,17 +279,35 @@ class FSRomsHandler(FSHandler):
|
||||
None,
|
||||
)
|
||||
|
||||
audio_meta = None
|
||||
if matching_category == RomFileCategory.SOUNDTRACK:
|
||||
from utils.audio_tags import extract_audio_meta, is_allowed_audio_file
|
||||
|
||||
if is_allowed_audio_file(file_name):
|
||||
audio_meta = extract_audio_meta(str(abs_file_path))
|
||||
|
||||
return RomFile(
|
||||
rom=rom,
|
||||
rom_id=rom.id,
|
||||
file_name=file_name,
|
||||
file_path=str(rom_path),
|
||||
file_size_bytes=os.stat(abs_file_path).st_size,
|
||||
last_modified=os.path.getmtime(abs_file_path),
|
||||
file_size_bytes=(
|
||||
file_size_bytes
|
||||
if file_size_bytes is not None
|
||||
else os.stat(abs_file_path).st_size
|
||||
),
|
||||
last_modified=(
|
||||
last_modified
|
||||
if last_modified is not None
|
||||
else os.path.getmtime(abs_file_path)
|
||||
),
|
||||
category=matching_category,
|
||||
audio_meta=audio_meta,
|
||||
crc_hash=file_hash["crc_hash"],
|
||||
md5_hash=file_hash["md5_hash"],
|
||||
sha1_hash=file_hash["sha1_hash"],
|
||||
chd_sha1_hash=file_hash["chd_sha1_hash"],
|
||||
archive_members=archive_members,
|
||||
)
|
||||
|
||||
async def get_rom_files(
|
||||
@@ -433,33 +327,58 @@ class FSRomsHandler(FSHandler):
|
||||
rom.platform_slug not in NON_HASHABLE_PLATFORMS and calculate_hashes
|
||||
)
|
||||
|
||||
excluded_file_names = cm.get_config().EXCLUDED_MULTI_PARTS_FILES
|
||||
excluded_file_exts = cm.get_config().EXCLUDED_MULTI_PARTS_EXT
|
||||
cnfg = cm.get_config()
|
||||
excluded_file_names = cnfg.EXCLUDED_MULTI_PARTS_FILES
|
||||
excluded_file_exts = cnfg.EXCLUDED_MULTI_PARTS_EXT
|
||||
|
||||
rom_crc_c = 0
|
||||
rom_md5_h = hashlib.md5(usedforsecurity=False) if calculate_hashes else None
|
||||
rom_sha1_h = hashlib.sha1(usedforsecurity=False) if calculate_hashes else None
|
||||
rom_ra_h = ""
|
||||
|
||||
rom_dir = Path(abs_fs_path, rom.fs_name)
|
||||
rom_ext = f".{rom.fs_extension.lower()}" if rom.fs_extension else ""
|
||||
|
||||
# Check if rom is a multi-part rom
|
||||
if await AnyioPath(f"{abs_fs_path}/{rom.fs_name}").is_dir():
|
||||
# Calculate the RA hash if the platform has a slug that matches a known RA slug
|
||||
if calculate_hashes:
|
||||
ra_platform = meta_ra_handler.get_platform(rom.platform_slug)
|
||||
if ra_platform and ra_platform["ra_id"]:
|
||||
# RAHasher can't process CHD files via the /* wildcard and instead expects
|
||||
# track files (bin/cue/etc.). For CHD-only folders, find the largest
|
||||
# CHD and pass it directly, matching single-file CHD behaviour.
|
||||
|
||||
def _largest_chd_file() -> Path | None:
|
||||
chds = [f for f in rom_dir.iterdir() if is_chd_file(f)]
|
||||
sorted_chds = sorted(
|
||||
chds, key=lambda f: f.stat().st_size, reverse=True
|
||||
)
|
||||
return sorted_chds[0] if sorted_chds else None
|
||||
|
||||
chd_file = await asyncio.to_thread(_largest_chd_file)
|
||||
ra_path = (
|
||||
str(chd_file)
|
||||
if chd_file and chd_file.is_file()
|
||||
else f"{abs_fs_path}/{rom.fs_name}/*"
|
||||
)
|
||||
rom_ra_h = await RAHasherService().calculate_hash(
|
||||
ra_platform,
|
||||
f"{abs_fs_path}/{rom.fs_name}/*",
|
||||
ra_path,
|
||||
)
|
||||
|
||||
for f_path, file_name in iter_files(
|
||||
f"{abs_fs_path}/{rom.fs_name}", recursive=True
|
||||
):
|
||||
# Check if file is excluded
|
||||
ext = self.parse_file_extension(file_name)
|
||||
if ext in excluded_file_exts:
|
||||
# Check if file is excluded by extension.
|
||||
f_rom_dir = Path(f_path, rom.fs_name)
|
||||
file_name_lower = file_name.lower()
|
||||
if any(
|
||||
file_name_lower.endswith("." + ext) for ext in excluded_file_exts
|
||||
):
|
||||
continue
|
||||
|
||||
# Check if the file name matches a pattern in the excluded list.
|
||||
if any(
|
||||
file_name == exc_name or fnmatch.fnmatch(file_name, exc_name)
|
||||
for exc_name in excluded_file_names
|
||||
@@ -474,7 +393,8 @@ class FSRomsHandler(FSHandler):
|
||||
if is_top_level:
|
||||
# Include this file in the main ROM hash calculation
|
||||
crc_c, rom_crc_c, md5_h, rom_md5_h, sha1_h, rom_sha1_h = (
|
||||
self._calculate_rom_hashes(
|
||||
await asyncio.to_thread(
|
||||
self._calculate_rom_hashes,
|
||||
Path(f_path, file_name),
|
||||
rom_crc_c,
|
||||
rom_md5_h,
|
||||
@@ -483,7 +403,8 @@ class FSRomsHandler(FSHandler):
|
||||
)
|
||||
else:
|
||||
# Calculate individual file hash only
|
||||
crc_c, _, md5_h, _, sha1_h, _ = self._calculate_rom_hashes(
|
||||
crc_c, _, md5_h, _, sha1_h, _ = await asyncio.to_thread(
|
||||
self._calculate_rom_hashes,
|
||||
Path(f_path, file_name),
|
||||
0,
|
||||
hashlib.md5(usedforsecurity=False),
|
||||
@@ -494,16 +415,13 @@ class FSRomsHandler(FSHandler):
|
||||
md5_h = hashlib.md5(usedforsecurity=False)
|
||||
sha1_h = hashlib.sha1(usedforsecurity=False)
|
||||
|
||||
file_hash = FileHash(
|
||||
crc_hash=crc32_to_hex(crc_c) if crc_c != DEFAULT_CRC_C else "",
|
||||
md5_hash=(
|
||||
md5_h.hexdigest()
|
||||
if md5_h.digest() != DEFAULT_MD5_H_DIGEST
|
||||
else ""
|
||||
),
|
||||
sha1_hash=(
|
||||
sha1_h.hexdigest()
|
||||
if sha1_h.digest() != DEFAULT_SHA1_H_DIGEST
|
||||
file_hash = _make_file_hash(
|
||||
crc_c,
|
||||
md5_h,
|
||||
sha1_h,
|
||||
chd_sha1_hash=(
|
||||
extract_chd_hash(f_rom_dir)
|
||||
if is_chd_file(f_rom_dir)
|
||||
else ""
|
||||
),
|
||||
)
|
||||
@@ -512,6 +430,7 @@ class FSRomsHandler(FSHandler):
|
||||
crc_hash="",
|
||||
md5_hash="",
|
||||
sha1_hash="",
|
||||
chd_sha1_hash="",
|
||||
)
|
||||
|
||||
rom_files.append(
|
||||
@@ -522,11 +441,101 @@ class FSRomsHandler(FSHandler):
|
||||
file_hash=file_hash,
|
||||
)
|
||||
)
|
||||
elif hashable_platform and rom_ext in ARCHIVE_READERS:
|
||||
# Multi-file archive: compute a composite hash across all
|
||||
# internal entries (in ASCII path order) for hash-database
|
||||
# matching, while still emitting a single RomFile for the
|
||||
# archive file itself. Per-member hashes are stored on that
|
||||
# RomFile in `archive_members` so consumers can identify each
|
||||
# internal file without us inventing RomFile rows whose
|
||||
# full_path would point inside the archive and break downloads.
|
||||
assert rom_md5_h is not None and rom_sha1_h is not None
|
||||
|
||||
def _hash_archive_entries(
|
||||
crc: int, md5_h: Any, sha1_h: Any
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
members: list[dict[str, Any]] = []
|
||||
for name, size, chunks in ARCHIVE_READERS[rom_ext](
|
||||
rom_dir,
|
||||
DEFAULT_EXCLUDED_FILES,
|
||||
DEFAULT_EXCLUDED_EXTENSIONS,
|
||||
):
|
||||
member_crc = 0
|
||||
member_md5 = hashlib.md5(usedforsecurity=False)
|
||||
member_sha1 = hashlib.sha1(usedforsecurity=False)
|
||||
for chunk in chunks:
|
||||
crc = binascii.crc32(chunk, crc)
|
||||
md5_h.update(chunk)
|
||||
sha1_h.update(chunk)
|
||||
member_crc = binascii.crc32(chunk, member_crc)
|
||||
member_md5.update(chunk)
|
||||
member_sha1.update(chunk)
|
||||
members.append(
|
||||
{
|
||||
"name": name,
|
||||
"size": size,
|
||||
"crc_hash": crc32_to_hex(member_crc),
|
||||
"md5_hash": member_md5.hexdigest(),
|
||||
"sha1_hash": member_sha1.hexdigest(),
|
||||
}
|
||||
)
|
||||
return members, crc
|
||||
|
||||
members, rom_crc_c = await asyncio.to_thread(
|
||||
_hash_archive_entries, rom_crc_c, rom_md5_h, rom_sha1_h
|
||||
)
|
||||
|
||||
if members:
|
||||
if calculate_hashes:
|
||||
ra_platform = meta_ra_handler.get_platform(rom.platform_slug)
|
||||
if ra_platform and ra_platform["ra_id"]:
|
||||
rom_ra_h = await RAHasherService().calculate_hash(
|
||||
ra_platform,
|
||||
f"{abs_fs_path}/{rom.fs_name}",
|
||||
)
|
||||
|
||||
rom_files.append(
|
||||
self._build_rom_file(
|
||||
rom=rom,
|
||||
rom_path=Path(rel_roms_path),
|
||||
file_name=rom.fs_name,
|
||||
file_hash=_make_file_hash(rom_crc_c, rom_md5_h, rom_sha1_h),
|
||||
archive_members=members,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Empty, malformed, or all-excluded archive: hash the archive
|
||||
# file's raw bytes. We avoid `_calculate_rom_hashes` here because
|
||||
# it would decompress based on extension and end up hashing the
|
||||
# largest internal member, not the archive itself — and would
|
||||
# crash on an empty zip. `archive_members` stays None.
|
||||
def _hash_raw_archive(crc: int) -> int:
|
||||
for chunk in read_basic_file(rom_dir):
|
||||
crc = binascii.crc32(chunk, crc)
|
||||
if rom_md5_h:
|
||||
rom_md5_h.update(chunk)
|
||||
if rom_sha1_h:
|
||||
rom_sha1_h.update(chunk)
|
||||
return crc
|
||||
|
||||
rom_crc_c = await asyncio.to_thread(_hash_raw_archive, rom_crc_c)
|
||||
rom_files.append(
|
||||
self._build_rom_file(
|
||||
rom=rom,
|
||||
rom_path=Path(rel_roms_path),
|
||||
file_name=rom.fs_name,
|
||||
file_hash=_make_file_hash(rom_crc_c, rom_md5_h, rom_sha1_h),
|
||||
)
|
||||
)
|
||||
elif hashable_platform:
|
||||
try:
|
||||
crc_c, rom_crc_c, md5_h, rom_md5_h, sha1_h, rom_sha1_h = (
|
||||
self._calculate_rom_hashes(
|
||||
Path(abs_fs_path, rom.fs_name), rom_crc_c, rom_md5_h, rom_sha1_h
|
||||
await asyncio.to_thread(
|
||||
self._calculate_rom_hashes,
|
||||
Path(abs_fs_path, rom.fs_name),
|
||||
rom_crc_c,
|
||||
rom_md5_h,
|
||||
rom_sha1_h,
|
||||
)
|
||||
)
|
||||
except zlib.error:
|
||||
@@ -543,15 +552,12 @@ class FSRomsHandler(FSHandler):
|
||||
f"{abs_fs_path}/{rom.fs_name}",
|
||||
)
|
||||
|
||||
file_hash = FileHash(
|
||||
crc_hash=crc32_to_hex(crc_c) if crc_c != DEFAULT_CRC_C else "",
|
||||
md5_hash=(
|
||||
md5_h.hexdigest() if md5_h.digest() != DEFAULT_MD5_H_DIGEST else ""
|
||||
),
|
||||
sha1_hash=(
|
||||
sha1_h.hexdigest()
|
||||
if sha1_h.digest() != DEFAULT_SHA1_H_DIGEST
|
||||
else ""
|
||||
file_hash = _make_file_hash(
|
||||
crc_c,
|
||||
md5_h,
|
||||
sha1_h,
|
||||
chd_sha1_hash=(
|
||||
extract_chd_hash(rom_dir) if is_chd_file(rom_dir) else ""
|
||||
),
|
||||
)
|
||||
rom_files.append(
|
||||
@@ -567,6 +573,7 @@ class FSRomsHandler(FSHandler):
|
||||
crc_hash="",
|
||||
md5_hash="",
|
||||
sha1_hash="",
|
||||
chd_sha1_hash="",
|
||||
)
|
||||
rom_files.append(
|
||||
self._build_rom_file(
|
||||
@@ -601,9 +608,8 @@ class FSRomsHandler(FSHandler):
|
||||
rom_sha1_h: Any,
|
||||
) -> tuple[int, int, Any, Any, Any, Any]:
|
||||
extension = Path(file_path).suffix.lower()
|
||||
mime = magic.Magic(mime=True)
|
||||
try:
|
||||
file_type = mime.from_file(file_path)
|
||||
file_type = detect_mime_type(file_path)
|
||||
|
||||
crc_c = 0
|
||||
md5_h = hashlib.md5(usedforsecurity=False)
|
||||
@@ -643,17 +649,6 @@ class FSRomsHandler(FSHandler):
|
||||
for chunk in read_bz2_file(file_path):
|
||||
update_hashes(chunk)
|
||||
|
||||
elif extension == ".chd" or file_type == "application/x-mame-chd":
|
||||
chd_hash = extract_chd_hash(file_path)
|
||||
if chd_hash:
|
||||
sha1_h = cast(Any, CHDHashWrapper(chd_hash, name="sha1"))
|
||||
rom_sha1_h = cast(Any, CHDHashWrapper(chd_hash, name="sha1"))
|
||||
else:
|
||||
# Not a valid v5 CHD, treat as basic file
|
||||
# This ensures CRC32 and MD5 are still calculated for non-v5 CHDs
|
||||
for chunk in read_basic_file(file_path):
|
||||
update_hashes(chunk)
|
||||
|
||||
else:
|
||||
for chunk in read_basic_file(file_path):
|
||||
update_hashes(chunk)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import functools
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -110,3 +111,13 @@ class FSSyncHandler(FSHandler):
|
||||
f"Path {full_path} is outside the sync base directory"
|
||||
) from e
|
||||
path.unlink()
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_fs_sync_handler() -> FSSyncHandler:
|
||||
"""Lazily instantiate the sync folder handler on first use.
|
||||
|
||||
Deferred so that startup doesn't fail when sync is unconfigured or its
|
||||
base path is not writable.
|
||||
"""
|
||||
return FSSyncHandler()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user