diff --git a/.gitignore b/.gitignore index 93aad4671..b4d24124e 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ romm_mock # testing backend/romm_test/resources +backend/romm_test/assets backend/romm_test/logs backend/romm_test/config .pytest_cache diff --git a/DEVELOPER-SETUP.md b/DEVELOPER-SETUP.md index d5c664630..27874ff54 100644 --- a/DEVELOPER-SETUP.md +++ b/DEVELOPER-SETUP.md @@ -8,6 +8,7 @@ mkdir -p romm_mock/library/roms/switch touch romm_mock/library/roms/switch/metroid.xci mkdir -p romm_mock/resources +mkdir -p romm_mock/assets mkdir -p romm_mock/config touch romm_mock/config.yml ``` @@ -87,6 +88,7 @@ npm install ```sh mkdir assets/romm ln -s ../../../romm_mock/resources assets/romm/resources +ln -s ../../../romm_mock/assets assets/romm/assets ``` ### - Run the frontend diff --git a/backend/alembic/versions/0019_migrate_to_mysql.py b/backend/alembic/versions/0019_migrate_to_mysql.py new file mode 100644 index 000000000..38574cc7d --- /dev/null +++ b/backend/alembic/versions/0019_migrate_to_mysql.py @@ -0,0 +1,58 @@ +"""empty message + +Revision ID: 0019_migrate_to_mysql +Revises: 0018_increase_file_extension +Create Date: 2024-01-24 13:54:32.458301 + +""" +import os +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from config import ROMM_DB_DRIVER +from config.config_manager import ConfigManager, SQLITE_DB_BASE_PATH + + +# revision identifiers, used by Alembic. +revision = '0019_migrate_to_mysql' +down_revision = '0018_increase_file_extension' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + if ROMM_DB_DRIVER != "mariadb": + raise Exception("Version 3.0 requires MariaDB as database driver!") + + # Skip if sqlite database is not mounted + if not os.path.exists(f"{SQLITE_DB_BASE_PATH}/romm.db"): + return + + maria_engine = create_engine(ConfigManager.get_db_engine(), pool_pre_ping=True) + maria_session = sessionmaker(bind=maria_engine, expire_on_commit=False) + + sqlite_engine = create_engine(f"sqlite:////{SQLITE_DB_BASE_PATH}/romm.db", pool_pre_ping=True) + sqlite_session = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + + # Copy all data from sqlite to maria + with maria_session.begin() as maria_conn: + with sqlite_session.begin() as sqlite_conn: + maria_conn.execute(text("SET FOREIGN_KEY_CHECKS=0")) + + tables = sqlite_conn.execute(text("SELECT name FROM sqlite_master WHERE type='table';")).fetchall() + for table_name in tables: + table_name = table_name[0] + if table_name == "alembic_version": + continue + + table_data = sqlite_conn.execute(text(f"SELECT * FROM {table_name}")).fetchall() + + # Insert data into MariaDB table + for row in table_data: + maria_conn.execute(text(f"INSERT INTO {table_name} VALUES {tuple(row)}")) + + maria_conn.execute(text("SET FOREIGN_KEY_CHECKS=1")) + + +def downgrade() -> None: + pass diff --git a/backend/alembic/versions/0020_assets_user_id.py b/backend/alembic/versions/0020_assets_user_id.py new file mode 100644 index 000000000..a29d442dc --- /dev/null +++ b/backend/alembic/versions/0020_assets_user_id.py @@ -0,0 +1,56 @@ +"""empty message + +Revision ID: 0020_assets_user_id +Revises: 0019_migrate_to_mysql +Create Date: 2024-02-01 17:08:02.103046 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0020_assets_user_id" +down_revision = "0019_migrate_to_mysql" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("saves", schema=None) as batch_op: + batch_op.add_column(sa.Column("user_id", sa.Integer(), nullable=False)) + batch_op.create_foreign_key( + "saves_user_FK", "users", ["user_id"], ["id"], ondelete="CASCADE" + ) + + with op.batch_alter_table("screenshots", schema=None) as batch_op: + batch_op.add_column(sa.Column("user_id", sa.Integer(), nullable=False)) + batch_op.create_foreign_key( + "screenshots_user_FK", "users", ["user_id"], ["id"], ondelete="CASCADE" + ) + + with op.batch_alter_table("states", schema=None) as batch_op: + batch_op.add_column(sa.Column("user_id", sa.Integer(), nullable=False)) + batch_op.create_foreign_key( + "states_user_FK", "users", ["user_id"], ["id"], ondelete="CASCADE" + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("states", schema=None) as batch_op: + batch_op.drop_constraint("states_user_FK", type_="foreignkey") + batch_op.drop_column("user_id") + + with op.batch_alter_table("screenshots", schema=None) as batch_op: + batch_op.drop_constraint("screenshots_user_FK", type_="foreignkey") + batch_op.drop_column("user_id") + + with op.batch_alter_table("saves", schema=None) as batch_op: + batch_op.drop_constraint("saves_user_FK", type_="foreignkey") + batch_op.drop_column("user_id") + + # ### end Alembic commands ### diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 7b58c5a75..2c9310bcd 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -14,9 +14,10 @@ ROMM_HOST: Final = os.environ.get("ROMM_HOST", DEV_HOST) # PATHS ROMM_BASE_PATH: Final = os.environ.get("ROMM_BASE_PATH", "/romm") LIBRARY_BASE_PATH: Final = f"{ROMM_BASE_PATH}/library" -FRONTEND_LIBRARY_PATH: Final = "/assets/romm/library" +RESOURCES_BASE_PATH: Final = f"{ROMM_BASE_PATH}/resources" +ASSETS_BASE_PATH: Final = f"{ROMM_BASE_PATH}/assets" FRONTEND_RESOURCES_PATH: Final = "/assets/romm/resources" - +FRONTEND_ASSETS_PATH: Final = "/assets/romm/assets" # DEFAULT RESOURCES DEFAULT_URL_COVER_L: Final = ( "https://images.igdb.com/igdb/image/upload/t_cover_big/nocover.png" diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index 9933eeb49..9549bc15b 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -38,9 +38,6 @@ class Config: PLATFORMS_BINDING: dict[str, str] PLATFORMS_VERSIONS: dict[str, str] ROMS_FOLDER_NAME: str - SAVES_FOLDER_NAME: str - STATES_FOLDER_NAME: str - SCREENSHOTS_FOLDER_NAME: str HIGH_PRIO_STRUCTURE_PATH: str def __init__(self, **entries): @@ -133,15 +130,6 @@ class ConfigManager: ROMS_FOLDER_NAME=pydash.get( self._raw_config, "filesystem.roms_folder", "roms" ), - SAVES_FOLDER_NAME=pydash.get( - self._raw_config, "filesystem.saves_folder", "saves" - ), - STATES_FOLDER_NAME=pydash.get( - self._raw_config, "filesystem.states_folder", "states" - ), - SCREENSHOTS_FOLDER_NAME=pydash.get( - self._raw_config, "filesystem.screenshots_folder", "screenshots" - ), ) def _validate_config(self): @@ -212,40 +200,6 @@ class ConfigManager: ) sys.exit(3) - if not isinstance(self.config.SAVES_FOLDER_NAME, str): - log.critical("Invalid config.yml: filesystem.saves_folder must be a string") - sys.exit(3) - - if self.config.SAVES_FOLDER_NAME == "": - log.critical( - "Invalid config.yml: filesystem.saves_folder cannot be an empty string" - ) - sys.exit(3) - - if not isinstance(self.config.STATES_FOLDER_NAME, str): - log.critical( - "Invalid config.yml: filesystem.states_folder must be a string" - ) - sys.exit(3) - - if self.config.STATES_FOLDER_NAME == "": - log.critical( - "Invalid config.yml: filesystem.states_folder cannot be an empty string" - ) - sys.exit(3) - - if not isinstance(self.config.SCREENSHOTS_FOLDER_NAME, str): - log.critical( - "Invalid config.yml: filesystem.screenshots_folder must be a string" - ) - sys.exit(3) - - if self.config.SCREENSHOTS_FOLDER_NAME == "": - log.critical( - "Invalid config.yml: filesystem.screenshots_folder cannot be an empty string" - ) - sys.exit(3) - def read_config(self) -> None: try: with open(self.config_file) as config_file: diff --git a/backend/config/tests/test_config_loader.py b/backend/config/tests/test_config_loader.py index 35d563534..96abb08f3 100644 --- a/backend/config/tests/test_config_loader.py +++ b/backend/config/tests/test_config_loader.py @@ -18,9 +18,6 @@ def test_config_loader(): assert loader.config.PLATFORMS_BINDING == {"gc": "ngc"} assert loader.config.PLATFORMS_VERSIONS == {"naomi": "arcade"} assert loader.config.ROMS_FOLDER_NAME == "ROMS" - assert loader.config.SAVES_FOLDER_NAME == "SAVES" - assert loader.config.STATES_FOLDER_NAME == "STATES" - assert loader.config.SCREENSHOTS_FOLDER_NAME == "SCREENSHOTS" def test_empty_config_loader(): @@ -35,6 +32,3 @@ def test_empty_config_loader(): assert loader.config.PLATFORMS_BINDING == {} assert loader.config.PLATFORMS_VERSIONS == {} assert loader.config.ROMS_FOLDER_NAME == "roms" - assert loader.config.SAVES_FOLDER_NAME == "saves" - assert loader.config.STATES_FOLDER_NAME == "states" - assert loader.config.SCREENSHOTS_FOLDER_NAME == "screenshots" diff --git a/backend/endpoints/raw.py b/backend/endpoints/raw.py index e65731f6e..c65fc7bc8 100644 --- a/backend/endpoints/raw.py +++ b/backend/endpoints/raw.py @@ -1,17 +1,39 @@ from fastapi import APIRouter, Request from fastapi.responses import FileResponse from decorators.auth import protected_route -from config import LIBRARY_BASE_PATH +from config import LIBRARY_BASE_PATH, ASSETS_BASE_PATH router = APIRouter() -@protected_route(router.head, "/raw/{path:path}", ["assets.read"]) +@protected_route(router.head, "/raw/roms/{path:path}", ["roms.read"]) +def head_raw_rom(request: Request, path: str): + rom_path = f"{LIBRARY_BASE_PATH}/{path}" + return FileResponse(path=rom_path, filename=path.split("/")[-1]) + + +@protected_route(router.get, "/raw/roms/{path:path}", ["roms.read"]) +def get_raw_rom(request: Request, path: str): + """Download a single rom file + + Args: + request (Request): Fastapi Request object + + Returns: + FileResponse: Returns a single rom file + """ + + rom_path = f"{LIBRARY_BASE_PATH}/{path}" + return FileResponse(path=rom_path, filename=path.split("/")[-1]) + + +@protected_route(router.head, "/raw/assets/{path:path}", ["assets.read"]) def head_raw_asset(request: Request, path: str): - asset_path = f"{LIBRARY_BASE_PATH}/{path}" + asset_path = f"{ASSETS_BASE_PATH}/{path}" return FileResponse(path=asset_path, filename=path.split("/")[-1]) -@protected_route(router.get, "/raw/{path:path}", ["assets.read"]) + +@protected_route(router.get, "/raw/assets/{path:path}", ["assets.read"]) def get_raw_asset(request: Request, path: str): """Download a single asset file @@ -22,6 +44,5 @@ def get_raw_asset(request: Request, path: str): FileResponse: Returns a single asset file """ - asset_path = f"{LIBRARY_BASE_PATH}/{path}" - + asset_path = f"{ASSETS_BASE_PATH}/{path}" return FileResponse(path=asset_path, filename=path.split("/")[-1]) diff --git a/backend/endpoints/responses/config.py b/backend/endpoints/responses/config.py index e9714c932..f6818700f 100644 --- a/backend/endpoints/responses/config.py +++ b/backend/endpoints/responses/config.py @@ -11,7 +11,4 @@ class ConfigResponse(TypedDict): PLATFORMS_BINDING: dict[str, str] PLATFORMS_VERSIONS: dict[str, str] ROMS_FOLDER_NAME: str - SAVES_FOLDER_NAME: str - STATES_FOLDER_NAME: str - SCREENSHOTS_FOLDER_NAME: str HIGH_PRIO_STRUCTURE_PATH: str diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index d280bff61..abdcc2230 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -46,7 +46,6 @@ class RomSchema(BaseModel): url_screenshots: list[str] merged_screenshots: list[str] full_path: str - download_path: str class Config: from_attributes = True diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 9fb196d3a..d6bb937b2 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -20,7 +20,6 @@ from fastapi_pagination.ext.sqlalchemy import paginate from handler import ( db_platform_handler, db_rom_handler, - fs_asset_handler, fs_resource_handler, fs_rom_handler, ) @@ -257,7 +256,7 @@ async def update_rom( ) cleaned_data.update( - fs_asset_handler.get_rom_screenshots( + fs_resource_handler.get_rom_screenshots( platform_fs_slug=platform_fs_slug, rom_name=cleaned_data["name"], url_screenshots=cleaned_data.get("url_screenshots", []), diff --git a/backend/endpoints/saves.py b/backend/endpoints/saves.py index b258449cc..fc7d1bcc4 100644 --- a/backend/endpoints/saves.py +++ b/backend/endpoints/saves.py @@ -1,12 +1,15 @@ -from config.config_manager import config_manager as cm from decorators.auth import protected_route from endpoints.responses import MessageResponse from endpoints.responses.assets import UploadedSavesResponse, SaveSchema from fastapi import APIRouter, File, HTTPException, Request, UploadFile, status -from handler import db_save_handler, fs_asset_handler, db_rom_handler, db_screenshot_handler -from handler.scan_handler import scan_save, build_asset_file_path +from handler import ( + db_save_handler, + fs_asset_handler, + db_rom_handler, + db_screenshot_handler, +) +from handler.scan_handler import scan_save from logger.logger import log -from config import LIBRARY_BASE_PATH router = APIRouter() @@ -27,19 +30,18 @@ def add_saves( detail="No saves were uploaded", ) - saves_path = build_asset_file_path( - rom.platform.fs_slug, folder=cm.config.SAVES_FOLDER_NAME, emulator=emulator + saves_path = fs_asset_handler.build_saves_file_path( + user=request.user, platform_fs_slug=rom.platform.fs_slug, emulator=emulator ) for save in saves: - fs_asset_handler._write_file( - file=save, path=f"{LIBRARY_BASE_PATH}/{saves_path}" - ) + fs_asset_handler.write_file(file=save, path=saves_path) # Scan or update save scanned_save = scan_save( file_name=save.filename, - platform_slug=rom.platform.fs_slug, + user=request.user, + platform_fs_slug=rom.platform.fs_slug, emulator=emulator, ) db_save = db_save_handler.get_save_by_filename(rom.id, save.filename) @@ -50,6 +52,7 @@ def add_saves( continue scanned_save.rom_id = rom.id + scanned_save.user_id = request.user.id scanned_save.emulator = emulator db_save_handler.add_save(scanned_save) @@ -79,9 +82,7 @@ async def update_save(request: Request, id: int) -> SaveSchema: if "file" in data: file: UploadFile = data["file"] - fs_asset_handler._write_file( - file=file, path=f"{LIBRARY_BASE_PATH}/{db_save.file_path}" - ) + fs_asset_handler.write_file(file=file, path=db_save.file_path) db_save_handler.update_save(db_save.id, {"file_size_bytes": file.size}) db_save = db_save_handler.get_save(id) @@ -119,7 +120,7 @@ async def delete_saves(request: Request) -> MessageResponse: error = f"Save file {save.file_name} not found for platform {save.rom.platform_slug}" log.error(error) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error) - + if save.screenshot: db_screenshot_handler.delete_screenshot(save.screenshot.id) diff --git a/backend/endpoints/screenshots.py b/backend/endpoints/screenshots.py index b12722247..33f111ef9 100644 --- a/backend/endpoints/screenshots.py +++ b/backend/endpoints/screenshots.py @@ -1,4 +1,3 @@ -from config.config_manager import config_manager as cm from decorators.auth import protected_route from endpoints.responses.assets import UploadedScreenshotsResponse from fastapi import APIRouter, File, HTTPException, Request, UploadFile, status @@ -22,16 +21,18 @@ def add_screenshots( detail="No screenshots were uploaded", ) - screenshots_path = fs_asset_handler.build_upload_file_path( - rom.platform.fs_slug, folder=cm.config.SCREENSHOTS_FOLDER_NAME + screenshots_path = fs_asset_handler.build_screenshots_file_path( + user=request.user, platform_fs_slug=rom.platform_slug ) for screenshot in screenshots: - fs_asset_handler._write_file(file=screenshot, path=screenshots_path) + fs_asset_handler.write_file(file=screenshot, path=screenshots_path) # Scan or update screenshot scanned_screenshot = scan_screenshot( - file_name=screenshot.filename, platform_slug=rom.platform_slug + file_name=screenshot.filename, + user=request.user, + platform_fs_slug=rom.platform_slug, ) db_screenshot = db_screenshot_handler.get_screenshot_by_filename( file_name=screenshot.filename, rom_id=rom.id @@ -44,6 +45,7 @@ def add_screenshots( continue scanned_screenshot.rom_id = rom.id + scanned_screenshot.user_id = request.user.id db_screenshot_handler.add_screenshot(scanned_screenshot) rom = db_rom_handler.get_roms(rom_id) diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index 72f9b23ba..c84bd03b4 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -10,23 +10,15 @@ from exceptions.fs_exceptions import ( from handler import ( db_platform_handler, db_rom_handler, - db_save_handler, - db_screenshot_handler, - db_state_handler, - fs_asset_handler, fs_platform_handler, fs_resource_handler, fs_rom_handler, socket_handler, ) -from handler.fs_handler import Asset from handler.redis_handler import high_prio_queue, redis_url from handler.scan_handler import ( scan_platform, scan_rom, - scan_save, - scan_screenshot, - scan_state, ) from logger.logger import log @@ -129,133 +121,9 @@ async def scan_platforms( }, ) - # Scanning saves - fs_saves = fs_asset_handler.get_assets( - platform.fs_slug, rom.file_name_no_ext, Asset.SAVES - ) - if len(fs_saves) > 0: - log.info(f"\t · {len(fs_saves)} saves found") - - for fs_emulator, fs_save_filename in fs_saves: - scanned_save = scan_save( - file_name=fs_save_filename, - platform_slug=platform.fs_slug, - emulator=fs_emulator, - ) - - save = db_save_handler.get_save_by_filename(rom.id, fs_save_filename) - if save: - # Update file size if changed - if save.file_size_bytes != scanned_save.file_size_bytes: - db_save_handler.update_save( - save.id, {"file_size_bytes": scanned_save.file_size_bytes} - ) - continue - - scanned_save.emulator = fs_emulator - - if rom: - scanned_save.rom_id = rom.id - db_save_handler.add_save(scanned_save) - - # Scanning states - fs_states = fs_asset_handler.get_assets( - platform.fs_slug, rom.file_name_no_ext, Asset.STATES - ) - if len(fs_states) > 0: - log.info(f"\t · {len(fs_states)} states found") - - for fs_emulator, fs_state_filename in fs_states: - scanned_state = scan_state( - file_name=fs_state_filename, - platform_slug=platform.fs_slug, - emulator=fs_emulator, - ) - - state = db_state_handler.get_state_by_filename( - rom.id, fs_state_filename - ) - if state: - # Update file size if changed - if state.file_size_bytes != scanned_state.file_size_bytes: - db_state_handler.update_state( - state.id, {"file_size_bytes": scanned_state.file_size_bytes} - ) - - continue - - scanned_state.emulator = fs_emulator - - if rom: - scanned_state.rom_id = rom.id - db_state_handler.add_state(scanned_state) - - # Scanning screenshots - fs_screenshots = fs_asset_handler.get_assets( - platform.fs_slug, rom.file_name_no_ext, Asset.SCREENSHOTS - ) - if len(fs_screenshots) > 0: - log.info(f"\t · {len(fs_screenshots)} screenshots found") - - for _, fs_screenshot_filename in fs_screenshots: - scanned_screenshot = scan_screenshot( - file_name=fs_screenshot_filename, platform_slug=platform.fs_slug - ) - - screenshot = db_screenshot_handler.get_screenshot_by_filename( - fs_screenshot_filename - ) - if screenshot: - # Update file size if changed - if screenshot.file_size_bytes != scanned_screenshot.file_size_bytes: - db_screenshot_handler.update_screenshot( - screenshot.id, - {"file_size_bytes": scanned_screenshot.file_size_bytes}, - ) - continue - - if rom: - scanned_screenshot.rom_id = rom.id - db_screenshot_handler.add_screenshot(scanned_screenshot) - - db_save_handler.purge_saves(rom.id, [s for _e, s in fs_saves]) - db_state_handler.purge_states(rom.id, [s for _e, s in fs_states]) - db_screenshot_handler.purge_screenshots( - rom.id, [s for _e, s in fs_screenshots] - ) db_rom_handler.purge_roms( platform.id, [rom["file_name"] for rom in fs_roms] ) - - # Scanning screenshots outside platform folders - fs_screenshots = fs_asset_handler.get_screenshots() - log.info("Screenshots") - log.info(f" · {len(fs_screenshots)} screenshots found") - for fs_platform, fs_screenshot_filename in fs_screenshots: - scanned_screenshot = scan_screenshot( - file_name=fs_screenshot_filename, platform_slug=fs_platform - ) - - screenshot = db_screenshot_handler.get_screenshot_by_filename( - fs_screenshot_filename - ) - if screenshot: - # Update file size if changed - if screenshot.file_size_bytes != scanned_screenshot.file_size_bytes: - db_screenshot_handler.update_screenshot( - screenshot.id, - {"file_size_bytes": scanned_screenshot.file_size_bytes}, - ) - continue - - rom = db_rom_handler.get_rom_by_filename_no_tags( - scanned_screenshot.file_name_no_tags - ) - if rom: - scanned_screenshot.rom_id = rom.id - db_screenshot_handler.add_screenshot(scanned_screenshot) - - # db_screenshot_handler.purge_screenshots([s for _e, s in fs_screenshots]) db_platform_handler.purge_platforms(fs_platforms) log.info(emoji.emojize(":check_mark: Scan completed ")) diff --git a/backend/endpoints/states.py b/backend/endpoints/states.py index ce6fc4f44..210310009 100644 --- a/backend/endpoints/states.py +++ b/backend/endpoints/states.py @@ -1,12 +1,15 @@ -from config.config_manager import config_manager as cm from decorators.auth import protected_route from endpoints.responses import MessageResponse from endpoints.responses.assets import UploadedStatesResponse, StateSchema from fastapi import APIRouter, File, HTTPException, Request, UploadFile, status -from handler import db_state_handler, db_rom_handler, fs_asset_handler, db_screenshot_handler -from handler.scan_handler import scan_state, build_asset_file_path +from handler import ( + db_state_handler, + db_rom_handler, + fs_asset_handler, + db_screenshot_handler, +) +from handler.scan_handler import scan_state from logger.logger import log -from config import LIBRARY_BASE_PATH router = APIRouter() @@ -26,20 +29,19 @@ def add_states( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="No states were uploaded", ) - - states_path = build_asset_file_path( - rom.platform.fs_slug, folder=cm.config.STATES_FOLDER_NAME, emulator=emulator + + states_path = fs_asset_handler.build_states_file_path( + user=request.user, platform_fs_slug=rom.platform.fs_slug, emulator=emulator ) for state in states: - fs_asset_handler._write_file( - file=state, path=f"{LIBRARY_BASE_PATH}/{states_path}" - ) + fs_asset_handler.write_file(file=state, path=states_path) # Scan or update state scanned_state = scan_state( file_name=state.filename, - platform_slug=rom.platform.fs_slug, + user=request.user, + platform_fs_slug=rom.platform.fs_slug, emulator=emulator, ) db_state = db_state_handler.get_state_by_filename(rom.id, state.filename) @@ -50,6 +52,7 @@ def add_states( continue scanned_state.rom_id = rom.id + scanned_state.user_id = request.user.id scanned_state.emulator = emulator db_state_handler.add_state(scanned_state) @@ -79,9 +82,7 @@ async def update_state(request: Request, id: int) -> StateSchema: if "file" in data: file: UploadFile = data["file"] - fs_asset_handler._write_file( - file=file, path=f"{LIBRARY_BASE_PATH}/{db_state.file_path}" - ) + fs_asset_handler.write_file(file=file, path=db_state.file_path) db_state_handler.update_state(db_state.id, {"file_size_bytes": file.size}) db_state = db_state_handler.get_state(id) diff --git a/backend/endpoints/tests/test_config.py b/backend/endpoints/tests/test_config.py index 95eda409f..95115c1c0 100644 --- a/backend/endpoints/tests/test_config.py +++ b/backend/endpoints/tests/test_config.py @@ -18,6 +18,3 @@ def test_config(): assert config.get('EXCLUDED_MULTI_PARTS_FILES') == [] assert config.get('PLATFORMS_BINDING') == {} assert config.get('ROMS_FOLDER_NAME') == 'roms' - assert config.get('SAVES_FOLDER_NAME') == 'saves' - assert config.get('STATES_FOLDER_NAME') == 'states' - assert config.get('SCREENSHOTS_FOLDER_NAME') == 'screenshots' diff --git a/backend/endpoints/user.py b/backend/endpoints/user.py index e6762c2c5..bd19e1269 100644 --- a/backend/endpoints/user.py +++ b/backend/endpoints/user.py @@ -6,13 +6,15 @@ from endpoints.forms.identity import UserForm from endpoints.responses import MessageResponse from endpoints.responses.identity import UserSchema from fastapi import APIRouter, Depends, HTTPException, Request, status -from handler import auth_handler, db_user_handler, fs_resource_handler +from handler import auth_handler, db_user_handler, fs_asset_handler from models.user import Role, User router = APIRouter() -@protected_route(router.post, "/users", ["users.write"], status_code=status.HTTP_201_CREATED) +@protected_route( + router.post, "/users", ["users.write"], status_code=status.HTTP_201_CREATED +) def add_user(request: Request, username: str, password: str, role: str) -> UserSchema: """Create user endpoint @@ -131,7 +133,9 @@ def update_user( cleaned_data["username"] = form_data.username.lower() if form_data.password: - cleaned_data["hashed_password"] = auth_handler.get_password_hash(form_data.password) + cleaned_data["hashed_password"] = auth_handler.get_password_hash( + form_data.password + ) # You can't change your own role if form_data.role and request.user.id != id: @@ -142,10 +146,9 @@ def update_user( cleaned_data["enabled"] = form_data.enabled # type: ignore[assignment] if form_data.avatar is not None: - cleaned_data["avatar_path"], avatar_user_path = fs_resource_handler.build_avatar_path( - form_data.avatar.filename, form_data.username - ) - file_location = f"{avatar_user_path}/{form_data.avatar.filename}" + user_avatar_path = fs_asset_handler.build_avatar_path(user=user) + file_location = f"{user_avatar_path}/{form_data.avatar.filename}" + cleaned_data["avatar_path"] = file_location with open(file_location, "wb+") as file_object: file_object.write(form_data.avatar.file.read()) diff --git a/backend/handler/auth_handler/__init__.py b/backend/handler/auth_handler/__init__.py index 766e8b171..794bbb6ff 100644 --- a/backend/handler/auth_handler/__init__.py +++ b/backend/handler/auth_handler/__init__.py @@ -53,8 +53,7 @@ class AuthHandler: def get_password_hash(self, password): return self.pwd_context.hash(password) - @staticmethod - def clear_session(req: HTTPConnection | Request): + def clear_session(self, req: HTTPConnection | Request): session_id = req.session.get("session_id") if session_id: cache.delete(f"romm:{session_id}") # type: ignore[attr-defined] diff --git a/backend/handler/db_handler/db_roms_handler.py b/backend/handler/db_handler/db_roms_handler.py index 13b1b37d6..5771d7e58 100644 --- a/backend/handler/db_handler/db_roms_handler.py +++ b/backend/handler/db_handler/db_roms_handler.py @@ -6,8 +6,7 @@ from sqlalchemy.orm import Session class DBRomsHandler(DBHandler): - @staticmethod - def _filter(data, platform_id, search_term): + def _filter(self, data, platform_id, search_term): if platform_id: data = data.filter_by(platform_id=platform_id) @@ -21,8 +20,7 @@ class DBRomsHandler(DBHandler): return data - @staticmethod - def _order(data, order_by, order_dir): + def _order(self, data, order_by, order_dir): if order_by == "id": _column = Rom.id else: diff --git a/backend/handler/fs_handler/__init__.py b/backend/handler/fs_handler/__init__.py index 3184fcd5e..e8cc9b823 100644 --- a/backend/handler/fs_handler/__init__.py +++ b/backend/handler/fs_handler/__init__.py @@ -1,14 +1,8 @@ -import os import re -import shutil from abc import ABC from enum import Enum from typing import Final -from config import LIBRARY_BASE_PATH, ROMM_BASE_PATH -from config.config_manager import config_manager as cm - -RESOURCES_BASE_PATH: Final = f"{ROMM_BASE_PATH}/resources" 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_S: Final = 90 # Width of small cover of IGDB @@ -90,37 +84,13 @@ class FSHandler(ABC): def __init__(self) -> None: pass - @staticmethod - def get_fs_structure(fs_slug: str, folder: str = cm.config.ROMS_FOLDER_NAME): - return ( - f"{folder}/{fs_slug}" - if os.path.exists(cm.config.HIGH_PRIO_STRUCTURE_PATH) - else f"{fs_slug}/{folder}" - ) - - @staticmethod - def get_file_name_with_no_extension(file_name: str) -> str: + def get_file_name_with_no_extension(self, file_name: str) -> str: return re.sub(EXTENSION_REGEX, "", file_name).strip() - @staticmethod - def get_file_name_with_no_tags(file_name: str) -> str: - file_name_no_extension = FSHandler.get_file_name_with_no_extension(file_name) + def get_file_name_with_no_tags(self, file_name: str) -> str: + file_name_no_extension = self.get_file_name_with_no_extension(file_name) return re.split(TAG_REGEX, file_name_no_extension)[0].strip() - @staticmethod - def parse_file_extension(file_name) -> str: + def parse_file_extension(self, file_name) -> str: match = re.search(EXTENSION_REGEX, file_name) return match.group(1) if match else "" - - @staticmethod - def remove_file(file_name: str, file_path: str): - try: - os.remove(f"{LIBRARY_BASE_PATH}/{file_path}/{file_name}") - except IsADirectoryError: - shutil.rmtree(f"{LIBRARY_BASE_PATH}/{file_path}/{file_name}") - - def build_upload_file_path( - self, fs_slug: str, folder: str = cm.config.ROMS_FOLDER_NAME - ): - file_path = self.get_fs_structure(fs_slug, folder=folder) - return f"{LIBRARY_BASE_PATH}/{file_path}" diff --git a/backend/handler/fs_handler/fs_assets_handler.py b/backend/handler/fs_handler/fs_assets_handler.py index 6b8ddb012..7fcbcd938 100644 --- a/backend/handler/fs_handler/fs_assets_handler.py +++ b/backend/handler/fs_handler/fs_assets_handler.py @@ -1,130 +1,66 @@ import os import shutil from pathlib import Path -from urllib.parse import quote -import requests -from config import LIBRARY_BASE_PATH -from config.config_manager import config_manager as cm from fastapi import UploadFile -from handler.fs_handler import RESOURCES_BASE_PATH, Asset, FSHandler +from handler.fs_handler import FSHandler from logger.logger import log +from models.user import User +from config import ASSETS_BASE_PATH class FSAssetsHandler(FSHandler): def __init__(self) -> None: pass - @staticmethod - def _write_file(file: UploadFile, path: str) -> None: + def remove_file(self, file_name: str, file_path: str): + try: + os.remove(os.path.join(ASSETS_BASE_PATH, file_path, file_name)) + except IsADirectoryError: + shutil.rmtree(os.path.join(ASSETS_BASE_PATH, file_path, file_name)) + + def write_file(self, file: UploadFile, path: str) -> None: log.info(f" - Uploading {file.filename}") - file_location = f"{path}/{file.filename}" + file_location = os.path.join(ASSETS_BASE_PATH, path, file.filename) Path(path).mkdir(parents=True, exist_ok=True) with open(file_location, "wb") as f: shutil.copyfileobj(file.file, f) - @staticmethod - def _store_screenshot(fs_slug: str, rom_name: str, url: str, idx: int): - """Store roms resources in filesystem + def get_asset_size(self, file_name: str, asset_path: str) -> int: + return os.path.getsize(os.path.join(ASSETS_BASE_PATH, asset_path, file_name)) - Args: - fs_slug: short name of the platform - file_name: name of rom - url: url to get the screenshot - """ - screenshot_file: str = f"{idx}.jpg" - screenshot_path: str = f"{RESOURCES_BASE_PATH}/{fs_slug}/{rom_name}/screenshots" - res = requests.get(url, stream=True, timeout=120) - if res.status_code == 200: - Path(screenshot_path).mkdir(parents=True, exist_ok=True) - with open(f"{screenshot_path}/{screenshot_file}", "wb") as f: - shutil.copyfileobj(res.raw, f) + def user_folder_path(self, user: User): + return os.path.join("users", user.fs_safe_folder_name) - @staticmethod - def _get_screenshot_path(fs_slug: str, rom_name: str, idx: str): - """Returns rom cover filesystem path adapted to frontend folder structure - - Args: - fs_slug: short name of the platform - file_name: name of rom - idx: index number of screenshot - """ - return f"{fs_slug}/{rom_name}/screenshots/{idx}.jpg" - - def get_rom_screenshots( - self, platform_fs_slug: str, rom_name: str, url_screenshots: list - ) -> dict: - q_rom_name = quote(rom_name) - - path_screenshots: list[str] = [] - for idx, url in enumerate(url_screenshots): - self._store_screenshot(platform_fs_slug, rom_name, url, idx) - path_screenshots.append( - self._get_screenshot_path(platform_fs_slug, q_rom_name, str(idx)) - ) - - return {"path_screenshots": path_screenshots} - - def get_assets( - self, platform_slug: str, rom_file_name_no_ext: str, asset_type: Asset - ): - asset_folder_name = { - Asset.SAVES: cm.config.SAVES_FOLDER_NAME, - Asset.STATES: cm.config.STATES_FOLDER_NAME, - Asset.SCREENSHOTS: cm.config.SCREENSHOTS_FOLDER_NAME, - } - - assets_path = self.get_fs_structure( - platform_slug, folder=asset_folder_name[asset_type] + # /users/557365723a31/profile + def build_avatar_path(self, user: User): + user_avatar_path = os.path.join( + ASSETS_BASE_PATH, self.user_folder_path(user), "profile" ) + Path(user_avatar_path).mkdir(parents=True, exist_ok=True) + return user_avatar_path - assets_file_path = f"{LIBRARY_BASE_PATH}/{assets_path}" + def _build_asset_file_path( + self, user: User, folder: str, platform_fs_slug, emulator: str = None + ): + user_folder_path = self.user_folder_path(user) + if emulator: + return os.path.join(user_folder_path, folder, platform_fs_slug, emulator) + return os.path.join(user_folder_path, folder, platform_fs_slug) - assets: list[str] = [] + # /users/557365723a31/saves/n64/mupen64plus + def build_saves_file_path( + self, user: User, platform_fs_slug: str, emulator: str = None + ): + return self._build_asset_file_path(user, "saves", platform_fs_slug, emulator) - try: - emulators = list(os.walk(assets_file_path))[0][1] - for emulator in emulators: - assets += [ - (emulator, file) - for file in list(os.walk(f"{assets_file_path}/{emulator}"))[0][2] - if rom_file_name_no_ext - == self.get_file_name_with_no_extension(file) - ] + # /users/557365723a31/states/n64/mupen64plus + def build_states_file_path( + self, user: User, platform_fs_slug: str, emulator: str = None + ): + return self._build_asset_file_path(user, "states", platform_fs_slug, emulator) - assets += [ - (None, file) - for file in list(os.walk(assets_file_path))[0][2] - if rom_file_name_no_ext == self.get_file_name_with_no_extension(file) - ] - except IndexError: - pass - - return assets - - @staticmethod - def get_screenshots(): - screenshots_path = f"{LIBRARY_BASE_PATH}/{cm.config.SCREENSHOTS_FOLDER_NAME}" - - fs_screenshots = [] - - try: - platforms = list(os.walk(screenshots_path))[0][1] - for platform in platforms: - fs_screenshots += [ - (platform, file) - for file in list(os.walk(f"{screenshots_path}/{platform}"))[0][2] - ] - - fs_screenshots += [ - (None, file) for file in list(os.walk(screenshots_path))[0][2] - ] - except IndexError: - pass - - return fs_screenshots - - @staticmethod - def get_asset_size(asset_path: str, file_name: str): - return os.stat(f"{LIBRARY_BASE_PATH}/{asset_path}/{file_name}").st_size + # /users/557365723a31/screenshots/n64 + def build_screenshots_file_path(self, user: User, platform_fs_slug: str): + return self._build_asset_file_path(user, "screenshots", platform_fs_slug) diff --git a/backend/handler/fs_handler/fs_platforms_handler.py b/backend/handler/fs_handler/fs_platforms_handler.py index 3c51f7799..0c032eca0 100644 --- a/backend/handler/fs_handler/fs_platforms_handler.py +++ b/backend/handler/fs_handler/fs_platforms_handler.py @@ -10,8 +10,7 @@ class FSPlatformsHandler(FSHandler): def __init__(self) -> None: pass - @staticmethod - def _exclude_platforms(platforms: list): + def _exclude_platforms(self, platforms: list): return [ platform for platform in platforms diff --git a/backend/handler/fs_handler/fs_resources_handler.py b/backend/handler/fs_handler/fs_resources_handler.py index 240313e82..6ef6babc2 100644 --- a/backend/handler/fs_handler/fs_resources_handler.py +++ b/backend/handler/fs_handler/fs_resources_handler.py @@ -10,13 +10,13 @@ from config import ( DEFAULT_PATH_COVER_S, DEFAULT_URL_COVER_L, DEFAULT_URL_COVER_S, + RESOURCES_BASE_PATH, ) from handler.fs_handler import ( DEFAULT_HEIGHT_COVER_L, DEFAULT_HEIGHT_COVER_S, DEFAULT_WIDTH_COVER_L, DEFAULT_WIDTH_COVER_S, - RESOURCES_BASE_PATH, CoverSize, FSHandler, ) @@ -27,8 +27,7 @@ class FSResourceHandler(FSHandler): def __init__(self) -> None: pass - @staticmethod - def _cover_exists(fs_slug: str, rom_name: str, size: CoverSize): + def _cover_exists(self, fs_slug: str, rom_name: str, size: CoverSize): """Check if rom cover exists in filesystem Args: @@ -44,8 +43,7 @@ class FSResourceHandler(FSHandler): ) ) - @staticmethod - def _resize_cover(cover_path: str, size: CoverSize) -> None: + def _resize_cover(self, cover_path: str, size: CoverSize) -> None: """Resizes the cover image to the standard size Args: @@ -99,8 +97,7 @@ class FSResourceHandler(FSHandler): shutil.copyfileobj(res.raw, f) self._resize_cover(f"{cover_path}/{cover_file}", size) - @staticmethod - def _get_cover_path(fs_slug: str, rom_name: str, size: CoverSize): + def _get_cover_path(self, fs_slug: str, rom_name: str, size: CoverSize): """Returns rom cover filesystem path adapted to frontend folder structure Args: @@ -152,8 +149,7 @@ class FSResourceHandler(FSHandler): if not self._cover_exists("default", "default", cover["size"]): self._store_cover("default", "default", cover["url"], cover["size"]) - @staticmethod - def build_artwork_path(rom_name: str, fs_slug: str, file_ext: str): + def build_artwork_path(self, rom_name: str, fs_slug: str, file_ext: str): q_rom_name = quote(rom_name) strtime = str(datetime.datetime.now().timestamp()) @@ -162,9 +158,43 @@ class FSResourceHandler(FSHandler): artwork_path = f"{RESOURCES_BASE_PATH}/{fs_slug}/{rom_name}/cover" Path(artwork_path).mkdir(parents=True, exist_ok=True) return path_cover_l, path_cover_s, artwork_path + + def _store_screenshot(self, fs_slug: str, rom_name: str, url: str, idx: int): + """Store roms resources in filesystem - @staticmethod - def build_avatar_path(avatar_path: str, username: str): - avatar_user_path = f"{RESOURCES_BASE_PATH}/users/{username}" - Path(avatar_user_path).mkdir(parents=True, exist_ok=True) - return f"users/{username}/{avatar_path}", avatar_user_path + Args: + fs_slug: short name of the platform + file_name: name of rom + url: url to get the screenshot + """ + screenshot_file = f"{idx}.jpg" + screenshot_path = f"{RESOURCES_BASE_PATH}/{fs_slug}/{rom_name}/screenshots" + res = requests.get(url, stream=True, timeout=120) + if res.status_code == 200: + Path(screenshot_path).mkdir(parents=True, exist_ok=True) + with open(f"{screenshot_path}/{screenshot_file}", "wb") as f: + shutil.copyfileobj(res.raw, f) + + def _get_screenshot_path(self, fs_slug: str, rom_name: str, idx: str): + """Returns rom cover filesystem path adapted to frontend folder structure + + Args: + fs_slug: short name of the platform + file_name: name of rom + idx: index number of screenshot + """ + return f"{fs_slug}/{rom_name}/screenshots/{idx}.jpg" + + def get_rom_screenshots( + self, platform_fs_slug: str, rom_name: str, url_screenshots: list + ) -> dict: + q_rom_name = quote(rom_name) + + path_screenshots: list[str] = [] + for idx, url in enumerate(url_screenshots): + self._store_screenshot(platform_fs_slug, rom_name, url, idx) + path_screenshots.append( + self._get_screenshot_path(platform_fs_slug, q_rom_name, str(idx)) + ) + + return {"path_screenshots": path_screenshots} diff --git a/backend/handler/fs_handler/fs_roms_handler.py b/backend/handler/fs_handler/fs_roms_handler.py index 575888823..acbfd6a1a 100644 --- a/backend/handler/fs_handler/fs_roms_handler.py +++ b/backend/handler/fs_handler/fs_roms_handler.py @@ -2,6 +2,7 @@ import fnmatch import os import re from pathlib import Path +import shutil from config import LIBRARY_BASE_PATH from config.config_manager import config_manager as cm @@ -21,8 +22,20 @@ class FSRomsHandler(FSHandler): def __init__(self) -> None: pass - @staticmethod - def parse_tags(file_name: str) -> tuple: + def get_fs_structure(self, fs_slug: str): + return ( + f"{cm.config.ROMS_FOLDER_NAME}/{fs_slug}" + if os.path.exists(cm.config.HIGH_PRIO_STRUCTURE_PATH) + else f"{fs_slug}/{cm.config.ROMS_FOLDER_NAME}" + ) + + def remove_file(self, file_name: str, file_path: str): + try: + os.remove(f"{LIBRARY_BASE_PATH}/{file_path}/{file_name}") + except IsADirectoryError: + shutil.rmtree(f"{LIBRARY_BASE_PATH}/{file_path}/{file_name}") + + def parse_tags(self, file_name: str) -> tuple: rev = "" regs = [] langs = [] @@ -91,8 +104,7 @@ class FSRomsHandler(FSHandler): # Return files that are not in the filtered list. return [f for f in files if f not in excluded_files] - @staticmethod - def _exclude_multi_roms(roms) -> list[str]: + def _exclude_multi_roms(self, roms) -> list[str]: excluded_names = cm.config.EXCLUDED_MULTI_FILES filtered_files: list = [] @@ -148,9 +160,8 @@ class FSRomsHandler(FSHandler): for rom in fs_roms ] - @staticmethod def get_rom_file_size( - roms_path: str, file_name: str, multi: bool, multi_files: list = [] + self, roms_path: str, file_name: str, multi: bool, multi_files: list = [] ): files = ( [f"{LIBRARY_BASE_PATH}/{roms_path}/{file_name}"] @@ -162,8 +173,7 @@ class FSRomsHandler(FSHandler): ) return sum([os.stat(file).st_size for file in files]) - @staticmethod - def file_exists(path: str, file_name: str): + def file_exists(self, path: str, file_name: str): """Check if file exists in filesystem Args: @@ -183,3 +193,7 @@ class FSRomsHandler(FSHandler): f"{LIBRARY_BASE_PATH}/{file_path}/{old_name}", f"{LIBRARY_BASE_PATH}/{file_path}/{new_name}", ) + + def build_upload_file_path(self, fs_slug: str): + file_path = self.get_fs_structure(fs_slug) + return f"{LIBRARY_BASE_PATH}/{file_path}" diff --git a/backend/handler/gh_handler.py b/backend/handler/gh_handler.py index b0d503ce1..d8cf0a14f 100644 --- a/backend/handler/gh_handler.py +++ b/backend/handler/gh_handler.py @@ -11,8 +11,7 @@ class GHHandler: def __init__(self) -> None: pass - @staticmethod - def get_version() -> str: + def get_version(self) -> str: """Returns current version or branch name.""" if not __version__ == "": return __version__ diff --git a/backend/handler/igdb_handler.py b/backend/handler/igdb_handler.py index 0ea52b6ae..90e4141d1 100644 --- a/backend/handler/igdb_handler.py +++ b/backend/handler/igdb_handler.py @@ -82,8 +82,7 @@ class IGDBHandler: return wrapper - @staticmethod - def normalize_search_term(search_term: str) -> str: + def _normalize_search_term(self, search_term: str) -> str: return ( search_term.replace("\u2122", "") # Remove trademark symbol .replace("\u00ae", "") # Remove registered symbol @@ -143,8 +142,7 @@ class IGDBHandler: return pydash.get(exact_matches or roms, "[0]", {}) - @staticmethod - def _normalize_cover_url(url: str) -> str: + def _normalize_cover_url(self, url: str) -> str: return f"https:{url.replace('https:', '')}" def _search_cover(self, rom_id: int) -> str: @@ -172,8 +170,7 @@ class IGDBHandler: if "url" in r.keys() ] - @staticmethod - async def _ps2_opl_format(match: re.Match[str], search_term: str) -> str: + async def _ps2_opl_format(self, match: re.Match[str], search_term: str) -> str: serial_code = match.group(1) with open(PS2_OPL_INDEX_FILE, "r") as index_json: @@ -184,8 +181,7 @@ class IGDBHandler: return search_term - @staticmethod - async def _switch_titledb_format(match: re.Match[str], search_term: str) -> str: + async def _switch_titledb_format(self, match: re.Match[str], search_term: str) -> str: titledb_index = {} title_id = match.group(1) @@ -207,8 +203,7 @@ class IGDBHandler: return search_term - @staticmethod - async def _switch_productid_format(match: re.Match[str], search_term: str) -> str: + async def _switch_productid_format(self, match: re.Match[str], search_term: str) -> str: product_id_index = {} product_id = match.group(1) @@ -317,7 +312,7 @@ class IGDBHandler: if platform_idgb_id in ARCADE_IGDB_IDS: search_term = await self._mame_format(search_term) - search_term = self.normalize_search_term(search_term) + search_term = self._normalize_search_term(search_term) res = ( self._search_rom(uc(search_term), platform_idgb_id, MAIN_GAME_CATEGORY) diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 475e1bdfc..51e9828da 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -1,4 +1,3 @@ -import os from typing import Any import emoji @@ -14,6 +13,7 @@ from logger.logger import log from models.assets import Save, Screenshot, State from models.platform import Platform from models.rom import Rom +from models.user import User SWAPPED_PLATFORM_BINDINGS = dict((v, k) for k, v in cm.config.PLATFORMS_BINDING.items()) @@ -160,7 +160,7 @@ async def scan_rom( ) ) rom_attrs.update( - fs_asset_handler.get_rom_screenshots( + fs_resource_handler.get_rom_screenshots( platform_fs_slug=platform.slug, rom_name=rom_attrs["name"], url_screenshots=rom_attrs["url_screenshots"], @@ -185,34 +185,28 @@ def _scan_asset(file_name: str, path: str): } -def build_asset_file_path(fs_slug: str, folder: str, emulator: str = None): - saves_path = fs_asset_handler.get_fs_structure(fs_slug, folder=folder) - - if emulator: - return os.path.join(saves_path, emulator) - - return saves_path - - -def scan_save(file_name: str, platform_slug: str, emulator: str = None) -> Save: - saves_path = build_asset_file_path( - platform_slug, folder=cm.config.SAVES_FOLDER_NAME, emulator=emulator +def scan_save( + file_name: str, user: User, platform_fs_slug: str, emulator: str = None +) -> Save: + saves_path = fs_asset_handler.build_saves_file_path( + user=user, platform_fs_slug=platform_fs_slug, emulator=emulator ) return Save(**_scan_asset(file_name, saves_path)) -def scan_state(file_name: str, platform_slug: str, emulator: str = None) -> State: - states_path = build_asset_file_path( - platform_slug, folder=cm.config.STATES_FOLDER_NAME, emulator=emulator +def scan_state( + file_name: str, user: User, platform_fs_slug: str, emulator: str = None +) -> State: + states_path = fs_asset_handler.build_states_file_path( + user=user, platform_fs_slug=platform_fs_slug, emulator=emulator ) return State(**_scan_asset(file_name, states_path)) -def scan_screenshot(file_name: str, platform_slug: str = None) -> Screenshot: - if not platform_slug: - return Screenshot(**_scan_asset(file_name, cm.config.SCREENSHOTS_FOLDER_NAME)) - - screenshots_path = build_asset_file_path( - platform_slug, folder=cm.config.SCREENSHOTS_FOLDER_NAME +def scan_screenshot( + file_name: str, user: User, platform_fs_slug: str = None +) -> Screenshot: + screenshots_path = fs_asset_handler.build_screenshots_file_path( + user=user, platform_fs_slug=platform_fs_slug ) return Screenshot(**_scan_asset(file_name, screenshots_path)) diff --git a/backend/models/assets.py b/backend/models/assets.py index c11e1dd87..e9f8e27b8 100644 --- a/backend/models/assets.py +++ b/backend/models/assets.py @@ -27,6 +27,9 @@ class BaseAsset(BaseModel): rom_id = Column( Integer(), ForeignKey("roms.id", ondelete="CASCADE"), nullable=False ) + user_id = Column( + Integer(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) @cached_property def full_path(self) -> str: @@ -34,7 +37,7 @@ class BaseAsset(BaseModel): @cached_property def download_path(self) -> str: - return f"/api/raw/{self.full_path}?timestamp={self.updated_at}" + return f"/api/raw/assets/{self.full_path}?timestamp={self.updated_at}" class Save(BaseAsset): @@ -44,6 +47,7 @@ class Save(BaseAsset): emulator = Column(String(length=50), nullable=True) rom = relationship("Rom", lazy="selectin", back_populates="saves") + user = relationship("User", lazy="selectin", back_populates="saves") @cached_property def screenshot(self) -> Optional["Screenshot"]: @@ -64,6 +68,7 @@ class State(BaseAsset): emulator = Column(String(length=50), nullable=True) rom = relationship("Rom", lazy="selectin", back_populates="states") + user = relationship("User", lazy="selectin", back_populates="states") @cached_property def screenshot(self) -> Optional["Screenshot"]: @@ -82,3 +87,4 @@ class Screenshot(BaseAsset): __table_args__ = {"extend_existing": True} rom = relationship("Rom", lazy="selectin", back_populates="screenshots") + user = relationship("User", lazy="selectin", back_populates="screenshots") diff --git a/backend/models/rom.py b/backend/models/rom.py index 9e8df5363..69117e859 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -8,7 +8,16 @@ from config import ( ) from models.assets import Save, Screenshot, State from models.base import BaseModel -from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String, Text, BigInteger +from sqlalchemy import ( + JSON, + Boolean, + Column, + ForeignKey, + Integer, + String, + Text, + BigInteger, +) from sqlalchemy.orm import Mapped, relationship SORT_COMPARE_REGEX = r"^([Tt]he|[Aa]|[Aa]nd)\s" @@ -86,7 +95,7 @@ class Rom(BaseModel): @cached_property def download_path(self) -> str: - return f"/api/raw/{self.full_path}" + return f"/api/raw/roms/{self.full_path}" @cached_property def has_cover(self) -> bool: diff --git a/backend/models/user.py b/backend/models/user.py index 233377a82..f05d5a2cd 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -1,8 +1,10 @@ import enum from models.base import BaseModel +from models.assets import Save, Screenshot, State from sqlalchemy import Boolean, Column, Enum, Integer, String from starlette.authentication import SimpleUser +from sqlalchemy.orm import Mapped, relationship class Role(enum.Enum): @@ -23,6 +25,18 @@ class User(BaseModel, SimpleUser): role: Role = Column(Enum(Role), default=Role.VIEWER) avatar_path: str = Column(String(length=255), default="") + saves: Mapped[list[Save]] = relationship( + "Save", + lazy="selectin", + back_populates="user", + ) + states: Mapped[list[State]] = relationship( + "State", lazy="selectin", back_populates="user" + ) + screenshots: Mapped[list[Screenshot]] = relationship( + "Screenshot", lazy="selectin", back_populates="user" + ) + @property def oauth_scopes(self): from handler.auth_handler import DEFAULT_SCOPES, FULL_SCOPES, WRITE_SCOPES @@ -34,3 +48,8 @@ class User(BaseModel, SimpleUser): return WRITE_SCOPES return DEFAULT_SCOPES + + @property + def fs_safe_folder_name(self): + # Uses the ID to avoid issues with username changes + return f'User:{self.id}'.encode("utf-8").hex() diff --git a/docker/Dockerfile b/docker/Dockerfile index 7683e6514..17820ea93 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -14,6 +14,7 @@ COPY ./frontend/assets/platforms ${WEBSERVER_FOLDER}/assets/platforms COPY ./frontend/assets/webrcade/feed ${WEBSERVER_FOLDER}/assets/webrcade/feed RUN mkdir -p ${WEBSERVER_FOLDER}/assets/romm && \ ln -s /romm/resources ${WEBSERVER_FOLDER}/assets/romm/resources + ln -s /romm/assets ${WEBSERVER_FOLDER}/assets/romm/assets # install generall required packages RUN apk add --upgrade \ diff --git a/examples/config.example.yml b/examples/config.example.yml index 25f4aca3e..30e0dac81 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -43,6 +43,3 @@ system: filesystem: roms_folder: 'roms' # The folder where your roms are located - saves_folder: 'saves' # The folder where your save files are located - states_folder: 'states' # The folder where your states are located - screenshots_folder: 'screenshots' # The folder where your screenshots are located diff --git a/examples/docker-compose.example.yml b/examples/docker-compose.example.yml index 5af89a814..2cb3749ba 100644 --- a/examples/docker-compose.example.yml +++ b/examples/docker-compose.example.yml @@ -40,7 +40,8 @@ services: - SCHEDULED_UPDATE_MAME_XML_CRON=0 5 * * * # Cron expression for the scheduled update (default: 0 5 * * * - At 5:00 AM every day) volumes: - "/path/to/library:/romm/library" - - "/path/to/resources:/romm/resources" # [Optional] Path where roms metadata (covers) are stored + - "/path/to/assets:/romm/assets" # Path where saves, states and other assets are stored + - "/path/to/resources:/romm/resources" # Path where roms metadata (covers) are stored - "/path/to/config:/romm/config" # [Optional] Path where config is stored - "/path/to/database:/romm/database" # [Optional] Only needed if ROMM_DB_DRIVER=sqlite or not set - "/path/to/logs:/romm/logs" # [Optional] Path where logs are stored diff --git a/frontend/src/__generated__/models/ConfigResponse.ts b/frontend/src/__generated__/models/ConfigResponse.ts index b8d852fd6..35eedd2ac 100644 --- a/frontend/src/__generated__/models/ConfigResponse.ts +++ b/frontend/src/__generated__/models/ConfigResponse.ts @@ -13,9 +13,6 @@ export type ConfigResponse = { PLATFORMS_BINDING: Record; PLATFORMS_VERSIONS: Record; ROMS_FOLDER_NAME: string; - SAVES_FOLDER_NAME: string; - STATES_FOLDER_NAME: string; - SCREENSHOTS_FOLDER_NAME: string; HIGH_PRIO_STRUCTURE_PATH: string; }; diff --git a/frontend/src/__generated__/models/EnhancedRomSchema.ts b/frontend/src/__generated__/models/EnhancedRomSchema.ts index ced6e4cc3..44ce702b1 100644 --- a/frontend/src/__generated__/models/EnhancedRomSchema.ts +++ b/frontend/src/__generated__/models/EnhancedRomSchema.ts @@ -41,7 +41,6 @@ export type EnhancedRomSchema = { url_screenshots: Array; merged_screenshots: Array; full_path: string; - download_path: string; sibling_roms: Array; }; diff --git a/frontend/src/__generated__/models/RomSchema.ts b/frontend/src/__generated__/models/RomSchema.ts index 991fed560..259c53dd0 100644 --- a/frontend/src/__generated__/models/RomSchema.ts +++ b/frontend/src/__generated__/models/RomSchema.ts @@ -40,6 +40,5 @@ export type RomSchema = { url_screenshots: Array; merged_screenshots: Array; full_path: string; - download_path: string; }; diff --git a/frontend/src/components/Dialog/User/EditUser.vue b/frontend/src/components/Dialog/User/EditUser.vue index a6e0b8cbf..6003af0ab 100644 --- a/frontend/src/components/Dialog/User/EditUser.vue +++ b/frontend/src/components/Dialog/User/EditUser.vue @@ -122,7 +122,7 @@ function closeDialog() { diff --git a/frontend/src/components/Drawer/Footer.vue b/frontend/src/components/Drawer/Footer.vue index 2f6439f09..e609d4113 100644 --- a/frontend/src/components/Drawer/Footer.vue +++ b/frontend/src/components/Drawer/Footer.vue @@ -61,7 +61,7 @@ async function logout() { diff --git a/frontend/src/views/Settings/ControlPanel/Users/Base.vue b/frontend/src/views/Settings/ControlPanel/Users/Base.vue index b581c1294..c3e8fa43c 100644 --- a/frontend/src/views/Settings/ControlPanel/Users/Base.vue +++ b/frontend/src/views/Settings/ControlPanel/Users/Base.vue @@ -123,7 +123,7 @@ onMounted(() => {