docstrings added to endpoints + organized imports + formatted code

This commit is contained in:
Zurdi
2024-01-09 16:22:31 +01:00
parent aea563e8ae
commit eea7e41940
37 changed files with 1268 additions and 993 deletions

View File

@@ -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")

View File

@@ -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", []
)

View File

@@ -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,

View File

@@ -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":

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,4 @@
import requests
from config import STEAMGRIDDB_API_KEY
from logger.logger import log

View File

@@ -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)

View File

@@ -2,6 +2,7 @@ import logging
from logger import COLORS
class StdoutFormatter(logging.Formatter):
level: str = "%(levelname)s"
dots: str = ":"

View File

@@ -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:

View File

@@ -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]

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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):

View File

@@ -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:

View File

@@ -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

View File

@@ -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 = (

View File

@@ -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 = (

View File

@@ -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)

View File

@@ -1,5 +1,6 @@
import re
import subprocess as sp
from __version__ import __version__
LANGUAGES = [

View File

@@ -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")

View File

@@ -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:

View File

@@ -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())

View File

@@ -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 =========

View File

@@ -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

View File

@@ -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
)

View File

@@ -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="*",

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff