diff --git a/backend/alembic/versions/0022_collections_.py b/backend/alembic/versions/0022_collections_.py new file mode 100644 index 000000000..7c11b82af --- /dev/null +++ b/backend/alembic/versions/0022_collections_.py @@ -0,0 +1,125 @@ +"""empty message + +Revision ID: 0022_collections +Revises: 0021_rom_user +Create Date: 2024-07-01 23:23:39.090219 + +""" + +import json +import os +import shutil + +import sqlalchemy as sa +from alembic import op +from config import RESOURCES_BASE_PATH +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = "0022_collections" +down_revision = "0021_rom_user" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "collections", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(length=400), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("path_cover_l", sa.String(length=1000), nullable=True), + sa.Column("path_cover_s", sa.String(length=1000), nullable=True), + sa.Column("url_cover", sa.Text(), nullable=True), + sa.Column("roms", sa.JSON(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("is_public", sa.Boolean(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("rom_user", schema=None) as batch_op: + batch_op.alter_column( + "is_main_sibling", + existing_type=mysql.TINYINT(display_width=1), + nullable=True, + ) + + connection = op.get_bind() + roms = connection.execute( + sa.text( + "SELECT id, name, platform_id, path_cover_s, path_cover_l, path_screenshots FROM roms" + ) + ).fetchall() + + # Define the path for the new folder + roms_folder_path = os.path.join(RESOURCES_BASE_PATH, "roms") + + # Create the new folder if it doesn't exist + os.makedirs(roms_folder_path, exist_ok=True) + + # List all items in the base directory + for folder in os.listdir(RESOURCES_BASE_PATH): + folder_path = os.path.join(RESOURCES_BASE_PATH, folder) + + # Check if the item is a directory and not the new folder itself + if os.path.isdir(folder_path) and folder != "roms": + # Move the folder to the new folder + shutil.move(folder_path, roms_folder_path) + + # Update paths for each rom + for rom in roms: + path_cover_s = rom.path_cover_s + path_cover_l = rom.path_cover_l + path_screenshots = rom.path_screenshots + + # Add "roms/" prefix to path_cover_s and path_cover_l + if path_cover_s: + path_cover_s = f"roms/{path_cover_s}" + if path_cover_l: + path_cover_l = f"roms/{path_cover_l}" + + # Add "roms/" prefix to each path in path_screenshots + if path_screenshots: + path_screenshots_list = json.loads(path_screenshots) + path_screenshots_list = [f"roms/{path}" for path in path_screenshots_list] + path_screenshots = json.dumps(path_screenshots_list) + + # Update the database with the new paths + connection.execute( + sa.text( + "UPDATE roms SET path_cover_s = :path_cover_s, path_cover_l = :path_cover_l, path_screenshots = :path_screenshots WHERE id = :id" + ), + { + "path_cover_s": path_cover_s, + "path_cover_l": path_cover_l, + "path_screenshots": path_screenshots, + "id": rom.id, + }, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("rom_user", schema=None) as batch_op: + batch_op.alter_column( + "is_main_sibling", + existing_type=mysql.TINYINT(display_width=1), + nullable=False, + ) + + op.drop_table("collections") + # ### end Alembic commands ### diff --git a/backend/endpoints/collections.py b/backend/endpoints/collections.py new file mode 100644 index 000000000..de8ca2dd6 --- /dev/null +++ b/backend/endpoints/collections.py @@ -0,0 +1,234 @@ +import json +from shutil import rmtree + +from config import RESOURCES_BASE_PATH +from decorators.auth import protected_route +from endpoints.responses import MessageResponse +from endpoints.responses.collection import CollectionSchema +from exceptions.endpoint_exceptions import ( + CollectionAlreadyExistsException, + CollectionNotFoundInDatabaseException, + CollectionPermissionError, +) +from fastapi import APIRouter, Request, UploadFile +from handler.database import db_collection_handler +from handler.filesystem import fs_resource_handler +from logger.logger import log +from models.collection import Collection +from sqlalchemy.inspection import inspect + +router = APIRouter() + + +@protected_route(router.post, "/collections", ["collections.write"]) +async def add_collection( + request: Request, + artwork: UploadFile | None = None, +) -> CollectionSchema: + """Create collection endpoint + + Args: + request (Request): Fastapi Request object + + Returns: + CollectionSchema: Just created collection + """ + + data = await request.form() + cleaned_data = { + "name": data.get("name", ""), + "description": data.get("description", ""), + "url_cover": data.get("url_cover", ""), + "is_public": data.get("is_public", False), + "user_id": request.user.id, + } + db_collection = db_collection_handler.get_collection_by_name( + cleaned_data["name"], request.user.id + ) + + if db_collection: + raise CollectionAlreadyExistsException(cleaned_data["name"]) + + _added_collection = db_collection_handler.add_collection(Collection(**cleaned_data)) + + if artwork is not None: + file_ext = artwork.filename.split(".")[-1] + ( + path_cover_l, + path_cover_s, + artwork_path, + ) = fs_resource_handler.build_artwork_path(_added_collection, file_ext) + + artwork_file = artwork.file.read() + file_location_s = f"{artwork_path}/small.{file_ext}" + with open(file_location_s, "wb+") as artwork_s: + artwork_s.write(artwork_file) + fs_resource_handler.resize_cover_to_small(file_location_s) + + file_location_l = f"{artwork_path}/big.{file_ext}" + with open(file_location_l, "wb+") as artwork_l: + artwork_l.write(artwork_file) + else: + path_cover_s, path_cover_l = fs_resource_handler.get_cover( + overwrite=True, + entity=_added_collection, + url_cover=_added_collection.url_cover, + ) + + _added_collection.path_cover_s = path_cover_s + _added_collection.path_cover_l = path_cover_l + # Update the collection with the cover path and update database + return db_collection_handler.update_collection( + _added_collection.id, + { + c: getattr(_added_collection, c) + for c in inspect(_added_collection).mapper.column_attrs.keys() + }, + ) + + +@protected_route(router.get, "/collections", ["collections.read"]) +def get_collections(request: Request) -> list[CollectionSchema]: + """Get collections endpoint + + Args: + request (Request): Fastapi Request object + id (int, optional): Collection id. Defaults to None. + + Returns: + list[CollectionSchema]: List of collections + """ + + return db_collection_handler.get_collections(user_id=request.user.id) + + +@protected_route(router.get, "/collections/{id}", ["collections.read"]) +def get_collection(request: Request, id: int) -> CollectionSchema: + """Get collections endpoint + + Args: + request (Request): Fastapi Request object + id (int, optional): Collection id. Defaults to None. + + Returns: + CollectionSchema: Collection + """ + + collection = db_collection_handler.get_collection(id) + + if not collection: + raise CollectionNotFoundInDatabaseException(id) + + return collection + + +@protected_route(router.put, "/collections/{id}", ["collections.write"]) +async def update_collection( + request: Request, + id: int, + remove_cover: bool = False, + artwork: UploadFile | None = None, +) -> CollectionSchema: + """Update collection endpoint + + Args: + request (Request): Fastapi Request object + + Returns: + MessageResponse: Standard message response + """ + + data = await request.form() + collection = db_collection_handler.get_collection(id) + + if collection.user_id != request.user.id: + raise CollectionPermissionError(id) + + if not collection: + raise CollectionNotFoundInDatabaseException(id) + + try: + roms = json.loads(data["roms"]) + except json.JSONDecodeError as e: + raise ValueError("Invalid list for roms field in update collection") from e + except KeyError: + roms = collection.roms + + cleaned_data = { + "name": data.get("name", collection.name), + "description": data.get("description", collection.description), + "roms": list(set(roms)), + "url_cover": data.get("url_cover", collection.url_cover), + "is_public": data.get("is_public", collection.is_public), + "user_id": request.user.id, + } + + if remove_cover: + cleaned_data.update(fs_resource_handler.remove_cover(collection)) + cleaned_data.update({"url_cover": ""}) + else: + if artwork is not None: + file_ext = artwork.filename.split(".")[-1] + ( + path_cover_l, + path_cover_s, + artwork_path, + ) = fs_resource_handler.build_artwork_path(collection, file_ext) + + cleaned_data["path_cover_l"] = path_cover_l + cleaned_data["path_cover_s"] = path_cover_s + + artwork_file = artwork.file.read() + file_location_s = f"{artwork_path}/small.{file_ext}" + with open(file_location_s, "wb+") as artwork_s: + artwork_s.write(artwork_file) + fs_resource_handler.resize_cover_to_small(file_location_s) + + file_location_l = f"{artwork_path}/big.{file_ext}" + with open(file_location_l, "wb+") as artwork_l: + artwork_l.write(artwork_file) + else: + cleaned_data["url_cover"] = data.get("url_cover", collection.url_cover) + path_cover_s, path_cover_l = fs_resource_handler.get_cover( + overwrite=cleaned_data["url_cover"] != collection.url_cover, + entity=collection, + url_cover=cleaned_data.get("url_cover", ""), + ) + cleaned_data.update( + {"path_cover_s": path_cover_s, "path_cover_l": path_cover_l} + ) + + return db_collection_handler.update_collection(id, cleaned_data) + + +@protected_route(router.delete, "/collections/{id}", ["collections.write"]) +async def delete_collections(request: Request, id: int) -> MessageResponse: + """Delete collections endpoint + + Args: + request (Request): Fastapi Request object + { + "collections": List of rom's ids to delete + } + + Raises: + HTTPException: Collection not found + + Returns: + MessageResponse: Standard message response + """ + + collection = db_collection_handler.get_collection(id) + + if not collection: + raise CollectionNotFoundInDatabaseException(id) + + log.info(f"Deleting {collection.name} from database") + db_collection_handler.delete_collection(id) + + try: + rmtree(f"{RESOURCES_BASE_PATH}/{collection.fs_resources_path}") + except FileNotFoundError: + log.error(f"Couldn't find resources to delete for {collection.name}") + + return {"msg": f"{collection.name} deleted successfully!"} diff --git a/backend/endpoints/platform.py b/backend/endpoints/platform.py index 85b018451..b701e66b1 100644 --- a/backend/endpoints/platform.py +++ b/backend/endpoints/platform.py @@ -34,8 +34,7 @@ async def add_platforms(request: Request) -> PlatformSchema: except PlatformAlreadyExistsException: log.info(f"Detected platform: {fs_slug}") scanned_platform = scan_platform(fs_slug, [fs_slug]) - platform = db_platform_handler.add_platform(scanned_platform) - return platform + return db_platform_handler.add_platform(scanned_platform) @protected_route(router.get, "/platforms", ["platforms.read"]) diff --git a/backend/endpoints/responses/collection.py b/backend/endpoints/responses/collection.py new file mode 100644 index 000000000..a78defb4e --- /dev/null +++ b/backend/endpoints/responses/collection.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class CollectionSchema(BaseModel): + id: int + name: str + description: str + path_cover_l: str | None + path_cover_s: str | None + has_cover: bool + url_cover: str + roms: set[int] + rom_count: int + user_id: int + user__username: str + is_public: bool + + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index b0122473a..81a35463b 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -5,6 +5,7 @@ from typing import NotRequired, get_type_hints from endpoints.responses.assets import SaveSchema, ScreenshotSchema, StateSchema +from endpoints.responses.collection import CollectionSchema from fastapi import Request from fastapi.responses import StreamingResponse from handler.metadata.igdb_handler import IGDBMetadata @@ -126,12 +127,6 @@ def from_orm_with_request(cls, db_rom: Rom, request: Request) -> RomSchema: return rom - @classmethod - def from_orm_with_request_list( - cls, db_roms: list[Rom], request: Request - ) -> list[RomSchema]: - return [cls.from_orm_with_request(rom, request) for rom in db_roms] - @computed_field # type: ignore @property def sort_comparator(self) -> str: @@ -153,6 +148,7 @@ class DetailedRomSchema(RomSchema): user_states: list[StateSchema] = Field(default_factory=list) user_screenshots: list[ScreenshotSchema] = Field(default_factory=list) user_notes: list[UserNotesSchema] = Field(default_factory=list) + user_collections: list[CollectionSchema] = Field(default_factory=list) @classmethod def from_orm_with_request(cls, db_rom: Rom, request: Request) -> DetailedRomSchema: @@ -175,6 +171,9 @@ def from_orm_with_request(cls, db_rom: Rom, request: Request) -> DetailedRomSche for s in db_rom.screenshots if s.user_id == user_id ] + rom.user_collections = [ + CollectionSchema.model_validate(c) for c in db_rom.get_collections(user_id) + ] return rom diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 907afad42..05ca83f56 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -94,6 +94,7 @@ def add_roms( def get_roms( request: Request, platform_id: int | None = None, + collection_id: int | None = None, search_term: str = "", limit: int | None = None, order_by: str = "name", @@ -108,15 +109,17 @@ def get_roms( Returns: list[RomSchema]: List of roms stored in the database """ - db_roms = db_rom_handler.get_roms( + + roms = db_rom_handler.get_roms( platform_id=platform_id, + collection_id=collection_id, search_term=search_term.lower(), order_by=order_by.lower(), order_dir=order_dir.lower(), limit=limit, ) - return RomSchema.from_orm_with_request_list(db_roms, request) + return [RomSchema.from_orm_with_request(rom, request) for rom in roms] @protected_route( @@ -292,9 +295,10 @@ async def update_rom( if not rom: raise RomNotFoundInDatabaseException(id) - cleaned_data = {} - cleaned_data["igdb_id"] = data.get("igdb_id", None) - cleaned_data["moby_id"] = data.get("moby_id", None) + cleaned_data = { + "igdb_id": data.get("igdb_id", None), + "moby_id": data.get("moby_id", None), + } if cleaned_data["moby_id"]: moby_rom = meta_moby_handler.get_rom_by_id(cleaned_data["moby_id"]) @@ -308,8 +312,12 @@ async def update_rom( else: cleaned_data.update({"igdb_metadata": {}}) - cleaned_data["name"] = data.get("name", rom.name) - cleaned_data["summary"] = data.get("summary", rom.summary) + cleaned_data.update( + { + "name": data.get("name", rom.name), + "summary": data.get("summary", rom.summary), + } + ) fs_safe_file_name = data.get("file_name", rom.file_name).strip().replace("/", "-") fs_safe_name = cleaned_data["name"].strip().replace("/", "-") @@ -327,21 +335,25 @@ async def update_rom( file_path=rom.file_path, ) except RomAlreadyExistsException as exc: - log.error(str(exc)) + log.error(exc) raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=exc ) from exc - cleaned_data["file_name"] = fs_safe_file_name - cleaned_data["file_name_no_tags"] = fs_rom_handler.get_file_name_with_no_tags( - fs_safe_file_name - ) - cleaned_data["file_name_no_ext"] = fs_rom_handler.get_file_name_with_no_extension( - fs_safe_file_name + cleaned_data.update( + { + "file_name": fs_safe_file_name, + "file_name_no_tags": fs_rom_handler.get_file_name_with_no_tags( + fs_safe_file_name + ), + "file_name_no_ext": fs_rom_handler.get_file_name_with_no_extension( + fs_safe_file_name + ), + } ) if remove_cover: - cleaned_data.update(fs_resource_handler.remove_cover(rom=rom)) + cleaned_data.update(fs_resource_handler.remove_cover(rom)) cleaned_data.update({"url_cover": ""}) else: if artwork is not None: @@ -352,8 +364,9 @@ async def update_rom( artwork_path, ) = fs_resource_handler.build_artwork_path(rom, file_ext) - cleaned_data["path_cover_l"] = path_cover_l - cleaned_data["path_cover_s"] = path_cover_s + cleaned_data.update( + {"path_cover_s": path_cover_s, "path_cover_l": path_cover_l} + ) artwork_file = artwork.file.read() file_location_s = f"{artwork_path}/small.{file_ext}" @@ -365,10 +378,10 @@ async def update_rom( with open(file_location_l, "wb+") as artwork_l: artwork_l.write(artwork_file) else: - cleaned_data["url_cover"] = data.get("url_cover", rom.url_cover) - path_cover_s, path_cover_l = fs_resource_handler.get_rom_cover( - overwrite=True, - rom=rom, + cleaned_data.update({"url_cover": data.get("url_cover", rom.url_cover)}) + path_cover_s, path_cover_l = fs_resource_handler.get_cover( + overwrite=cleaned_data["url_cover"] != rom.url_cover, + entity=rom, url_cover=cleaned_data.get("url_cover", ""), ) cleaned_data.update( @@ -451,15 +464,12 @@ async def update_rom_user(request: Request, id: int) -> RomUserSchema: id, request.user.id ) or db_rom_handler.add_rom_user(id, request.user.id) - cleaned_data = {} - cleaned_data["note_raw_markdown"] = data.get( - "note_raw_markdown", db_rom_user.note_raw_markdown - ) - cleaned_data["note_is_public"] = data.get( - "note_is_public", db_rom_user.note_is_public - ) - cleaned_data["is_main_sibling"] = data.get( - "is_main_sibling", db_rom_user.is_main_sibling - ) + cleaned_data = { + "note_raw_markdown": data.get( + "note_raw_markdown", db_rom_user.note_raw_markdown + ), + "note_is_public": data.get("note_is_public", db_rom_user.note_is_public), + "is_main_sibling": data.get("is_main_sibling", db_rom_user.is_main_sibling), + } return db_rom_handler.update_rom_user(db_rom_user.id, cleaned_data) diff --git a/backend/endpoints/search.py b/backend/endpoints/search.py index 5cee25c3a..a2c8d624b 100644 --- a/backend/endpoints/search.py +++ b/backend/endpoints/search.py @@ -6,6 +6,7 @@ from handler.metadata import meta_igdb_handler, meta_moby_handler, meta_sgdb_handler from handler.metadata.igdb_handler import IGDB_API_ENABLED from handler.metadata.moby_handler import MOBY_API_ENABLED +from handler.metadata.sgdb_handler import STEAMGRIDDB_API_ENABLED from handler.scan_handler import _get_main_platform_igdb_id from logger.logger import log @@ -115,4 +116,11 @@ async def search_cover( search_term: str | None = None, ) -> list[SearchCoverSchema]: + if not STEAMGRIDDB_API_ENABLED: + log.error("Search error: No SteamGridDB enabled") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="No SteamGridDB enabled", + ) + return meta_sgdb_handler.get_details(search_term) diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index b485c63d4..c0eb38a38 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -3,7 +3,6 @@ import emoji import socketio # type: ignore from config import SCAN_TIMEOUT -from endpoints.responses.firmware import FirmwareSchema from endpoints.responses.platform import PlatformSchema from endpoints.responses.rom import RomSchema from exceptions.fs_exceptions import ( @@ -156,6 +155,7 @@ async def scan_platforms( exclude=EXCLUDED_FROM_DUMP ), ) + await sm.emit("", None) # Scanning firmware try: @@ -187,17 +187,6 @@ async def scan_platforms( _added_firmware = db_firmware_handler.add_firmware(scanned_firmware) firmware = db_firmware_handler.get_firmware(_added_firmware.id) - await sm.emit( - "scan:scanning_firmware", - { - "platform_name": platform.name, - "platform_slug": platform.slug, - **FirmwareSchema.model_validate(firmware).model_dump( - exclude=EXCLUDED_FROM_DUMP - ), - }, - ) - # Scanning roms try: fs_roms = fs_rom_handler.get_roms(platform) @@ -236,9 +225,9 @@ async def scan_platforms( _added_rom = db_rom_handler.add_rom(scanned_rom) - path_cover_s, path_cover_l = fs_resource_handler.get_rom_cover( + path_cover_s, path_cover_l = fs_resource_handler.get_cover( overwrite=True, - rom=_added_rom, + entity=_added_rom, url_cover=_added_rom.url_cover, ) @@ -269,6 +258,7 @@ async def scan_platforms( ), }, ) + await sm.emit("", None) db_rom_handler.purge_roms( platform.id, [rom["file_name"] for rom in fs_roms] diff --git a/backend/exceptions/endpoint_exceptions.py b/backend/exceptions/endpoint_exceptions.py index 0de3d09ba..6e297a8f1 100644 --- a/backend/exceptions/endpoint_exceptions.py +++ b/backend/exceptions/endpoint_exceptions.py @@ -22,3 +22,38 @@ def __init__(self, id): def __repr__(self) -> str: return self.message + + +class CollectionNotFoundInDatabaseException(Exception): + def __init__(self, id): + self.message = f"Collection with id '{id}' not found" + super().__init__(self.message) + log.critical(self.message) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=self.message) + + def __repr__(self) -> str: + return self.message + + +class CollectionPermissionError(Exception): + def __init__(self, id): + self.message = f"Permission denied for collection with id '{id}'" + super().__init__(self.message) + log.critical(self.message) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=self.message) + + def __repr__(self) -> str: + return self.message + + +class CollectionAlreadyExistsException(Exception): + def __init__(self, name): + self.message = f"Collection with name '{name}' already exists" + super().__init__(self.message) + log.critical(self.message) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=self.message + ) + + def __repr__(self) -> str: + return self.message diff --git a/backend/handler/auth/base_handler.py b/backend/handler/auth/base_handler.py index 3127d0cf0..c2350aab1 100644 --- a/backend/handler/auth/base_handler.py +++ b/backend/handler/auth/base_handler.py @@ -24,6 +24,8 @@ "firmware.read": "View firmware", "roms.user.read": "View user-rom properties", "roms.user.write": "Modify user-rom properties", + "collections.read": "View collections", + "collections.write": "Modify collections", } WRITE_SCOPES_MAP: Final = { diff --git a/backend/handler/database/__init__.py b/backend/handler/database/__init__.py index e09b022bc..65816f359 100644 --- a/backend/handler/database/__init__.py +++ b/backend/handler/database/__init__.py @@ -1,3 +1,4 @@ +from .collections_handler import DBCollectionsHandler from .firmware_handler import DBFirmwareHandler from .platforms_handler import DBPlatformsHandler from .roms_handler import DBRomsHandler @@ -15,3 +16,4 @@ db_state_handler = DBStatesHandler() db_stats_handler = DBStatsHandler() db_user_handler = DBUsersHandler() +db_collection_handler = DBCollectionsHandler() diff --git a/backend/handler/database/collections_handler.py b/backend/handler/database/collections_handler.py new file mode 100644 index 000000000..2b82a55eb --- /dev/null +++ b/backend/handler/database/collections_handler.py @@ -0,0 +1,62 @@ +from decorators.database import begin_session +from models.collection import Collection +from sqlalchemy import Select, delete, select, update +from sqlalchemy.orm import Session + +from .base_handler import DBBaseHandler + + +class DBCollectionsHandler(DBBaseHandler): + @begin_session + def add_collection( + self, collection: Collection, session: Session = None + ) -> Collection | None: + collection = session.merge(collection) + session.flush() + return session.scalar(select(Collection).filter_by(id=collection.id).limit(1)) + + @begin_session + def get_collection(self, id: int, session: Session = None) -> Collection | None: + return session.scalar(select(Collection).filter_by(id=id).limit(1)) + + @begin_session + def get_collection_by_name( + self, name: str, user_id: int, session: Session = None + ) -> Collection | None: + return session.scalar( + select(Collection).filter_by(name=name, user_id=user_id).limit(1) + ) + + @begin_session + def get_collections( + self, user_id: int, session: Session = None + ) -> Select[tuple[Collection]]: + return ( + session.scalars( + select(Collection) + .filter_by(user_id=user_id) + .order_by(Collection.name.asc()) + ) # type: ignore[attr-defined] + .unique() + .all() + ) + + @begin_session + def update_collection( + self, id: int, data: dict, session: Session = None + ) -> Collection: + session.execute( + update(Collection) + .where(Collection.id == id) + .values(**data) + .execution_options(synchronize_session="evaluate") + ) + return session.query(Collection).filter_by(id=id).one() + + @begin_session + def delete_collection(self, id: int, session: Session = None) -> int: + return session.execute( + delete(Collection) + .where(Collection.id == id) + .execution_options(synchronize_session="evaluate") + ) diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 998fa714d..bb69cead2 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -1,6 +1,7 @@ import functools from decorators.database import begin_session +from models.collection import Collection from models.rom import Rom, RomUser from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.orm import Query, Session, selectinload @@ -44,10 +45,26 @@ def wrapper(*args, **kwargs): class DBRomsHandler(DBBaseHandler): - def _filter(self, data, platform_id: int | None, search_term: str): + def _filter( + self, + data, + platform_id: int | None, + collection_id: int | None, + search_term: str, + session: Session, + ): if platform_id: data = data.filter(Rom.platform_id == platform_id) + if collection_id: + collection = ( + session.query(Collection) + .filter(Collection.id == collection_id) + .one_or_none() + ) + if collection: + data = data.filter(Rom.id.in_(collection.roms)) + if search_term: data = data.filter( or_( @@ -90,6 +107,7 @@ def get_roms( self, *, platform_id: int | None = None, + collection_id: int | None = None, search_term: str = "", order_by: str = "name", order_dir: str = "asc", @@ -97,17 +115,12 @@ def get_roms( query: Query = None, session: Session = None, ) -> list[Rom]: - return ( - session.scalars( - self._order( - self._filter(query, platform_id, search_term), - order_by, - order_dir, - ).limit(limit) - ) - .unique() - .all() + filtered_query = self._filter( + query, platform_id, collection_id, search_term, session ) + ordered_query = self._order(filtered_query, order_by, order_dir) + limited_query = ordered_query.limit(limit) + return session.scalars(limited_query).unique().all() @begin_session @with_details @@ -166,6 +179,24 @@ def get_sibling_roms( ) ).all() + @begin_session + def get_rom_collections( + self, rom: Rom, user_id: int, session: Session = None + ) -> list[Collection]: + + return ( + session.scalars( + select(Collection) + .filter( + func.json_contains(Collection.roms, f"{rom.id}"), + Collection.user_id == user_id, + ) + .order_by(Collection.name.asc()) + ) + .unique() + .all() + ) + @begin_session def update_rom(self, id: int, data: dict, session: Session = None) -> Rom: return session.execute( diff --git a/backend/handler/filesystem/resources_handler.py b/backend/handler/filesystem/resources_handler.py index 4c13d0c54..c96742cc6 100644 --- a/backend/handler/filesystem/resources_handler.py +++ b/backend/handler/filesystem/resources_handler.py @@ -6,6 +6,7 @@ from config import RESOURCES_BASE_PATH from fastapi import HTTPException, status from logger.logger import log +from models.collection import Collection from models.rom import Rom from PIL import Image from urllib3.exceptions import ProtocolError @@ -90,55 +91,57 @@ def _get_cover_path(rom: Rom, size: CoverSize): """ return f"{rom.fs_resources_path}/cover/{size.value}.png" - def get_rom_cover( - self, rom: Rom | None, overwrite: bool, url_cover: str = "" + def get_cover( + self, entity: Rom | Collection | None, overwrite: bool, url_cover: str = "" ) -> tuple[str, str]: - if not rom: + if not entity: return "", "" - if (overwrite or not self._cover_exists(rom, CoverSize.SMALL)) and url_cover: - self._store_cover(rom, url_cover, CoverSize.SMALL) + if (overwrite or not self._cover_exists(entity, CoverSize.SMALL)) and url_cover: + self._store_cover(entity, url_cover, CoverSize.SMALL) path_cover_s = ( - self._get_cover_path(rom, CoverSize.SMALL) - if self._cover_exists(rom, CoverSize.SMALL) + self._get_cover_path(entity, CoverSize.SMALL) + if self._cover_exists(entity, CoverSize.SMALL) else "" ) - if (overwrite or not self._cover_exists(rom, CoverSize.BIG)) and url_cover: - self._store_cover(rom, url_cover, CoverSize.BIG) + if (overwrite or not self._cover_exists(entity, CoverSize.BIG)) and url_cover: + self._store_cover(entity, url_cover, CoverSize.BIG) path_cover_l = ( - self._get_cover_path(rom, CoverSize.BIG) - if self._cover_exists(rom, CoverSize.BIG) + self._get_cover_path(entity, CoverSize.BIG) + if self._cover_exists(entity, CoverSize.BIG) else "" ) return path_cover_s, path_cover_l @staticmethod - def remove_cover(rom: Rom | None): - if not rom: + def remove_cover(entity: Rom | Collection | None): + if not entity: return {"path_cover_s": "", "path_cover_l": ""} try: - cover_path = f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/cover" + cover_path = f"{RESOURCES_BASE_PATH}/{entity.fs_resources_path}/cover" shutil.rmtree(cover_path) except FileNotFoundError: log.warning( - f"Couldn't remove rom '{rom.name or rom.id}' cover since '{cover_path}' doesn't exists." + f"Couldn't remove cover from '{entity.name or entity.id}' since '{cover_path}' doesn't exists." ) return {"path_cover_s": "", "path_cover_l": ""} @staticmethod - def build_artwork_path(rom: Rom | None, file_ext: str): - if not rom: + def build_artwork_path(entity: Rom | Collection | None, file_ext: str): + if not entity: return "", "", "" - path_cover_l = f"{rom.fs_resources_path}/cover/{CoverSize.BIG.value}.{file_ext}" + path_cover_l = ( + f"{entity.fs_resources_path}/cover/{CoverSize.BIG.value}.{file_ext}" + ) path_cover_s = ( - f"{rom.fs_resources_path}/cover/{CoverSize.SMALL.value}.{file_ext}" + f"{entity.fs_resources_path}/cover/{CoverSize.SMALL.value}.{file_ext}" ) - artwork_path = f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/cover" + artwork_path = f"{RESOURCES_BASE_PATH}/{entity.fs_resources_path}/cover" Path(artwork_path).mkdir(parents=True, exist_ok=True) return path_cover_l, path_cover_s, artwork_path diff --git a/backend/handler/filesystem/tests/test_fs.py b/backend/handler/filesystem/tests/test_fs.py index 7f91fbd2d..0291a1b4f 100644 --- a/backend/handler/filesystem/tests/test_fs.py +++ b/backend/handler/filesystem/tests/test_fs.py @@ -8,8 +8,8 @@ @pytest.mark.vcr def test_get_rom_cover(): - path_cover_s, path_cover_l = fs_resource_handler.get_rom_cover( - overwrite=False, rom=None, url_cover="" + path_cover_s, path_cover_l = fs_resource_handler.get_cover( + overwrite=False, entity=None, url_cover="" ) assert "" in path_cover_s diff --git a/backend/main.py b/backend/main.py index 65f89020d..5f006f900 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,6 +8,7 @@ from config import DEV_HOST, DEV_PORT, DISABLE_CSRF_PROTECTION, ROMM_AUTH_SECRET_KEY from endpoints import ( auth, + collections, config, feeds, firmware, @@ -93,6 +94,7 @@ async def lifespan(app: FastAPI): app.include_router(raw.router) app.include_router(screenshots.router) app.include_router(firmware.router) +app.include_router(collections.router) app.mount("/ws", socket_handler.socket_app) diff --git a/backend/models/collection.py b/backend/models/collection.py new file mode 100644 index 000000000..eee6aec69 --- /dev/null +++ b/backend/models/collection.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from functools import cached_property + +from models.base import BaseModel +from models.user import User +from sqlalchemy import JSON, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + + +class Collection(BaseModel): + __tablename__ = "collections" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + name: Mapped[str] = mapped_column(String(length=400)) + description: Mapped[str | None] = mapped_column(Text) + + path_cover_l: Mapped[str | None] = mapped_column(Text, default="") + path_cover_s: Mapped[str | None] = mapped_column(Text, default="") + + url_cover: Mapped[str | None] = mapped_column( + Text, default="", doc="URL to cover image stored in IGDB" + ) + + roms: Mapped[set[int]] = mapped_column( + JSON, default=[], doc="Rom id's that belong to this collection" + ) + + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + is_public: Mapped[bool] = mapped_column(default=False) + user: Mapped[User] = relationship(lazy="joined", back_populates="collections") + + @property + def user__username(self) -> str: + return self.user.username + + @property + def rom_count(self): + return len(self.roms) + + @cached_property + def has_cover(self) -> bool: + return bool(self.path_cover_s or self.path_cover_l) + + @property + def fs_resources_path(self) -> str: + return f"collections/{str(self.id)}" + + def __repr__(self) -> str: + return self.name diff --git a/backend/models/rom.py b/backend/models/rom.py index 5500af70a..cd34df019 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from models.assets import Save, Screenshot, State + from models.collection import Collection from models.platform import Platform from models.user import User @@ -103,6 +104,11 @@ def get_sibling_roms(self) -> list[Rom]: return db_rom_handler.get_sibling_roms(self) + def get_collections(self, user_id) -> list[Collection]: + from handler.database import db_rom_handler + + return db_rom_handler.get_rom_collections(self, user_id) + # Metadata fields @property def alternative_names(self) -> list[str]: @@ -142,7 +148,7 @@ def game_modes(self) -> list[str]: @property def fs_resources_path(self) -> str: - return f"{str(self.platform_id)}/{str(self.id)}" + return f"roms/{str(self.platform_id)}/{str(self.id)}" def __repr__(self) -> str: return self.file_name diff --git a/backend/models/user.py b/backend/models/user.py index c86c2d80d..c1d80f34f 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from models.assets import Save, Screenshot, State + from models.collection import Collection from models.rom import RomUser @@ -40,6 +41,7 @@ class User(BaseModel, SimpleUser): states: Mapped[list[State]] = relationship(back_populates="user") screenshots: Mapped[list[Screenshot]] = relationship(back_populates="user") rom_users: Mapped[list[RomUser]] = relationship(back_populates="user") + collections: Mapped[list[Collection]] = relationship(back_populates="user") @property def oauth_scopes(self): diff --git a/frontend/assets/default/cover/big_dark_collection.png b/frontend/assets/default/cover/big_dark_collection.png new file mode 100644 index 000000000..869d0e687 Binary files /dev/null and b/frontend/assets/default/cover/big_dark_collection.png differ diff --git a/frontend/assets/default/cover/big_light_collection.png b/frontend/assets/default/cover/big_light_collection.png new file mode 100644 index 000000000..9d1570e5e Binary files /dev/null and b/frontend/assets/default/cover/big_light_collection.png differ diff --git a/frontend/assets/default/cover/small_dark_collection.png b/frontend/assets/default/cover/small_dark_collection.png new file mode 100644 index 000000000..996e70385 Binary files /dev/null and b/frontend/assets/default/cover/small_dark_collection.png differ diff --git a/frontend/assets/default/cover/small_light_collection.png b/frontend/assets/default/cover/small_light_collection.png new file mode 100644 index 000000000..239a4a10d Binary files /dev/null and b/frontend/assets/default/cover/small_light_collection.png differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 750c7935a..3a6030176 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,94 +1,18 @@ diff --git a/frontend/src/components/Details/Info/GameInfo.vue b/frontend/src/components/Details/Info/GameInfo.vue index de5bc6029..7d5cd613f 100644 --- a/frontend/src/components/Details/Info/GameInfo.vue +++ b/frontend/src/components/Details/Info/GameInfo.vue @@ -40,7 +40,7 @@ const show = ref(false); -

+ {{ rom.summary }} diff --git a/frontend/src/components/Gallery/AppBar/AdminMenu.vue b/frontend/src/components/Gallery/AppBar/AdminMenu.vue deleted file mode 100644 index fb3b275f7..000000000 --- a/frontend/src/components/Gallery/AppBar/AdminMenu.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/frontend/src/components/Gallery/AppBar/Collection/AdminMenu.vue b/frontend/src/components/Gallery/AppBar/Collection/AdminMenu.vue new file mode 100644 index 000000000..fe0dbc675 --- /dev/null +++ b/frontend/src/components/Gallery/AppBar/Collection/AdminMenu.vue @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/components/Gallery/AppBar/Collection/Base.vue b/frontend/src/components/Gallery/AppBar/Collection/Base.vue new file mode 100644 index 000000000..584696d13 --- /dev/null +++ b/frontend/src/components/Gallery/AppBar/Collection/Base.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/src/components/Gallery/AppBar/Collection/DeleteBtn.vue b/frontend/src/components/Gallery/AppBar/Collection/DeleteBtn.vue new file mode 100644 index 000000000..c91e490cc --- /dev/null +++ b/frontend/src/components/Gallery/AppBar/Collection/DeleteBtn.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/Gallery/AppBar/Collection/EditBtn.vue b/frontend/src/components/Gallery/AppBar/Collection/EditBtn.vue new file mode 100644 index 000000000..3081b5fe5 --- /dev/null +++ b/frontend/src/components/Gallery/AppBar/Collection/EditBtn.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/Gallery/AppBar/Platform/AdminMenu.vue b/frontend/src/components/Gallery/AppBar/Platform/AdminMenu.vue new file mode 100644 index 000000000..d159b0db1 --- /dev/null +++ b/frontend/src/components/Gallery/AppBar/Platform/AdminMenu.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/Gallery/AppBar/Base.vue b/frontend/src/components/Gallery/AppBar/Platform/Base.vue similarity index 55% rename from frontend/src/components/Gallery/AppBar/Base.vue rename to frontend/src/components/Gallery/AppBar/Platform/Base.vue index c5fed7b4c..abb62d621 100644 --- a/frontend/src/components/Gallery/AppBar/Base.vue +++ b/frontend/src/components/Gallery/AppBar/Platform/Base.vue @@ -1,12 +1,12 @@ @@ -18,16 +19,12 @@ const route = useRoute(); @click=" emitter?.emit( 'showDeletePlatformDialog', - platforms.get(Number(route.params.platform)) as Platform + roms.currentPlatform as Platform ) " > - + Delete platform diff --git a/frontend/src/components/Gallery/AppBar/FirmwareBtn.vue b/frontend/src/components/Gallery/AppBar/Platform/FirmwareBtn.vue similarity index 100% rename from frontend/src/components/Gallery/AppBar/FirmwareBtn.vue rename to frontend/src/components/Gallery/AppBar/Platform/FirmwareBtn.vue diff --git a/frontend/src/components/Gallery/FirmwareDrawer.vue b/frontend/src/components/Gallery/AppBar/Platform/FirmwareDrawer.vue similarity index 100% rename from frontend/src/components/Gallery/FirmwareDrawer.vue rename to frontend/src/components/Gallery/AppBar/Platform/FirmwareDrawer.vue diff --git a/frontend/src/components/Gallery/AppBar/ScanBtn.vue b/frontend/src/components/Gallery/AppBar/Platform/ScanBtn.vue similarity index 100% rename from frontend/src/components/Gallery/AppBar/ScanBtn.vue rename to frontend/src/components/Gallery/AppBar/Platform/ScanBtn.vue diff --git a/frontend/src/components/Gallery/AppBar/UploadRomBtn.vue b/frontend/src/components/Gallery/AppBar/Platform/UploadRomBtn.vue similarity index 100% rename from frontend/src/components/Gallery/AppBar/UploadRomBtn.vue rename to frontend/src/components/Gallery/AppBar/Platform/UploadRomBtn.vue diff --git a/frontend/src/components/Gallery/AppBar/FilterBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterBtn.vue similarity index 100% rename from frontend/src/components/Gallery/AppBar/FilterBtn.vue rename to frontend/src/components/Gallery/AppBar/common/FilterBtn.vue diff --git a/frontend/src/components/Gallery/FilterDrawer/Base.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue similarity index 95% rename from frontend/src/components/Gallery/FilterDrawer/Base.vue rename to frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue index b0b7175d7..5ddae329e 100644 --- a/frontend/src/components/Gallery/FilterDrawer/Base.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue @@ -1,5 +1,5 @@ + + + diff --git a/frontend/src/components/common/Collection/Dialog/AddRoms.vue b/frontend/src/components/common/Collection/Dialog/AddRoms.vue new file mode 100644 index 000000000..a51857703 --- /dev/null +++ b/frontend/src/components/common/Collection/Dialog/AddRoms.vue @@ -0,0 +1,198 @@ + + + diff --git a/frontend/src/components/common/Collection/Dialog/CreateCollection.vue b/frontend/src/components/common/Collection/Dialog/CreateCollection.vue new file mode 100644 index 000000000..0c0f34873 --- /dev/null +++ b/frontend/src/components/common/Collection/Dialog/CreateCollection.vue @@ -0,0 +1,214 @@ + + + + diff --git a/frontend/src/components/common/Collection/Dialog/DeleteCollection.vue b/frontend/src/components/common/Collection/Dialog/DeleteCollection.vue new file mode 100644 index 000000000..2a3ae3bca --- /dev/null +++ b/frontend/src/components/common/Collection/Dialog/DeleteCollection.vue @@ -0,0 +1,85 @@ + + diff --git a/frontend/src/components/common/Collection/Dialog/EditCollection.vue b/frontend/src/components/common/Collection/Dialog/EditCollection.vue new file mode 100644 index 000000000..1977f8115 --- /dev/null +++ b/frontend/src/components/common/Collection/Dialog/EditCollection.vue @@ -0,0 +1,210 @@ + + + + diff --git a/frontend/src/components/common/Collection/Dialog/RemoveRoms.vue b/frontend/src/components/common/Collection/Dialog/RemoveRoms.vue new file mode 100644 index 000000000..1d96e596d --- /dev/null +++ b/frontend/src/components/common/Collection/Dialog/RemoveRoms.vue @@ -0,0 +1,175 @@ + + + diff --git a/frontend/src/components/common/Collection/ListItem.vue b/frontend/src/components/common/Collection/ListItem.vue new file mode 100644 index 000000000..5e0f52f3f --- /dev/null +++ b/frontend/src/components/common/Collection/ListItem.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/frontend/src/components/common/Collection/RAvatar.vue b/frontend/src/components/common/Collection/RAvatar.vue new file mode 100644 index 000000000..d768fe069 --- /dev/null +++ b/frontend/src/components/common/Collection/RAvatar.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/components/common/EmptyCollection.vue b/frontend/src/components/common/EmptyCollection.vue new file mode 100644 index 000000000..5185b2bb6 --- /dev/null +++ b/frontend/src/components/common/EmptyCollection.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/src/components/common/Game/AdminMenu.vue b/frontend/src/components/common/Game/AdminMenu.vue index 2d4646beb..bc1dca9bd 100644 --- a/frontend/src/components/common/Game/AdminMenu.vue +++ b/frontend/src/components/common/Game/AdminMenu.vue @@ -1,66 +1,66 @@ diff --git a/frontend/src/components/common/Game/Card/ActionBar.vue b/frontend/src/components/common/Game/Card/ActionBar.vue index 255bb21fa..618f54621 100644 --- a/frontend/src/components/common/Game/Card/ActionBar.vue +++ b/frontend/src/components/common/Game/Card/ActionBar.vue @@ -1,19 +1,13 @@