Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Support for retro achievements #1142

Draft
wants to merge 23 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions backend/alembic/versions/0028_add_retro_achievements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""add retro achievements data

Revision ID: 0028_add_retro_achievements
Revises: 0027_platforms_data
Create Date: 2024-08-31 18:48:49.772416

"""

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql

# revision identifiers, used by Alembic.
revision = "0028_add_retro_achievements"
down_revision = "0027_platforms_data"
branch_labels = None
depends_on = None


def upgrade() -> None:
with op.batch_alter_table("rom_user", schema=None) as batch_op:
batch_op.add_column(sa.Column("ra_metadata", mysql.JSON(), nullable=True))

with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.add_column(sa.Column("ra_id", sa.Integer(), nullable=True))

with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.add_column(
sa.Column("ra_api_key", sa.String(length=100), nullable=True)
)
batch_op.add_column(
sa.Column("ra_username", sa.String(length=100), nullable=True)
)

with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.add_column(sa.Column("ra_id", sa.Integer(), nullable=True))


def downgrade() -> None:
with op.batch_alter_table("rom_user", schema=None) as batch_op:
batch_op.drop_column("ra_metadata")

with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.drop_column("ra_id")

with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.drop_column("ra_api_key")
batch_op.drop_column("ra_username")

with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.drop_column("ra_id")
4 changes: 4 additions & 0 deletions backend/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ def str_to_bool(value: str) -> bool:
# STEAMGRIDDB
STEAMGRIDDB_API_KEY: Final = os.environ.get("STEAMGRIDDB_API_KEY", "")

# RETROACHIEVEMENTS
RETROACHIEVEMENTS_USERNAME: Final = os.environ.get("RETROACHIEVEMENTS_USERNAME", "")
RETROACHIEVEMENTS_API_KEY: Final = os.environ.get("RETROACHIEVEMENTS_API_KEY", "")

# MOBYGAMES
MOBYGAMES_API_KEY: Final = os.environ.get("MOBYGAMES_API_KEY", "")

Expand Down
4 changes: 4 additions & 0 deletions backend/endpoints/forms/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ def __init__(
role: str | None = None,
enabled: bool | None = None,
avatar: UploadFile | None = None,
ra_username: str | None = None,
ra_api_key: str | None = None,
):
self.username = username
self.password = password
self.role = role
self.enabled = enabled
self.avatar = avatar
self.ra_username = ra_username
self.ra_api_key = ra_api_key


class OAuth2RequestForm:
Expand Down
2 changes: 2 additions & 0 deletions backend/endpoints/heartbeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from handler.filesystem import fs_platform_handler
from handler.metadata.igdb_handler import IGDB_API_ENABLED
from handler.metadata.moby_handler import MOBY_API_ENABLED
from handler.metadata.ra_handler import RETROACHIEVEMENTS_API_ENABLED
from handler.metadata.sgdb_handler import STEAMGRIDDB_API_ENABLED
from utils import get_version
from utils.router import APIRouter
Expand All @@ -37,6 +38,7 @@ def heartbeat() -> HeartbeatResponse:
"IGDB_API_ENABLED": IGDB_API_ENABLED,
"MOBY_API_ENABLED": MOBY_API_ENABLED,
"STEAMGRIDDB_ENABLED": STEAMGRIDDB_API_ENABLED,
"RETROACHIEVEMENTS_ENABLED": RETROACHIEVEMENTS_API_ENABLED,
},
"FS_PLATFORMS": fs_platform_handler.get_platforms(),
"WATCHER": {
Expand Down
1 change: 1 addition & 0 deletions backend/endpoints/responses/heartbeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class MetadataSourcesDict(TypedDict):
IGDB_API_ENABLED: bool
MOBY_API_ENABLED: bool
STEAMGRIDDB_ENABLED: bool
RETROACHIEVEMENTS_ENABLED: bool


class EmulationDict(TypedDict):
Expand Down
2 changes: 2 additions & 0 deletions backend/endpoints/responses/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class UserSchema(BaseModel):
avatar_path: str
last_login: datetime | None
last_active: datetime | None
ra_api_key: str | None = None
ra_username: str | None = None

created_at: datetime
updated_at: datetime
Expand Down
65 changes: 65 additions & 0 deletions backend/endpoints/responses/retroachievements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from __future__ import annotations

from typing import Any

from fastapi import Request
from pydantic import BaseModel


class Achievements(BaseModel):
ID: int
NumAwarded: int
NumAwardedHardcore: int
Title: str
Description: str
Points: int
TrueRatio: int
Author: str
DateModified: str
DateCreated: str
DateEarned: str | None = None
BadgeName: str
DisplayOrder: int
MemAddr: str
type: Any


class RetroAchievementsGameSchema(BaseModel):
ID: int
Title: str
ConsoleID: int
ForumTopicID: int
Flags: int
ImageIcon: str
ImageTitle: str
ImageIngame: str
ImageBoxArt: str
Publisher: str
Developer: str
Genre: str
Released: str
IsFinal: int
RichPresencePatch: str
GuideURL: str | None = None
ConsoleName: str
NumDistinctPlayers: int
ParentGameID: str | None = None
NumAchievements: int
Achievements: dict[str, Achievements]
NumAwardedToUser: int
NumAwardedToUserHardcore: int
NumDistinctPlayersCasual: int
NumDistinctPlayersHardcore: int
ReleasedAtGranularity: str
UserCompletion: str
UserCompletionHardcore: str
HighestAwardKind: str | None = None
HighestAwardDate: str | None = None

@classmethod
def from_orm_with_request(
cls, db_rom: RetroAchievementsGameSchema, request: Request
) -> RetroAchievementsGameSchema:
rom = cls.model_validate(db_rom)

return rom
1 change: 1 addition & 0 deletions backend/endpoints/responses/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class RomSchema(BaseModel):
igdb_id: int | None
sgdb_id: int | None
moby_id: int | None
ra_id: int | None

platform_id: int
platform_slug: str
Expand Down
42 changes: 42 additions & 0 deletions backend/endpoints/retroachievements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import yarl
from decorators.auth import protected_route
from endpoints.responses.retroachievements import RetroAchievementsGameSchema
from exceptions.endpoint_exceptions import RomNotFoundInRetroAchievementsException
from fastapi import Request
from handler.auth.base_handler import Scope
from handler.metadata import meta_ra_handler
from utils.router import APIRouter

router = APIRouter()


@protected_route(router.get, "/retroachievements/{id}", [Scope.ROMS_READ])
async def get_rom_retroachievements(
request: Request, id: int
) -> RetroAchievementsGameSchema:
"""Get rom endpoint

