Skip to content

Commit 8dc87d6

Browse files
committed
Merge branch 'master' into flashpoint-metadata-handler
2 parents 382c4e4 + 9649860 commit 8dc87d6

536 files changed

Lines changed: 14505 additions & 5227 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.devcontainer/dev.env

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
AUTHENTIK_BOOTSTRAP_PASSWORD=password
2+
# Authentik defaults
3+
AUTHENTIK_SECRET_KEY=secret-key-default
4+
# Database defaults for development
5+
DB_HOST=romm-db-dev
6+
DB_NAME=romm
7+
DB_PASSWD=romm
8+
DB_ROOT_PASSWD=rootpassword
9+
DB_USER=romm
10+
DEV_HTTPS=false
11+
# Development environment overrides for Dev Containers
12+
DEV_MODE=true
13+
# Pick a host port that doesn't conflict with local services
14+
DEV_PORT=5001
15+
# Logging
16+
LOGLEVEL=DEBUG
17+
# Redis
18+
REDIS_PORT=6379

.devcontainer/devcontainer.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
22
"name": "ROMM Development",
3-
"dockerComposeFile": "docker-compose.yml",
3+
"dockerComposeFile": ["../docker-compose.yml"],
44
"service": "romm-dev",
55
"workspaceFolder": "/app",
66
"shutdownAction": "stopCompose",
7-
"forwardPorts": [5000, 3000, 3306, 6379],
7+
"forwardPorts": [8443, 3000, 3306, 5000, 6379],
88
"portsAttributes": {
9-
"5000": {
10-
"label": "Backend API",
9+
"8443": {
10+
"label": "HTTPS Dev Server",
1111
"onAutoForward": "notify"
1212
},
1313
"3000": {
@@ -18,6 +18,10 @@
1818
"label": "MariaDB",
1919
"onAutoForward": "silent"
2020
},
21+
"5000": {
22+
"label": "Backend API",
23+
"onAutoForward": "notify"
24+
},
2125
"6379": {
2226
"label": "Valkey/Redis",
2327
"onAutoForward": "silent"
@@ -29,7 +33,6 @@
2933
"ms-python.python",
3034
"ms-python.pylint",
3135
"ms-python.black-formatter",
32-
"bradlc.vscode-tailwindcss",
3336
"vue.volar",
3437
"ms-vscode.vscode-typescript-next"
3538
],

.trunk/configs/.isort.cfg

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,13 @@
11
[settings]
22
profile=black
3+
known_first_party =
4+
adapters
5+
config
6+
decorators
7+
endpoints
8+
exceptions
9+
handler
10+
logger
11+
models
12+
tasks
13+
utils

.trunk/configs/.prettierrc.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
plugins: ["@trivago/prettier-plugin-sort-imports"]
2+
importOrder:
3+
- "<THIRD_PARTY_MODULES>"
4+
- "^@/(.*)$"
5+
- "^[./]"

.trunk/trunk.yaml

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
33
version: 0.1
44
cli:
5-
version: 1.24.0
5+
version: 1.25.0
66
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
77
plugins:
88
sources:
99
- id: trunk
10-
ref: v1.7.1
10+
ref: v1.7.2
1111
uri: https://github.com/trunk-io/plugins
1212
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
1313
runtimes:
@@ -20,26 +20,28 @@ lint:
2020
disabled:
2121
- pyright
2222
enabled:
23-
- hadolint@2.12.1-beta
23+
- dotenv-linter@3.3.0
24+
- hadolint@2.13.1
2425
- markdownlint@0.45.0
25-
- eslint@9.31.0
26+
- eslint@9.34.0
2627
- actionlint@1.7.7
2728
- bandit@1.8.6
2829
- black@25.1.0
29-
- checkov@3.2.451
30+
- checkov@3.2.469
3031
- git-diff-check
3132
- isort@6.0.1
32-
- mypy@1.17.0
33-
- osv-scanner@2.0.3
34-
- oxipng@9.1.5
35-
- prettier@3.6.2
36-
- ruff@0.12.4
37-
- shellcheck@0.10.0
33+
- mypy@1.17.1
34+
- osv-scanner@2.2.2
35+
- prettier@3.6.2:
36+
packages:
37+
- "@trivago/prettier-plugin-sort-imports@5.2.2"
38+
- "@vue/compiler-sfc@3.5.21"
39+
- ruff@0.12.11
40+
- shellcheck@0.11.0
3841
- shfmt@3.6.0
39-
- svgo@4.0.0
40-
- taplo@0.9.3
41-
- trivy@0.64.1
42-
- trufflehog@3.90.1
42+
- taplo@0.10.0
43+
- trivy@0.66.0
44+
- trufflehog@3.90.5
4345
- yamllint@1.37.1
4446
ignore:
4547
- linters: [ALL]

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<img src=".github/resources/logotipo.png" height="45px" width="auto" alt="romm logotype">
77

88
<h3 style="font-size: 25px;">
9-
A beautiful, powerful, self-hosted rom manager.
9+
A beautiful, powerful, self-hosted ROM manager.
1010
</h3>
1111
<br/>
1212

backend/adapters/services/igdb.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import asyncio
2+
import http
3+
import json
4+
from collections.abc import Sequence
5+
from functools import partial
6+
from typing import TYPE_CHECKING
7+
8+
import aiohttp
9+
import yarl
10+
from aiohttp.client import ClientTimeout
11+
from fastapi import HTTPException, status
12+
from unidecode import unidecode
13+
14+
from adapters.services.igdb_types import Game
15+
from config import IGDB_CLIENT_ID
16+
from logger.logger import log
17+
from utils.context import ctx_aiohttp_session
18+
19+
if TYPE_CHECKING:
20+
from handler.metadata.igdb_handler import TwitchAuth
21+
22+
23+
class IGDBInvalidCredentialsException(Exception):
24+
"""Exception raised when IGDB credentials are invalid."""
25+
26+
27+
async def auth_middleware(
28+
req: aiohttp.ClientRequest,
29+
handler: aiohttp.ClientHandlerType,
30+
*,
31+
twitch_auth: "TwitchAuth",
32+
) -> aiohttp.ClientResponse:
33+
"""IGDB API authentication mechanism.
34+
35+
Reference: https://api-docs.igdb.com/#authentication
36+
"""
37+
token = await twitch_auth.get_oauth_token()
38+
if not token:
39+
raise IGDBInvalidCredentialsException()
40+
req.headers.update(
41+
{
42+
"Accept": "application/json",
43+
"Authorization": f"Bearer {token}",
44+
"Client-ID": IGDB_CLIENT_ID,
45+
}
46+
)
47+
return await handler(req)
48+
49+
50+
class IGDBService:
51+
"""Service to interact with the IGDB API.
52+
53+
Reference: https://api-docs.igdb.com/
54+
"""
55+
56+
def __init__(
57+
self,
58+
twitch_auth: "TwitchAuth",
59+
base_url: str | None = None,
60+
) -> None:
61+
self.url = yarl.URL(base_url or "https://api.igdb.com/v4")
62+
self.twitch_auth = twitch_auth
63+
self.auth_middleware = partial(auth_middleware, twitch_auth=self.twitch_auth)
64+
65+
async def _request(
66+
self,
67+
url: str,
68+
search_term: str | None = None,
69+
fields: Sequence[str] | None = None,
70+
where: str | None = None,
71+
limit: int | None = None,
72+
request_timeout: int = 120,
73+
) -> list:
74+
aiohttp_session = ctx_aiohttp_session.get()
75+
76+
content = ""
77+
if search_term:
78+
content += f'search "{unidecode(search_term)}"; '
79+
if fields:
80+
content += f"fields {','.join(fields)}; "
81+
if where:
82+
content += f"where {where}; "
83+
if limit is not None:
84+
content += f"limit {limit}; "
85+
content = content.strip()
86+
87+
log.debug(
88+
"API request: URL=%s, Content=%s, Timeout=%s",
89+
url,
90+
content,
91+
request_timeout,
92+
)
93+
94+
try:
95+
res = await aiohttp_session.post(
96+
url,
97+
data=content,
98+
middlewares=(self.auth_middleware,),
99+
timeout=ClientTimeout(total=request_timeout),
100+
)
101+
res.raise_for_status()
102+
return await res.json()
103+
except aiohttp.ServerTimeoutError:
104+
# Retry the request once if it times out
105+
log.debug("Request to URL=%s timed out. Retrying...", url)
106+
except IGDBInvalidCredentialsException as exc:
107+
log.critical("IGDB Error: Invalid IGDB_CLIENT_ID or IGDB_CLIENT_SECRET")
108+
raise HTTPException(
109+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
110+
detail="Invalid IGDB credentials",
111+
) from exc
112+
except aiohttp.ClientConnectionError as exc:
113+
log.critical("Connection error: can't connect to IGDB", exc_info=True)
114+
raise HTTPException(
115+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
116+
detail="Can't connect to IGDB, check your internet connection",
117+
) from exc
118+
except aiohttp.ClientResponseError as exc:
119+
if exc.status == http.HTTPStatus.UNAUTHORIZED:
120+
# Refresh the token and retry if the auth token is invalid
121+
log.info("Twitch token invalid: fetching a new one...")
122+
await self.twitch_auth._update_twitch_token()
123+
elif exc.status == http.HTTPStatus.TOO_MANY_REQUESTS:
124+
# Retry after 2 seconds if rate limit hit
125+
await asyncio.sleep(2)
126+
else:
127+
# Log the error and return an empty list if the request fails with a different code
128+
log.error(exc)
129+
return []
130+
except json.JSONDecodeError as exc:
131+
log.error("Error decoding JSON response from IGDB: %s", exc)
132+
return []
133+
134+
# Retry the request once if it times out
135+
try:
136+
log.debug(
137+
"API request: URL=%s, Content=%s, Timeout=%s",
138+
url,
139+
content,
140+
request_timeout,
141+
)
142+
res = await aiohttp_session.post(
143+
url,
144+
data=content,
145+
middlewares=(self.auth_middleware,),
146+
timeout=ClientTimeout(total=request_timeout),
147+
)
148+
res.raise_for_status()
149+
return await res.json()
150+
except (aiohttp.ClientResponseError, aiohttp.ServerTimeoutError) as exc:
151+
if (
152+
isinstance(exc, aiohttp.ClientResponseError)
153+
and exc.status == http.HTTPStatus.UNAUTHORIZED
154+
):
155+
return []
156+
157+
log.error(exc)
158+
return []
159+
except json.JSONDecodeError as exc:
160+
log.error("Error decoding JSON response from IGDB: %s", exc)
161+
return []
162+
163+
async def list_games(
164+
self,
165+
*,
166+
search_term: str | None = None,
167+
fields: Sequence[str] | None = None,
168+
where: str | None = None,
169+
limit: int | None = None,
170+
) -> list[Game]:
171+
"""Retrieve games.
172+
173+
Reference: https://api-docs.igdb.com/#game
174+
"""
175+
url = self.url.joinpath("games")
176+
return await self._request(
177+
str(url),
178+
search_term=search_term,
179+
fields=fields,
180+
where=where,
181+
limit=limit,
182+
)
183+
184+
async def search(
185+
self,
186+
*,
187+
search_term: str | None = None,
188+
fields: Sequence[str] | None = None,
189+
where: str | None = None,
190+
limit: int | None = None,
191+
) -> list[dict]:
192+
"""Search for different entities.
193+
194+
Reference: https://api-docs.igdb.com/#search
195+
"""
196+
url = self.url.joinpath("search")
197+
return await self._request(
198+
str(url),
199+
search_term=search_term,
200+
fields=fields,
201+
where=where,
202+
limit=limit,
203+
)

backend/adapters/services/igdb_types.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
from __future__ import annotations
22

33
import enum
4-
from typing import Literal, NewType, TypedDict
4+
from typing import Literal, NewType, TypedDict, TypeGuard
55

66
# https://api-docs.igdb.com/#expander
77
type ExpandableField[T] = T | int
88

9+
10+
def mark_expanded[T](value: ExpandableField[T]) -> TypeGuard[T]:
11+
"""Type guard to narrow an `ExpandableField` to its expanded type."""
12+
return True
13+
14+
15+
def mark_list_expanded[T](value: list[ExpandableField[T]]) -> TypeGuard[list[T]]:
16+
"""Type guard to narrow an `ExpandableField` list to its expanded type."""
17+
return True
18+
19+
920
# TODO: Add missing structures until all are implemented.
1021
UnimplementedEntity = NewType("UnimplementedEntity", dict)
1122
AgeRatingContentDescription = UnimplementedEntity
@@ -95,7 +106,7 @@ class AgeRating(IGDBEntity, total=False):
95106
category: AgeRatingCategory
96107
checksum: str # uuid
97108
content_descriptions: list[ExpandableField[AgeRatingContentDescription]]
98-
rating: AgeRatingRating
109+
rating_category: AgeRatingRating
99110
rating_cover_url: str
100111
synopsis: str
101112

backend/adapters/services/mobygames.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66

77
import aiohttp
88
import yarl
9-
from adapters.services.mobygames_types import MobyGame, MobyGameBrief, MobyOutputFormat
109
from aiohttp.client import ClientTimeout
11-
from config import MOBYGAMES_API_KEY
1210
from fastapi import HTTPException, status
11+
12+
from adapters.services.mobygames_types import MobyGame, MobyGameBrief, MobyOutputFormat
13+
from config import MOBYGAMES_API_KEY
1314
from logger.logger import log
1415
from utils.context import ctx_aiohttp_session
1516

0 commit comments

Comments
 (0)