mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 00:27:41 +01:00
docstrings added to endpoints + organized imports + formatted code
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
import secrets
|
||||
from dotenv import load_dotenv
|
||||
from typing import Final
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# UVICORN
|
||||
@@ -26,14 +27,14 @@ DEFAULT_URL_COVER_L: Final = (
|
||||
"https://images.igdb.com/igdb/image/upload/t_cover_big/nocover.png"
|
||||
)
|
||||
DEFAULT_PATH_COVER_L: Final = "default/default/cover/big.png"
|
||||
DEFAULT_WIDTH_COVER_L: Final = 264 # Width of big cover of IGDB
|
||||
DEFAULT_HEIGHT_COVER_L: Final = 352 # Height of big cover of IGDB
|
||||
DEFAULT_WIDTH_COVER_L: Final = 264 # Width of big cover of IGDB
|
||||
DEFAULT_HEIGHT_COVER_L: Final = 352 # Height of big cover of IGDB
|
||||
DEFAULT_URL_COVER_S: Final = (
|
||||
"https://images.igdb.com/igdb/image/upload/t_cover_small/nocover.png"
|
||||
)
|
||||
DEFAULT_PATH_COVER_S: Final = "default/default/cover/small.png"
|
||||
DEFAULT_WIDTH_COVER_S: Final = 90 # Width of small cover of IGDB
|
||||
DEFAULT_HEIGHT_COVER_S: Final = 120 # Height of small cover of IGDB
|
||||
DEFAULT_WIDTH_COVER_S: Final = 90 # Width of small cover of IGDB
|
||||
DEFAULT_HEIGHT_COVER_S: Final = 120 # Height of small cover of IGDB
|
||||
|
||||
# MARIADB
|
||||
DB_HOST: Final = os.environ.get("DB_HOST", "127.0.0.1")
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
import pydash
|
||||
from yaml.loader import SafeLoader
|
||||
from urllib.parse import quote_plus
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
import pydash
|
||||
import yaml
|
||||
from config import (
|
||||
ROMM_DB_DRIVER,
|
||||
SQLITE_DB_BASE_PATH,
|
||||
ROMM_USER_CONFIG_PATH,
|
||||
DB_HOST,
|
||||
DB_NAME,
|
||||
DB_PASSWD,
|
||||
DB_PORT,
|
||||
DB_USER,
|
||||
DB_PASSWD,
|
||||
DB_NAME,
|
||||
ROMM_DB_DRIVER,
|
||||
ROMM_USER_CONFIG_PATH,
|
||||
SQLITE_DB_BASE_PATH,
|
||||
)
|
||||
from logger.logger import log
|
||||
from typing_extensions import TypedDict
|
||||
from yaml.loader import SafeLoader
|
||||
|
||||
|
||||
class ConfigDict(TypedDict):
|
||||
@@ -30,11 +30,19 @@ class ConfigDict(TypedDict):
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
"""Parse and load the user configuration from the config.yml file
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Raises an error if the config.yml is not found
|
||||
"""
|
||||
|
||||
# Tests require custom config path
|
||||
def __init__(self, config_path: str = ROMM_USER_CONFIG_PATH):
|
||||
self.config_path = config_path
|
||||
if os.path.isdir(config_path):
|
||||
log.critical(f"Your config file {config_path} is a directory, not a file. Docker creates folders by default for binded files that doesn't exists in advance in the host system.")
|
||||
log.critical(
|
||||
f"Your config file {config_path} is a directory, not a file. Docker creates folders by default for binded files that doesn't exists in advance in the host system."
|
||||
)
|
||||
raise FileNotFoundError()
|
||||
try:
|
||||
with open(config_path) as config_file:
|
||||
@@ -46,6 +54,12 @@ class ConfigLoader:
|
||||
|
||||
@staticmethod
|
||||
def get_db_engine() -> str:
|
||||
"""Builds the database connection string depending on the defined database in the config.yml file
|
||||
|
||||
Returns:
|
||||
str: database connection string
|
||||
"""
|
||||
|
||||
if ROMM_DB_DRIVER == "mariadb":
|
||||
if not DB_USER or not DB_PASSWD:
|
||||
log.critical(
|
||||
@@ -67,6 +81,8 @@ class ConfigLoader:
|
||||
sys.exit(3)
|
||||
|
||||
def _parse_config(self):
|
||||
"""Parses each entry in the config.yml"""
|
||||
|
||||
self.config["EXCLUDED_PLATFORMS"] = pydash.get(
|
||||
self.config, "exclude.platforms", []
|
||||
)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import secrets
|
||||
from typing import Optional, Annotated
|
||||
from typing_extensions import TypedDict
|
||||
from fastapi import APIRouter, HTTPException, status, Request, Depends, File, UploadFile
|
||||
from fastapi.security.http import HTTPBasic
|
||||
from pydantic import BaseModel
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from handler import dbh
|
||||
from models.user import User, Role
|
||||
from utils.cache import cache
|
||||
from utils.auth import authenticate_user, get_password_hash, clear_session
|
||||
from utils.oauth import protected_route
|
||||
from utils.fs import build_avatar_path
|
||||
from config import ROMM_AUTH_ENABLED
|
||||
from exceptions.credentials_exceptions import credentials_exception, disabled_exception
|
||||
from exceptions.credentials_exceptions import CredentialsException, DisabledException
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi.security.http import HTTPBasic
|
||||
from handler import dbh
|
||||
from models.user import Role, User
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
from utils.auth import authenticate_user, clear_session, get_password_hash
|
||||
from utils.cache import cache
|
||||
from utils.fs import build_avatar_path
|
||||
from utils.oauth import protected_route
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -29,90 +29,6 @@ class UserSchema(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MessageResponse(TypedDict):
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(request: Request, credentials=Depends(HTTPBasic())) -> MessageResponse:
|
||||
"""Session login endpoint"""
|
||||
user = authenticate_user(credentials.username, credentials.password)
|
||||
if not user:
|
||||
raise credentials_exception
|
||||
|
||||
if not user.enabled:
|
||||
raise disabled_exception
|
||||
|
||||
# Generate unique session key and store in cache
|
||||
request.session["session_id"] = secrets.token_hex(16)
|
||||
cache.set(f'romm:{request.session["session_id"]}', user.username) # type: ignore[attr-defined]
|
||||
|
||||
return {"message": "Successfully logged in"}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request) -> MessageResponse:
|
||||
"""Session logout endpoint"""
|
||||
# Check if session key already stored in cache
|
||||
session_id = request.session.get("session_id")
|
||||
if not session_id:
|
||||
return {"message": "Already logged out"}
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return {"message": "Already logged out"}
|
||||
|
||||
clear_session(request)
|
||||
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
@protected_route(router.get, "/users", ["users.read"])
|
||||
def users(request: Request) -> list[UserSchema]:
|
||||
"""Get all users"""
|
||||
return dbh.get_users()
|
||||
|
||||
|
||||
@protected_route(router.get, "/users/me", ["me.read"])
|
||||
def current_user(request: Request) -> UserSchema | None:
|
||||
"""Get current user"""
|
||||
return request.user
|
||||
|
||||
|
||||
@protected_route(router.get, "/users/{user_id}", ["users.read"])
|
||||
def get_user(request: Request, user_id: int) -> UserSchema:
|
||||
"""Get a specific user"""
|
||||
user = dbh.get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/users",
|
||||
["users.write"],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_user(
|
||||
request: Request, username: str, password: str, role: str
|
||||
) -> UserSchema:
|
||||
"""Create a new user"""
|
||||
if not ROMM_AUTH_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot create user: ROMM_AUTH_ENABLED is set to False",
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
hashed_password=get_password_hash(password),
|
||||
role=Role[role.upper()],
|
||||
)
|
||||
|
||||
return dbh.add_user(user)
|
||||
|
||||
|
||||
class UserUpdateForm:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -129,11 +45,169 @@ class UserUpdateForm:
|
||||
self.avatar = avatar
|
||||
|
||||
|
||||
class MessageResponse(TypedDict):
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(request: Request, credentials=Depends(HTTPBasic())) -> MessageResponse:
|
||||
"""Session login endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
credentials: Defaults to Depends(HTTPBasic()).
|
||||
|
||||
Raises:
|
||||
CredentialsException: Invalid credentials
|
||||
DisabledException: Auth is disabled
|
||||
|
||||
Returns:
|
||||
MessageResponse: Standard message response
|
||||
"""
|
||||
|
||||
user = authenticate_user(credentials.username, credentials.password)
|
||||
if not user:
|
||||
raise CredentialsException
|
||||
|
||||
if not user.enabled:
|
||||
raise DisabledException
|
||||
|
||||
# Generate unique session key and store in cache
|
||||
request.session["session_id"] = secrets.token_hex(16)
|
||||
cache.set(f'romm:{request.session["session_id"]}', user.username) # type: ignore[attr-defined]
|
||||
|
||||
return {"message": "Successfully logged in"}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request) -> MessageResponse:
|
||||
"""Session logout endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
|
||||
Returns:
|
||||
MessageResponse: Standard message response
|
||||
"""
|
||||
|
||||
# Check if session key already stored in cache
|
||||
session_id = request.session.get("session_id")
|
||||
if not session_id:
|
||||
return {"message": "Already logged out"}
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return {"message": "Already logged out"}
|
||||
|
||||
clear_session(request)
|
||||
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
@protected_route(router.get, "/users", ["users.read"])
|
||||
def users(request: Request) -> list[UserSchema]:
|
||||
"""Get all users endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
|
||||
Returns:
|
||||
list[UserSchema]: All users stored in the RomM's database
|
||||
"""
|
||||
|
||||
return dbh.get_users()
|
||||
|
||||
|
||||
@protected_route(router.get, "/users/me", ["me.read"])
|
||||
def current_user(request: Request) -> UserSchema | None:
|
||||
"""Get current user endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
|
||||
Returns:
|
||||
UserSchema | None: Current user stored in the RomM's database
|
||||
"""
|
||||
|
||||
return request.user
|
||||
|
||||
|
||||
@protected_route(router.get, "/users/{user_id}", ["users.read"])
|
||||
def get_user(request: Request, user_id: int) -> UserSchema:
|
||||
"""Get user endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
|
||||
Returns:
|
||||
UserSchem: User stored in the RomM's database
|
||||
"""
|
||||
|
||||
user = dbh.get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/users",
|
||||
["users.write"],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_user(
|
||||
request: Request, username: str, password: str, role: str
|
||||
) -> UserSchema:
|
||||
"""Create user endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Requests object
|
||||
username (str): User username
|
||||
password (str): User password
|
||||
role (str): RomM Role object represented as string
|
||||
|
||||
Raises:
|
||||
HTTPException: ROMM_AUTH_ENABLED is disabled
|
||||
|
||||
Returns:
|
||||
UserSchema: Created user info
|
||||
"""
|
||||
|
||||
if not ROMM_AUTH_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot create user: ROMM_AUTH_ENABLED is set to False",
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
hashed_password=get_password_hash(password),
|
||||
role=Role[role.upper()],
|
||||
)
|
||||
|
||||
return dbh.add_user(user)
|
||||
|
||||
|
||||
@protected_route(router.patch, "/users/{user_id}", ["users.write"])
|
||||
def update_user(
|
||||
request: Request, user_id: int, form_data: Annotated[UserUpdateForm, Depends()]
|
||||
) -> UserSchema:
|
||||
"""Update a specific user"""
|
||||
"""Update user endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Requests object
|
||||
user_id (int): User internal id
|
||||
form_data (Annotated[UserUpdateForm, Depends): Form Data with user updated info
|
||||
|
||||
Raises:
|
||||
HTTPException: ROMM_AUTH_ENABLED is disabled
|
||||
HTTPException: User is not found in database
|
||||
HTTPException: Username already in use by another user
|
||||
|
||||
Returns:
|
||||
UserSchema: Updated user info
|
||||
"""
|
||||
|
||||
if not ROMM_AUTH_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -188,7 +262,22 @@ def update_user(
|
||||
|
||||
@protected_route(router.delete, "/users/{user_id}", ["users.write"])
|
||||
def delete_user(request: Request, user_id: int) -> MessageResponse:
|
||||
"""Delete a specific user"""
|
||||
"""Delete user endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
user_id (int): User internal id
|
||||
|
||||
Raises:
|
||||
HTTPException: ROMM_AUTH_ENABLED is disabled
|
||||
HTTPException: User is not found in database
|
||||
HTTPException: User deleting itself
|
||||
HTTPException: User is the last admin user
|
||||
|
||||
Returns:
|
||||
MessageResponse: Standard message response
|
||||
"""
|
||||
|
||||
if not ROMM_AUTH_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from typing import Annotated, Final
|
||||
from typing_extensions import TypedDict, NotRequired
|
||||
from datetime import timedelta
|
||||
from fastapi import Depends, APIRouter, HTTPException, status
|
||||
|
||||
from typing import Annotated, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
from utils.auth import authenticate_user
|
||||
from utils.oauth import (
|
||||
OAuth2RequestForm,
|
||||
@@ -11,7 +10,6 @@ from utils.oauth import (
|
||||
get_current_active_user_from_bearer_token,
|
||||
)
|
||||
|
||||
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: Final = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: Final = 7
|
||||
|
||||
@@ -27,7 +25,23 @@ class TokenResponse(TypedDict):
|
||||
|
||||
@router.post("/token")
|
||||
async def token(form_data: Annotated[OAuth2RequestForm, Depends()]) -> TokenResponse:
|
||||
"""OAuth2 token endpoint"""
|
||||
"""OAuth2 token endpoint
|
||||
|
||||
Args:
|
||||
form_data (Annotated[OAuth2RequestForm, Depends): Form Data with OAuth2 info
|
||||
|
||||
Raises:
|
||||
HTTPException: Missing refresh token
|
||||
HTTPException: Invalid refresh token
|
||||
HTTPException: Missing username or password
|
||||
HTTPException: Invalid username or password
|
||||
HTTPException: Client credentials are not yet supported
|
||||
HTTPException: Invalid or unsupported grant type
|
||||
HTTPException: Insufficient scope
|
||||
|
||||
Returns:
|
||||
TokenResponse: TypedDict with the new generated token info
|
||||
"""
|
||||
|
||||
# Suppport refreshing access tokens
|
||||
if form_data.grant_type == "refresh_token":
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from fastapi import APIRouter, Request, status, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from typing_extensions import TypedDict
|
||||
from handler import dbh
|
||||
from utils.oauth import protected_route
|
||||
|
||||
from config import ROMM_HOST
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from handler import dbh
|
||||
from logger.logger import log
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
from utils.oauth import protected_route
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -77,15 +78,39 @@ class PlatformSchema(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WebrcadeFeedSchema(TypedDict):
|
||||
title: str
|
||||
longTitle: str
|
||||
description: str
|
||||
thumbnail: str
|
||||
background: str
|
||||
categories: list[dict]
|
||||
|
||||
|
||||
@protected_route(router.get, "/platforms", ["platforms.read"])
|
||||
def platforms(request: Request) -> list[PlatformSchema]:
|
||||
"""Returns platforms data"""
|
||||
"""Get platforms endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
|
||||
Returns:
|
||||
list[PlatformSchema]: All platforms in the database
|
||||
"""
|
||||
|
||||
return dbh.get_platforms()
|
||||
|
||||
|
||||
@protected_route(router.get, "/platforms/webrcade/feed", [])
|
||||
def platforms_webrcade_feed(request: Request):
|
||||
"""Returns platforms data"""
|
||||
def platforms_webrcade_feed(request: Request) -> WebrcadeFeedSchema:
|
||||
"""Get webrcade feed endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
|
||||
Returns:
|
||||
WebrcadeFeedSchema: Webrcade feed object schema
|
||||
"""
|
||||
platforms = dbh.get_platforms()
|
||||
|
||||
with dbh.session.begin() as session:
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
from datetime import datetime
|
||||
import json
|
||||
from typing import Optional, Annotated
|
||||
from typing_extensions import TypedDict
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Request,
|
||||
status,
|
||||
HTTPException,
|
||||
File,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi import Query
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate
|
||||
from fastapi_pagination.cursor import CursorPage, CursorParams
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from datetime import datetime
|
||||
from stat import S_IFREG
|
||||
from stream_zip import ZIP_64, stream_zip # type: ignore[import]
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from config import LIBRARY_BASE_PATH
|
||||
from exceptions.fs_exceptions import RomAlreadyExistsException, RomNotFoundError
|
||||
from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi_pagination.cursor import CursorPage, CursorParams
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate
|
||||
from handler import dbh
|
||||
from logger.logger import log
|
||||
from models import Rom
|
||||
from handler import dbh
|
||||
from exceptions.fs_exceptions import RomNotFoundError, RomAlreadyExistsException
|
||||
from utils.oauth import protected_route
|
||||
from pydantic import BaseModel
|
||||
from stream_zip import ZIP_64, stream_zip # type: ignore[import]
|
||||
from typing_extensions import TypedDict
|
||||
from utils import get_file_name_with_no_tags
|
||||
from utils.fs import (
|
||||
_rom_exists,
|
||||
build_artwork_path,
|
||||
build_upload_roms_path,
|
||||
rename_rom,
|
||||
get_cover,
|
||||
get_screenshots,
|
||||
remove_rom,
|
||||
rename_rom,
|
||||
)
|
||||
|
||||
from .utils import CustomStreamingResponse
|
||||
from utils.oauth import protected_route
|
||||
from utils.socket import socket_server
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -71,11 +61,14 @@ class RomSchema(BaseModel):
|
||||
regions: list[str]
|
||||
languages: list[str]
|
||||
tags: list[str]
|
||||
|
||||
multi: bool
|
||||
files: list[str]
|
||||
|
||||
url_screenshots: list[str]
|
||||
path_screenshots: list[str]
|
||||
full_path: str
|
||||
|
||||
download_path: str
|
||||
|
||||
class Config:
|
||||
@@ -86,27 +79,111 @@ class EnhancedRomSchema(RomSchema):
|
||||
sibling_roms: list["RomSchema"]
|
||||
|
||||
|
||||
class UploadRomResponse(TypedDict):
|
||||
uploaded_roms: list[str]
|
||||
skipped_roms: list[str]
|
||||
|
||||
|
||||
class CustomStreamingResponse(StreamingResponse):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.emit_body = kwargs.pop("emit_body", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
async def stream_response(self, *args, **kwargs) -> None:
|
||||
await super().stream_response(*args, **kwargs)
|
||||
await socket_server.emit("download:complete", self.emit_body)
|
||||
|
||||
|
||||
class DeleteRomResponse(TypedDict):
|
||||
msg: str
|
||||
|
||||
|
||||
class MassDeleteRomResponse(TypedDict):
|
||||
msg: str
|
||||
|
||||
|
||||
@protected_route(router.get, "/roms/{id}", ["roms.read"])
|
||||
def rom(request: Request, id: int) -> EnhancedRomSchema:
|
||||
"""Returns one rom data of the desired platform"""
|
||||
"""Get rom endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
id (int): Rom internal id
|
||||
|
||||
Returns:
|
||||
EnhancedRomSchema: Rom stored in RomM's database
|
||||
"""
|
||||
|
||||
return dbh.get_rom(id)
|
||||
|
||||
|
||||
@protected_route(router.get, "/roms-recent", ["roms.read"])
|
||||
def recent_roms(request: Request) -> list[RomSchema]:
|
||||
"""Returns the last 15 added roms"""
|
||||
"""Get recent roms endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
|
||||
Returns:
|
||||
list[RomSchema]: List of the last 15 stored roms in RomM's database
|
||||
"""
|
||||
|
||||
return dbh.get_recent_roms()
|
||||
|
||||
|
||||
class UploadRomResponse(TypedDict):
|
||||
uploaded_roms: list[str]
|
||||
skipped_roms: list[str]
|
||||
@protected_route(router.get, "/platforms/{platform_slug}/roms", ["roms.read"])
|
||||
def roms(
|
||||
request: Request,
|
||||
platform_slug: str,
|
||||
size: int = 60,
|
||||
cursor: str = "",
|
||||
search_term: str = "",
|
||||
) -> CursorPage[RomSchema]:
|
||||
"""Get all roms for a specific platform endpoint (paginated)
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
platform_slug (str): Platform slug
|
||||
size (int, optional): Size of each page. Defaults to 60.
|
||||
cursor (str, optional): Cursor string. Defaults to "".
|
||||
search_term (str, optional): Filter to search roms. Defaults to "".
|
||||
|
||||
Returns:
|
||||
CursorPage[RomSchema]: Paged list of roms
|
||||
"""
|
||||
|
||||
with dbh.session.begin() as session:
|
||||
cursor_params = CursorParams(size=size, cursor=cursor)
|
||||
qq = dbh.get_roms(platform_slug)
|
||||
|
||||
if search_term:
|
||||
return paginate(
|
||||
session,
|
||||
qq.filter(Rom.file_name.ilike(f"%{search_term}%")),
|
||||
cursor_params,
|
||||
)
|
||||
|
||||
return paginate(session, qq, cursor_params)
|
||||
|
||||
|
||||
@protected_route(router.put, "/roms/upload", ["roms.write"])
|
||||
def upload_roms(
|
||||
request: Request, platform_slug: str, roms: list[UploadFile] = File(...)
|
||||
) -> UploadRomResponse:
|
||||
"""Upload roms endpoint (one or more at the same time)
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
platform_slug (str): Slug of the platform where to upload the roms
|
||||
roms (list[UploadFile], optional): List of files to upload. Defaults to File(...).
|
||||
|
||||
Raises:
|
||||
HTTPException: No files were uploaded
|
||||
|
||||
Returns:
|
||||
UploadRomResponse: Standard message response
|
||||
"""
|
||||
|
||||
platform_fs_slug = dbh.get_platform(platform_slug).fs_slug
|
||||
log.info(f"Uploading files to: {platform_fs_slug}")
|
||||
if roms is None:
|
||||
@@ -149,7 +226,20 @@ def upload_roms(
|
||||
def download_rom(
|
||||
request: Request, id: int, files: Annotated[list[str] | None, Query()] = None
|
||||
):
|
||||
"""Downloads a rom or a zip file with multiple roms"""
|
||||
"""Download rom endpoint (one single file or multiple zipped files for multi-part roms)
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
id (int): Rom internal id
|
||||
files (Annotated[list[str] | None, Query, optional): List of files to download for multi-part roms. Defaults to None.
|
||||
|
||||
Returns:
|
||||
FileResponse: Returns one file for single file roms
|
||||
|
||||
Yields:
|
||||
CustomStreamingResponse: Streams a file for multi-part roms
|
||||
"""
|
||||
|
||||
rom = dbh.get_rom(id)
|
||||
rom_path = f"{LIBRARY_BASE_PATH}/{rom.full_path}"
|
||||
|
||||
@@ -182,29 +272,6 @@ def download_rom(
|
||||
)
|
||||
|
||||
|
||||
@protected_route(router.get, "/platforms/{platform_slug}/roms", ["roms.read"])
|
||||
def roms(
|
||||
request: Request,
|
||||
platform_slug: str,
|
||||
size: int = 60,
|
||||
cursor: str = "",
|
||||
search_term: str = "",
|
||||
) -> CursorPage[RomSchema]:
|
||||
"""Returns all roms of the desired platform"""
|
||||
with dbh.session.begin() as session:
|
||||
cursor_params = CursorParams(size=size, cursor=cursor)
|
||||
qq = dbh.get_roms(platform_slug)
|
||||
|
||||
if search_term:
|
||||
return paginate(
|
||||
session,
|
||||
qq.filter(Rom.file_name.ilike(f"%{search_term}%")),
|
||||
cursor_params,
|
||||
)
|
||||
|
||||
return paginate(session, qq, cursor_params)
|
||||
|
||||
|
||||
@protected_route(router.patch, "/roms/{id}", ["roms.write"])
|
||||
async def update_rom(
|
||||
request: Request,
|
||||
@@ -212,7 +279,20 @@ async def update_rom(
|
||||
rename_as_igdb: bool = False,
|
||||
artwork: Optional[UploadFile] = File(None),
|
||||
) -> RomSchema:
|
||||
"""Updates rom details"""
|
||||
"""Update rom endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
id (Rom): Rom internal id
|
||||
rename_as_igdb (bool, optional): Flag to rename rom file as matched IGDB game. Defaults to False.
|
||||
artwork (Optional[UploadFile], optional): Custom artork to set as cover. Defaults to File(None).
|
||||
|
||||
Raises:
|
||||
HTTPException: If a rom already have that name when enabling the rename_as_igdb flag
|
||||
|
||||
Returns:
|
||||
RomSchema: Rom stored in RomM's database
|
||||
"""
|
||||
|
||||
data = await request.form()
|
||||
|
||||
@@ -288,7 +368,21 @@ async def update_rom(
|
||||
return dbh.get_rom(id)
|
||||
|
||||
|
||||
def _delete_single_rom(rom_id: int, delete_from_fs: bool = False):
|
||||
def _delete_single_rom(rom_id: int, delete_from_fs: bool = False) -> Rom:
|
||||
"""Auxiliar function to delete one single rom at once
|
||||
|
||||
Args:
|
||||
rom_id (int): Rom internal id
|
||||
delete_from_fs (bool, optional): Flag to delete rom from filesystem. Defaults to False.
|
||||
|
||||
Raises:
|
||||
HTTPException: Rom could not be found
|
||||
HTTPException: Rom could not be deleted from filesystem
|
||||
|
||||
Returns:
|
||||
Rom: Rom object
|
||||
"""
|
||||
|
||||
rom = dbh.get_rom(rom_id)
|
||||
if not rom:
|
||||
error = f"Rom with id {rom_id} not found"
|
||||
@@ -309,31 +403,40 @@ def _delete_single_rom(rom_id: int, delete_from_fs: bool = False):
|
||||
return rom
|
||||
|
||||
|
||||
class DeleteRomResponse(TypedDict):
|
||||
msg: str
|
||||
|
||||
|
||||
@protected_route(router.delete, "/roms/{id}", ["roms.write"])
|
||||
def delete_rom(
|
||||
request: Request, id: int, delete_from_fs: bool = False
|
||||
) -> DeleteRomResponse:
|
||||
"""Detele rom from database [and filesystem]"""
|
||||
"""Delete rom endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
id (int): Rom internal id
|
||||
delete_from_fs (bool, optional): Flag to delete rom from filesystem. Defaults to False.
|
||||
|
||||
Returns:
|
||||
DeleteRomResponse: Standard message response
|
||||
"""
|
||||
|
||||
rom = _delete_single_rom(id, delete_from_fs)
|
||||
|
||||
return {"msg": f"{rom.file_name} deleted successfully!"}
|
||||
|
||||
|
||||
class MassDeleteRomResponse(TypedDict):
|
||||
msg: str
|
||||
|
||||
|
||||
@protected_route(router.post, "/roms/delete", ["roms.write"])
|
||||
async def mass_delete_roms(
|
||||
request: Request,
|
||||
delete_from_fs: bool = False,
|
||||
) -> MassDeleteRomResponse:
|
||||
"""Detele multiple roms from database [and filesystem]"""
|
||||
"""Delete roms endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
delete_from_fs (bool, optional): Flag to delete rom from filesystem. Defaults to False.
|
||||
|
||||
Returns:
|
||||
MassDeleteRomResponse: Standard message response
|
||||
"""
|
||||
|
||||
data: dict = await request.json()
|
||||
roms_ids: list = data["roms"]
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import emoji
|
||||
import socketio # type: ignore
|
||||
|
||||
from logger.logger import log
|
||||
from exceptions.fs_exceptions import PlatformsNotFoundException, RomsNotFoundException
|
||||
from handler import dbh
|
||||
from utils.fastapi import scan_platform, scan_rom
|
||||
from utils.socket import socket_server
|
||||
from utils.fs import get_platforms, get_roms, store_default_resources
|
||||
from utils.redis import high_prio_queue, redis_url
|
||||
from config import ENABLE_EXPERIMENTAL_REDIS
|
||||
from endpoints.platform import PlatformSchema
|
||||
from endpoints.rom import RomSchema
|
||||
from config import ENABLE_EXPERIMENTAL_REDIS
|
||||
from exceptions.fs_exceptions import PlatformsNotFoundException, RomsNotFoundException
|
||||
from handler import dbh
|
||||
from logger.logger import log
|
||||
from utils.fastapi import scan_platform, scan_rom
|
||||
from utils.fs import get_platforms, get_roms, store_default_resources
|
||||
from utils.redis import high_prio_queue, redis_url
|
||||
from utils.socket import socket_server
|
||||
|
||||
|
||||
async def scan_platforms(
|
||||
@@ -19,6 +18,15 @@ async def scan_platforms(
|
||||
rescan_unidentified: bool = False,
|
||||
selected_roms: list[str] = (),
|
||||
):
|
||||
"""Scan all the listed platforms and fetch metadata from different sources
|
||||
|
||||
Args:
|
||||
platform_slugs (list[str]): List of platform slugs to be scanned
|
||||
complete_rescan (bool, optional): Flag to rescan already scanned platforms. Defaults to False.
|
||||
rescan_unidentified (bool, optional): Flag to rescan only unidentified roms. Defaults to False.
|
||||
selected_roms (list[str], optional): List of selected roms to be scanned. Defaults to ().
|
||||
"""
|
||||
|
||||
# Connect to external socketio server
|
||||
sm = (
|
||||
socketio.AsyncRedisManager(redis_url, write_only=True)
|
||||
@@ -37,8 +45,10 @@ async def scan_platforms(
|
||||
platform_list = [dbh.get_platform(s).fs_slug for s in platform_slugs]
|
||||
platform_list = platform_list or fs_platforms
|
||||
|
||||
if (len(platform_list) == 0):
|
||||
log.warn("⚠️ No platforms found, verify that the folder structure is right and the volume is mounted correctly ")
|
||||
if len(platform_list) == 0:
|
||||
log.warn(
|
||||
"⚠️ No platforms found, verify that the folder structure is right and the volume is mounted correctly "
|
||||
)
|
||||
else:
|
||||
log.info(f"Found {len(platform_list)} platforms in file system ")
|
||||
|
||||
@@ -61,14 +71,18 @@ async def scan_platforms(
|
||||
log.error(e)
|
||||
continue
|
||||
|
||||
if (len(fs_roms) == 0):
|
||||
log.warning(" ⚠️ No roms found, verify that the folder structure is correct")
|
||||
if len(fs_roms) == 0:
|
||||
log.warning(
|
||||
" ⚠️ No roms found, verify that the folder structure is correct"
|
||||
)
|
||||
else:
|
||||
log.warn(f" {len(fs_roms)} roms found")
|
||||
|
||||
for fs_rom in fs_roms:
|
||||
rom = dbh.get_rom_by_filename(scanned_platform.slug, fs_rom["file_name"])
|
||||
if (rom and rom.id not in selected_roms and not complete_rescan) and not (rescan_unidentified and rom and not rom.igdb_id):
|
||||
if (rom and rom.id not in selected_roms and not complete_rescan) and not (
|
||||
rescan_unidentified and rom and not rom.igdb_id
|
||||
):
|
||||
continue
|
||||
|
||||
scanned_rom = await scan_rom(scanned_platform, fs_rom)
|
||||
@@ -96,7 +110,11 @@ async def scan_platforms(
|
||||
|
||||
@socket_server.on("scan")
|
||||
async def scan_handler(_sid: str, options: dict):
|
||||
"""Scan platforms and roms and write them in database."""
|
||||
"""Scan socket endpoint
|
||||
|
||||
Args:
|
||||
options (dict): Socket options
|
||||
"""
|
||||
|
||||
log.info(emoji.emojize(":magnifying_glass_tilted_right: Scanning "))
|
||||
store_default_resources()
|
||||
@@ -117,4 +135,6 @@ async def scan_handler(_sid: str, options: dict):
|
||||
job_timeout=14400, # Timeout after 4 hours
|
||||
)
|
||||
else:
|
||||
await scan_platforms(platform_slugs, complete_rescan, rescan_unidentified, selected_roms)
|
||||
await scan_platforms(
|
||||
platform_slugs, complete_rescan, rescan_unidentified, selected_roms
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import emoji
|
||||
from fastapi import APIRouter, Request
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from logger.logger import log
|
||||
from handler import igdbh, dbh
|
||||
from handler import dbh, igdbh
|
||||
from handler.igdb_handler import IGDBRomType
|
||||
from logger.logger import log
|
||||
from typing_extensions import TypedDict
|
||||
from utils.oauth import protected_route
|
||||
|
||||
router = APIRouter()
|
||||
@@ -19,7 +18,17 @@ class RomSearchResponse(TypedDict):
|
||||
async def search_rom_igdb(
|
||||
request: Request, rom_id: str, query: str = None, field: str = "Name"
|
||||
) -> RomSearchResponse:
|
||||
"""Search IGDB for ROMs"""
|
||||
"""Search rom into IGDB database
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
rom_id (str): Rom internal id
|
||||
query (str, optional): Query to search the rom (IGDB name or IGDB id). Defaults to None.
|
||||
field (str, optional): field with which to search for the rom (name | id). Defaults to "Name".
|
||||
|
||||
Returns:
|
||||
RomSearchResponse: List of objects with all the matched roms
|
||||
"""
|
||||
|
||||
rom = dbh.get_rom(rom_id)
|
||||
query = query or rom.file_name_no_tags
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from utils.oauth import protected_route
|
||||
from tasks.update_mame_xml import update_mame_xml_task
|
||||
from tasks.update_switch_titledb import update_switch_titledb_task
|
||||
from typing_extensions import TypedDict
|
||||
from utils.oauth import protected_route
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class RunTasksResponse(TypedDict):
|
||||
msg: str
|
||||
|
||||
|
||||
@protected_route(router.post, "/tasks/run", ["tasks.run"])
|
||||
async def run_tasks(request: Request):
|
||||
"""Run all async tasks"""
|
||||
async def run_tasks(request: Request) -> RunTasksResponse:
|
||||
"""Run all tasks endpoint
|
||||
|
||||
Args:
|
||||
request (Request): Fastapi Request object
|
||||
|
||||
Returns:
|
||||
RunTasksResponse: Standard message response
|
||||
"""
|
||||
|
||||
await update_mame_xml_task.run()
|
||||
await update_switch_titledb_task.run()
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from utils.socket import socket_server
|
||||
|
||||
|
||||
class CustomStreamingResponse(StreamingResponse):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.emit_body = kwargs.pop("emit_body", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
async def stream_response(self, *args, **kwargs) -> None:
|
||||
await super().stream_response(*args, **kwargs)
|
||||
await socket_server.emit("download:complete", self.emit_body)
|
||||
@@ -1,17 +1,16 @@
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
CredentialsException = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
authentication_scheme_exception = HTTPException(
|
||||
AuthenticationSchemeException = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication scheme",
|
||||
)
|
||||
|
||||
disabled_exception = HTTPException(
|
||||
DisabledException = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Disabled user",
|
||||
)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import functools
|
||||
|
||||
from fastapi import status, HTTPException
|
||||
from sqlalchemy import create_engine, select, delete, update, and_, or_, func
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
|
||||
from logger.logger import log
|
||||
from config.config_loader import ConfigLoader
|
||||
from models import Platform, Rom, User, Role
|
||||
from fastapi import HTTPException, status
|
||||
from logger.logger import log
|
||||
from models import Platform, Role, Rom, User
|
||||
from sqlalchemy import and_, create_engine, delete, func, or_, select, update
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
|
||||
class DBHandler:
|
||||
@@ -51,7 +50,7 @@ class DBHandler:
|
||||
return session.scalars(
|
||||
select(Platform).filter_by(fs_slug=fs_slug).limit(1)
|
||||
).first()
|
||||
|
||||
|
||||
@begin_session
|
||||
def delete_platform(self, slug: str, session: Session = None):
|
||||
# Remove all roms from that platforms first
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import sys
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import Final
|
||||
|
||||
import pydash
|
||||
import requests
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
import xmltodict
|
||||
from unidecode import unidecode as uc
|
||||
from requests.exceptions import HTTPError, Timeout
|
||||
from typing import Final
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from config import IGDB_CLIENT_ID, IGDB_CLIENT_SECRET, DEFAULT_URL_COVER_L
|
||||
from utils import get_file_name_with_no_tags as get_search_term, normalize_search_term
|
||||
from config import DEFAULT_URL_COVER_L, IGDB_CLIENT_ID, IGDB_CLIENT_SECRET
|
||||
from igdb.wrapper import IGDBWrapper
|
||||
from logger.logger import log
|
||||
from utils.cache import cache
|
||||
from tasks.update_switch_titledb import update_switch_titledb_task
|
||||
from requests.exceptions import HTTPError, Timeout
|
||||
from tasks.update_mame_xml import update_mame_xml_task
|
||||
from tasks.update_switch_titledb import update_switch_titledb_task
|
||||
from typing_extensions import TypedDict
|
||||
from unidecode import unidecode as uc
|
||||
from utils import get_file_name_with_no_tags as get_search_term
|
||||
from utils import normalize_search_term
|
||||
from utils.cache import cache
|
||||
|
||||
MAIN_GAME_CATEGORY: Final = 0
|
||||
EXPANDED_GAME_CATEGORY: Final = 10
|
||||
@@ -70,14 +72,15 @@ class IGDBHandler:
|
||||
"Authorization": f"Bearer {self.twitch_auth.get_oauth_token()}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
self.wrapper = IGDBWrapper(IGDB_CLIENT_ID, self.twitch_auth.get_oauth_token())
|
||||
|
||||
@staticmethod
|
||||
def check_twitch_token(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args):
|
||||
args[0].headers[
|
||||
"Authorization"
|
||||
] = f"Bearer {args[0].twitch_auth.get_oauth_token()}"
|
||||
args[0].wrapper = IGDBWrapper(
|
||||
IGDB_CLIENT_ID, args[0].twitch_auth.get_oauth_token()
|
||||
)
|
||||
return func(*args)
|
||||
|
||||
return wrapper
|
||||
@@ -165,21 +168,20 @@ class IGDBHandler:
|
||||
@staticmethod
|
||||
async def _ps2_opl_format(match: re.Match[str], search_term: str) -> str:
|
||||
serial_code = match.group(1)
|
||||
|
||||
|
||||
with open(PS2_OPL_INDEX_FILE, "r") as index_json:
|
||||
opl_index = json.loads(index_json.read())
|
||||
index_entry = opl_index.get(serial_code, None)
|
||||
if index_entry:
|
||||
search_term = index_entry["Name"] # type: ignore
|
||||
|
||||
return search_term
|
||||
|
||||
return search_term
|
||||
|
||||
@staticmethod
|
||||
async def _switch_titledb_format(match: re.Match[str], search_term: str) -> str:
|
||||
titledb_index = {}
|
||||
title_id = match.group(1)
|
||||
|
||||
|
||||
try:
|
||||
with open(SWITCH_TITLEDB_INDEX_FILE, "r") as index_json:
|
||||
titledb_index = json.loads(index_json.read())
|
||||
@@ -195,19 +197,19 @@ class IGDBHandler:
|
||||
index_entry = titledb_index.get(title_id, None)
|
||||
if index_entry:
|
||||
search_term = index_entry["name"] # type: ignore
|
||||
|
||||
|
||||
return search_term
|
||||
|
||||
@staticmethod
|
||||
async def _switch_productid_format(match: re.Match[str], search_term: str) -> str:
|
||||
product_id_index = {}
|
||||
product_id = match.group(1)
|
||||
|
||||
|
||||
# Game updates have the same product ID as the main application, except with bitmask 0x800 set
|
||||
product_id = list(product_id)
|
||||
product_id[-3] = '0'
|
||||
product_id = ''.join(product_id)
|
||||
|
||||
product_id[-3] = "0"
|
||||
product_id = "".join(product_id)
|
||||
|
||||
try:
|
||||
with open(SWITCH_PRODUCT_ID_FILE, "r") as index_json:
|
||||
product_id_index = json.loads(index_json.read())
|
||||
@@ -228,7 +230,7 @@ class IGDBHandler:
|
||||
@staticmethod
|
||||
async def _mame_format(search_term: str) -> str:
|
||||
mame_index = {"menu": {"game": []}}
|
||||
|
||||
|
||||
try:
|
||||
with open(MAME_XML_FILE, "r") as index_xml:
|
||||
mame_index = xmltodict.parse(index_xml.read())
|
||||
@@ -251,7 +253,7 @@ class IGDBHandler:
|
||||
search_term = get_search_term(
|
||||
index_entry[0].get("description", search_term)
|
||||
)
|
||||
|
||||
|
||||
return search_term
|
||||
|
||||
@check_twitch_token
|
||||
@@ -314,8 +316,8 @@ class IGDBHandler:
|
||||
)
|
||||
|
||||
if igdb_id:
|
||||
rom['url_cover'] = self._search_cover(igdb_id)
|
||||
rom['url_screenshots'] = self._search_screenshots(igdb_id)
|
||||
rom["url_cover"] = self._search_cover(igdb_id)
|
||||
rom["url_screenshots"] = self._search_screenshots(igdb_id)
|
||||
|
||||
return rom
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import requests
|
||||
|
||||
from config import STEAMGRIDDB_API_KEY
|
||||
from logger.logger import log
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from config import LOGS_BASE_PATH
|
||||
from .stdout_formatter import StdoutFormatter
|
||||
|
||||
from .file_formatter import FileFormatter
|
||||
from .stdout_formatter import StdoutFormatter
|
||||
|
||||
# Create logs folder if not exists
|
||||
Path(LOGS_BASE_PATH).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
from logger import COLORS
|
||||
|
||||
|
||||
class StdoutFormatter(logging.Formatter):
|
||||
level: str = "%(levelname)s"
|
||||
dots: str = ":"
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
import uvicorn
|
||||
import alembic.config
|
||||
import re
|
||||
import sys
|
||||
|
||||
import alembic.config
|
||||
import uvicorn
|
||||
from config import (
|
||||
DEV_HOST,
|
||||
DEV_PORT,
|
||||
ENABLE_RESCAN_ON_FILESYSTEM_CHANGE,
|
||||
ENABLE_SCHEDULED_RESCAN,
|
||||
ENABLE_SCHEDULED_UPDATE_MAME_XML,
|
||||
ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB,
|
||||
RESCAN_ON_FILESYSTEM_CHANGE_DELAY,
|
||||
ROMM_AUTH_ENABLED,
|
||||
ROMM_AUTH_SECRET_KEY,
|
||||
SCHEDULED_RESCAN_CRON,
|
||||
SCHEDULED_UPDATE_MAME_XML_CRON,
|
||||
SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON,
|
||||
)
|
||||
from config.config_loader import ConfigDict, config
|
||||
from endpoints import identity, oauth, platform, rom, scan, search, tasks # noqa
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi_pagination import add_pagination
|
||||
from handler import dbh
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from config import (
|
||||
DEV_PORT,
|
||||
DEV_HOST,
|
||||
ROMM_AUTH_SECRET_KEY,
|
||||
ROMM_AUTH_ENABLED,
|
||||
ENABLE_RESCAN_ON_FILESYSTEM_CHANGE,
|
||||
RESCAN_ON_FILESYSTEM_CHANGE_DELAY,
|
||||
ENABLE_SCHEDULED_RESCAN,
|
||||
SCHEDULED_RESCAN_CRON,
|
||||
ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB,
|
||||
SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON,
|
||||
ENABLE_SCHEDULED_UPDATE_MAME_XML,
|
||||
SCHEDULED_UPDATE_MAME_XML_CRON,
|
||||
)
|
||||
from endpoints import search, platform, rom, identity, oauth, scan, tasks # noqa
|
||||
from handler import dbh
|
||||
from utils.socket import socket_app
|
||||
from utils import get_version
|
||||
from utils.auth import (
|
||||
HybridAuthBackend,
|
||||
CustomCSRFMiddleware,
|
||||
HybridAuthBackend,
|
||||
create_default_admin_user,
|
||||
)
|
||||
from utils import get_version
|
||||
from config.config_loader import config, ConfigDict
|
||||
from utils.socket import socket_app
|
||||
|
||||
app = FastAPI(title="RomM API", version="0.1.0")
|
||||
|
||||
@@ -101,9 +101,13 @@ class HeartbeatReturn(TypedDict):
|
||||
CONFIG: ConfigDict
|
||||
|
||||
|
||||
# Endpoint to set the CSRF token in cache
|
||||
@app.get("/heartbeat")
|
||||
def heartbeat() -> HeartbeatReturn:
|
||||
"""Endpoint to set the CSFR token in cache and return all the basic RomM config
|
||||
|
||||
Returns:
|
||||
HeartbeatReturn: TypedDict structure with all the defined values in the HeartbeatReturn class.
|
||||
"""
|
||||
return {
|
||||
"VERSION": get_version(),
|
||||
"ROMM_AUTH_ENABLED": ROMM_AUTH_ENABLED,
|
||||
@@ -138,7 +142,7 @@ def heartbeat() -> HeartbeatReturn:
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
"""Startup application."""
|
||||
"""Event to handle RomM startup logic."""
|
||||
|
||||
# Create default admin user if no admin user exists
|
||||
if len(dbh.get_admin_users()) == 0 and "pytest" not in sys.modules:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .platform import Platform # noqa[401]
|
||||
from .rom import Rom # noqa[401]
|
||||
from .user import User, Role # noqa[401]
|
||||
from .platform import Platform # noqa[401]
|
||||
from .rom import Rom # noqa[401]
|
||||
from .user import Role, User # noqa[401]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from sqlalchemy import Column, String, Integer
|
||||
|
||||
from config import DEFAULT_PATH_COVER_S
|
||||
from sqlalchemy import Column, Integer, String
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import re
|
||||
from sqlalchemy import Integer, Column, String, Text, Boolean, Float, JSON, ForeignKey
|
||||
from sqlalchemy.orm import relationship, Mapped
|
||||
from functools import cached_property
|
||||
|
||||
from config import DEFAULT_PATH_COVER_S, DEFAULT_PATH_COVER_L, FRONT_LIBRARY_PATH
|
||||
from config import DEFAULT_PATH_COVER_L, DEFAULT_PATH_COVER_S, FRONT_LIBRARY_PATH
|
||||
from sqlalchemy import JSON, Boolean, Column, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, relationship
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
SIZE_UNIT_TO_BYTES = {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import enum
|
||||
from sqlalchemy import Column, String, Boolean, Integer, Enum
|
||||
|
||||
from sqlalchemy import Boolean, Column, Enum, Integer, String
|
||||
from starlette.authentication import SimpleUser
|
||||
from utils.oauth import DEFAULT_SCOPES, FULL_SCOPES, WRITE_SCOPES
|
||||
|
||||
from .base import BaseModel
|
||||
from utils.oauth import DEFAULT_SCOPES, WRITE_SCOPES, FULL_SCOPES
|
||||
|
||||
|
||||
class Role(enum.Enum):
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import sys
|
||||
|
||||
from config import ENABLE_EXPERIMENTAL_REDIS
|
||||
from tasks.utils import tasks_scheduler
|
||||
from logger.logger import log
|
||||
from tasks.scan_library import scan_library_task
|
||||
from tasks.update_switch_titledb import update_switch_titledb_task
|
||||
from tasks.update_mame_xml import update_mame_xml_task
|
||||
from tasks.update_switch_titledb import update_switch_titledb_task
|
||||
from tasks.utils import tasks_scheduler
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not ENABLE_EXPERIMENTAL_REDIS:
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from logger.logger import log
|
||||
from config import (
|
||||
ENABLE_SCHEDULED_RESCAN,
|
||||
SCHEDULED_RESCAN_CRON,
|
||||
)
|
||||
from config import ENABLE_SCHEDULED_RESCAN, SCHEDULED_RESCAN_CRON
|
||||
from endpoints.scan import scan_platforms
|
||||
from logger.logger import log
|
||||
|
||||
from .utils import PeriodicTask
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Final
|
||||
from config import (
|
||||
ENABLE_SCHEDULED_UPDATE_MAME_XML,
|
||||
SCHEDULED_UPDATE_MAME_XML_CRON,
|
||||
)
|
||||
|
||||
from config import ENABLE_SCHEDULED_UPDATE_MAME_XML, SCHEDULED_UPDATE_MAME_XML_CRON
|
||||
|
||||
from .utils import RemoteFilePullTask
|
||||
|
||||
FIXTURE_FILE_PATH: Final = (
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Final
|
||||
|
||||
from config import (
|
||||
ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB,
|
||||
SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON,
|
||||
)
|
||||
|
||||
from .utils import RemoteFilePullTask
|
||||
|
||||
FIXTURE_FILE_PATH: Final = (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import requests
|
||||
from rq_scheduler import Scheduler
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from utils.redis import low_prio_queue
|
||||
import requests
|
||||
from config import ENABLE_EXPERIMENTAL_REDIS
|
||||
from logger.logger import log
|
||||
from rq_scheduler import Scheduler
|
||||
from utils.redis import low_prio_queue
|
||||
|
||||
from .exceptions import SchedulerException
|
||||
|
||||
tasks_scheduler = Scheduler(queue=low_prio_queue, connection=low_prio_queue.connection)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
import subprocess as sp
|
||||
|
||||
from __version__ import __version__
|
||||
|
||||
LANGUAGES = [
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException, status, Request
|
||||
from config import ROMM_AUTH_ENABLED, ROMM_AUTH_PASSWORD, ROMM_AUTH_USERNAME
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.security.http import HTTPBasic
|
||||
from passlib.context import CryptContext
|
||||
from starlette.requests import HTTPConnection
|
||||
from starlette_csrf.middleware import CSRFMiddleware
|
||||
from starlette.types import Receive, Scope, Send
|
||||
from starlette.authentication import (
|
||||
AuthCredentials,
|
||||
AuthenticationBackend,
|
||||
)
|
||||
|
||||
from handler import dbh
|
||||
from models.user import Role, User
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from starlette.authentication import AuthCredentials, AuthenticationBackend
|
||||
from starlette.requests import HTTPConnection
|
||||
from starlette.types import Receive, Scope, Send
|
||||
from starlette_csrf.middleware import CSRFMiddleware
|
||||
from utils.cache import cache
|
||||
from models.user import User, Role
|
||||
from config import (
|
||||
ROMM_AUTH_ENABLED,
|
||||
ROMM_AUTH_USERNAME,
|
||||
ROMM_AUTH_PASSWORD,
|
||||
)
|
||||
|
||||
from .oauth import (
|
||||
FULL_SCOPES,
|
||||
get_current_active_user_from_bearer_token,
|
||||
)
|
||||
|
||||
from .oauth import FULL_SCOPES, get_current_active_user_from_bearer_token
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from config import ENABLE_EXPERIMENTAL_REDIS, REDIS_HOST, REDIS_PASSWORD, REDIS_PORT
|
||||
from redis import Redis
|
||||
|
||||
from config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, ENABLE_EXPERIMENTAL_REDIS
|
||||
|
||||
|
||||
class FallbackCache:
|
||||
def __init__(self) -> None:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import emoji
|
||||
from typing import Any
|
||||
|
||||
from handler import igdbh, dbh
|
||||
from utils import fs, parse_tags, get_file_extension, get_file_name_with_no_tags
|
||||
import emoji
|
||||
from config.config_loader import config
|
||||
from models import Platform, Rom
|
||||
from handler import dbh, igdbh
|
||||
from logger.logger import log
|
||||
from models import Platform, Rom
|
||||
from utils import fs, get_file_extension, get_file_name_with_no_tags, parse_tags
|
||||
|
||||
SWAPPED_PLATFORM_BINDINGS = dict((v, k) for k, v in config["PLATFORMS_BINDING"].items())
|
||||
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import os
|
||||
from enum import Enum
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import datetime
|
||||
import requests
|
||||
import fnmatch
|
||||
import os
|
||||
import shutil
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from PIL import Image
|
||||
|
||||
import requests
|
||||
from config import (
|
||||
LIBRARY_BASE_PATH,
|
||||
HIGH_PRIO_STRUCTURE_PATH,
|
||||
ROMS_FOLDER_NAME,
|
||||
RESOURCES_BASE_PATH,
|
||||
DEFAULT_URL_COVER_L,
|
||||
DEFAULT_PATH_COVER_L,
|
||||
DEFAULT_WIDTH_COVER_L,
|
||||
DEFAULT_HEIGHT_COVER_L,
|
||||
DEFAULT_URL_COVER_S,
|
||||
DEFAULT_PATH_COVER_S,
|
||||
DEFAULT_WIDTH_COVER_S,
|
||||
DEFAULT_HEIGHT_COVER_S,
|
||||
DEFAULT_PATH_COVER_L,
|
||||
DEFAULT_PATH_COVER_S,
|
||||
DEFAULT_URL_COVER_L,
|
||||
DEFAULT_URL_COVER_S,
|
||||
DEFAULT_WIDTH_COVER_L,
|
||||
DEFAULT_WIDTH_COVER_S,
|
||||
HIGH_PRIO_STRUCTURE_PATH,
|
||||
LIBRARY_BASE_PATH,
|
||||
RESOURCES_BASE_PATH,
|
||||
ROMS_FOLDER_NAME,
|
||||
)
|
||||
from config.config_loader import config
|
||||
from exceptions.fs_exceptions import (
|
||||
PlatformsNotFoundException,
|
||||
PlatformNotFoundException,
|
||||
RomsNotFoundException,
|
||||
RomNotFoundError,
|
||||
PlatformsNotFoundException,
|
||||
RomAlreadyExistsException,
|
||||
RomNotFoundError,
|
||||
RomsNotFoundException,
|
||||
)
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# ========= Resources utils =========
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Final, Any
|
||||
from jose import JWTError, jwt
|
||||
from fastapi import HTTPException, status, Security
|
||||
from fastapi.param_functions import Form
|
||||
from fastapi.security.oauth2 import OAuth2PasswordBearer
|
||||
from fastapi.security.http import HTTPBasic
|
||||
from fastapi.types import DecoratedCallable
|
||||
from starlette.authentication import requires
|
||||
from typing import Any, Final, Optional
|
||||
|
||||
from config import ROMM_AUTH_SECRET_KEY
|
||||
from fastapi import HTTPException, Security, status
|
||||
from fastapi.param_functions import Form
|
||||
from fastapi.security.http import HTTPBasic
|
||||
from fastapi.security.oauth2 import OAuth2PasswordBearer
|
||||
from fastapi.types import DecoratedCallable
|
||||
from jose import JWTError, jwt
|
||||
from starlette.authentication import requires
|
||||
|
||||
ALGORITHM: Final = "HS256"
|
||||
DEFAULT_OAUTH_TOKEN_EXPIRY: Final = 15
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from config import REDIS_HOST, REDIS_PASSWORD, REDIS_PORT
|
||||
from redis import Redis
|
||||
from rq import Queue
|
||||
|
||||
from config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
|
||||
|
||||
|
||||
redis_client = Redis(
|
||||
host=REDIS_HOST, port=int(REDIS_PORT), password=REDIS_PASSWORD, db=0
|
||||
)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import socketio # type: ignore
|
||||
|
||||
from utils.redis import redis_url
|
||||
from config import ENABLE_EXPERIMENTAL_REDIS
|
||||
|
||||
from utils.redis import redis_url
|
||||
|
||||
socket_server = socketio.AsyncServer(
|
||||
cors_allowed_origins="*",
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
|
||||
from config import (
|
||||
ENABLE_RESCAN_ON_FILESYSTEM_CHANGE,
|
||||
HIGH_PRIO_STRUCTURE_PATH,
|
||||
LIBRARY_BASE_PATH,
|
||||
RESCAN_ON_FILESYSTEM_CHANGE_DELAY,
|
||||
)
|
||||
from endpoints.scan import scan_platforms
|
||||
from logger.logger import log
|
||||
from tasks.utils import tasks_scheduler
|
||||
|
||||
|
||||
from config import (
|
||||
HIGH_PRIO_STRUCTURE_PATH,
|
||||
LIBRARY_BASE_PATH,
|
||||
ENABLE_RESCAN_ON_FILESYSTEM_CHANGE,
|
||||
RESCAN_ON_FILESYSTEM_CHANGE_DELAY,
|
||||
)
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
path = (
|
||||
HIGH_PRIO_STRUCTURE_PATH
|
||||
@@ -23,7 +21,14 @@ path = (
|
||||
|
||||
|
||||
class EventHandler(FileSystemEventHandler):
|
||||
"""Filesystem event handler"""
|
||||
|
||||
def on_any_event(self, event):
|
||||
"""Catch-all event handler.
|
||||
|
||||
Args:
|
||||
event: The event object representing the file system event.
|
||||
"""
|
||||
if not ENABLE_RESCAN_ON_FILESYSTEM_CHANGE:
|
||||
return
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import sys
|
||||
from rq import Worker, Queue, Connection
|
||||
|
||||
from config import ENABLE_EXPERIMENTAL_REDIS
|
||||
from rq import Connection, Queue, Worker
|
||||
from utils.redis import redis_client
|
||||
|
||||
listen = ["high", "default", "low"]
|
||||
@@ -15,5 +15,3 @@ if __name__ == "__main__":
|
||||
with Connection(redis_client):
|
||||
worker = Worker(map(Queue, listen))
|
||||
worker.work()
|
||||
|
||||
|
||||
|
||||
1192
poetry.lock
generated
1192
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user