Args:
request (Request): Fastapi Request object
id (int): Rom internal id

Returns:
RetroAchievementsGameSchema: User and Game info from retro achivements
"""

url = yarl.URL(
"https://retroachievements.org/API/API_GetGameInfoAndUserProgress.php"
).with_query(
g=[id],
a=["1"],
u=[request.user.ra_username],
z=[request.user.ra_username],
y=[request.user.ra_api_key],
)

game_with_details = await meta_ra_handler._request(str(url))

if not game_with_details:
raise RomNotFoundInRetroAchievementsException(id)

return RetroAchievementsGameSchema.model_validate(game_with_details)
4 changes: 1 addition & 3 deletions backend/endpoints/sockets/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ async def scan_platforms(
roms_ids = []

if not metadata_sources:
metadata_sources = ["igdb", "moby"]
metadata_sources = ["igdb", "moby", "retro_achievements"]

sm = _get_socket_manager()

Expand Down Expand Up @@ -425,9 +425,7 @@ async def scan_handler(_sid: str, options: dict):
Args:
options (dict): Socket options
"""

log.info(emoji.emojize(":magnifying_glass_tilted_right: Scanning "))

platform_ids = options.get("platforms", [])
scan_type = ScanType[options.get("type", "quick").upper()]
roms_ids = options.get("roms_ids", [])
Expand Down
6 changes: 6 additions & 0 deletions backend/endpoints/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ async def update_user(
if form_data.enabled is not None and request.user.id != id:
cleaned_data["enabled"] = form_data.enabled # type: ignore[assignment]

if form_data.ra_username:
cleaned_data["ra_username"] = form_data.ra_username # type: ignore[assignment]

if form_data.ra_api_key:
cleaned_data["ra_api_key"] = form_data.ra_api_key # type: ignore[assignment]

if form_data.avatar is not None:
user_avatar_path = fs_asset_handler.build_avatar_path(user=db_user)
file_location = f"{user_avatar_path}/{form_data.avatar.filename}"
Expand Down
11 changes: 11 additions & 0 deletions backend/exceptions/endpoint_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,14 @@ def __init__(self, name):

def __repr__(self) -> str:
return self.message


class RomNotFoundInRetroAchievementsException(Exception):
def __init__(self, id):
self.message = f"Rom with id '{id}' does not exist on RetroAchievements"
super().__init__(self.message)
log.critical(self.message)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=self.message)

def __repr__(self) -> str:
return self.message
2 changes: 2 additions & 0 deletions backend/handler/metadata/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from .igdb_handler import IGDBBaseHandler
from .moby_handler import MobyGamesHandler
from .ra_handler import RetroAchievementsHandler
from .sgdb_handler import SGDBBaseHandler

meta_igdb_handler = IGDBBaseHandler()
meta_moby_handler = MobyGamesHandler()
meta_sgdb_handler = SGDBBaseHandler()
meta_ra_handler = RetroAchievementsHandler()
Loading