diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 8cd4677fe..f1884b74c 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -6,6 +6,7 @@ from alembic import context from config.config_manager import ConfigManager from models.assets import Save, Screenshot, State # noqa from models.base import BaseModel +from models.collection import VirtualCollection from models.firmware import Firmware # noqa from models.platform import Platform # noqa from models.rom import Rom, SiblingRom # noqa @@ -35,7 +36,10 @@ target_metadata = BaseModel.metadata # Ignore specific models when running migrations def include_object(object, name, type_, reflected, compare_to): - if type_ == "table" and name in [SiblingRom.__tablename__]: # Virtual table + if type_ == "table" and name in [ + SiblingRom.__tablename__, + VirtualCollection.__tablename__, + ]: # Virtual table return False return True diff --git a/backend/alembic/versions/0034_virtual_collections_db_view.py b/backend/alembic/versions/0034_virtual_collections_db_view.py new file mode 100644 index 000000000..b382fc677 --- /dev/null +++ b/backend/alembic/versions/0034_virtual_collections_db_view.py @@ -0,0 +1,306 @@ +"""empty message + +Revision ID: 0034_virtual_collections_db_view +Revises: 0033_rom_file_and_hashes +Create Date: 2024-08-08 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op +from utils.database import CustomJSON, is_postgresql + +# revision identifiers, used by Alembic. +revision = "0034_virtual_collections_db_view" +down_revision = "0033_rom_file_and_hashes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "collections_roms", + sa.Column("collection_id", sa.Integer(), nullable=False), + sa.Column("rom_id", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["collection_id"], ["collections.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["rom_id"], ["roms.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("collection_id", "rom_id"), + sa.UniqueConstraint("collection_id", "rom_id", name="unique_collection_rom"), + ) + + connection = op.get_bind() + + if is_postgresql(connection): + connection.execute( + sa.text( + """ + INSERT INTO collections_roms (collection_id, rom_id, created_at, updated_at) + SELECT c.id, rom_id::INT, NOW(), NOW() + FROM collections c, + LATERAL jsonb_array_elements_text(c.roms) AS rom_id + """ + ) + ) + connection.execute( + sa.text( + """ + CREATE OR REPLACE VIEW virtual_collections AS + WITH genres_collection AS ( + SELECT + r.id as rom_id, + r.path_cover_s as path_cover_s, + r.path_cover_l as path_cover_l, + jsonb_array_elements_text(igdb_metadata -> 'genres') as collection_name, + 'genre' as collection_type + FROM roms r + WHERE igdb_metadata->'genres' IS NOT NULL + ), + franchises_collection AS ( + SELECT + r.id as rom_id, + r.path_cover_s as path_cover_s, + r.path_cover_l as path_cover_l, + jsonb_array_elements_text(igdb_metadata -> 'franchises') as collection_name, + 'franchise' as collection_type + FROM roms r + WHERE igdb_metadata->'franchises' IS NOT NULL + ), + collection_collection AS ( + SELECT + r.id as rom_id, + r.path_cover_s as path_cover_s, + r.path_cover_l as path_cover_l, + jsonb_array_elements_text(igdb_metadata -> 'collections') as collection_name, + 'collection' as collection_type + FROM roms r + WHERE igdb_metadata->'collections' IS NOT NULL + ), + modes_collection AS ( + SELECT + r.id as rom_id, + r.path_cover_s as path_cover_s, + r.path_cover_l as path_cover_l, + jsonb_array_elements_text(igdb_metadata -> 'game_modes') as collection_name, + 'mode' as collection_type + FROM roms r + WHERE igdb_metadata->'game_modes' IS NOT NULL + ), + companies_collection AS ( + SELECT + r.id as rom_id, + r.path_cover_s as path_cover_s, + r.path_cover_l as path_cover_l, + jsonb_array_elements_text(igdb_metadata -> 'companies') as collection_name, + 'company' as collection_type + FROM roms r + WHERE igdb_metadata->'companies' IS NOT NULL + ) + SELECT + collection_name as name, + collection_type as type, + 'Autogenerated ' || collection_type || ' collection' AS description, + NOW() AS created_at, + NOW() AS updated_at, + array_to_json(array_agg(rom_id)) as rom_ids, + array_to_json(array_agg(path_cover_s)) as path_covers_s, + array_to_json(array_agg(path_cover_l)) as path_covers_l + FROM ( + SELECT * FROM genres_collection + UNION ALL + SELECT * FROM franchises_collection + UNION ALL + SELECT * FROM collection_collection + UNION ALL + SELECT * FROM modes_collection + UNION ALL + SELECT * FROM companies_collection + ) combined + GROUP BY collection_type, collection_name + HAVING COUNT(DISTINCT rom_id) > 2 + ORDER BY collection_type, collection_name; + """ # nosec B608 + ), + ) + else: + connection.execute( + sa.text( + """ + INSERT INTO collections_roms (collection_id, rom_id, created_at, updated_at) + SELECT c.id, jt.rom_id, NOW(), NOW() + FROM collections c + JOIN JSON_TABLE(c.roms, '$[*]' COLUMNS (rom_id INT PATH '$')) AS jt + """ + ) + ) + connection.execute( + sa.text( + """ + CREATE OR REPLACE VIEW virtual_collections AS + WITH genres AS ( + SELECT + r.id as rom_id, + r.path_cover_s as path_cover_s, + r.path_cover_l as path_cover_l, + CONCAT(j.genre) as collection_name, + 'genre' as collection_type + FROM + roms r + CROSS JOIN JSON_TABLE( + JSON_EXTRACT(igdb_metadata, '$.genres'), + '$[*]' COLUMNS (genre VARCHAR(255) PATH '$') + ) j + WHERE + JSON_EXTRACT(igdb_metadata, '$.genres') IS NOT NULL + ), + franchises AS ( + SELECT + r.id as rom_id, + r.path_cover_s as path_cover_s, + r.path_cover_l as path_cover_l, + CONCAT(j.franchise) as collection_name, + 'franchise' as collection_type + FROM + roms r + CROSS JOIN JSON_TABLE( + JSON_EXTRACT(igdb_metadata, '$.franchises'), + '$[*]' COLUMNS (franchise VARCHAR(255) PATH '$') + ) j + WHERE + JSON_EXTRACT(igdb_metadata, '$.franchises') IS NOT NULL + ), + collections AS ( + SELECT + r.id as rom_id, + r.path_cover_s as path_cover_s, + r.path_cover_l as path_cover_l, + CONCAT(j.collection) as collection_name, + 'collection' as collection_type + FROM + roms r + CROSS JOIN JSON_TABLE( + JSON_EXTRACT(igdb_metadata, '$.collections'), + '$[*]' COLUMNS (collection VARCHAR(255) PATH '$') + ) j + WHERE + JSON_EXTRACT(igdb_metadata, '$.collections') IS NOT NULL + ), + modes AS ( + SELECT + r.id as rom_id, + r.path_cover_s as path_cover_s, + r.path_cover_l as path_cover_l, + CONCAT(j.mode) as collection_name, + 'mode' as collection_type + FROM + roms r + CROSS JOIN JSON_TABLE( + JSON_EXTRACT(igdb_metadata, '$.game_modes'), + '$[*]' COLUMNS (mode VARCHAR(255) PATH '$') + ) j + WHERE + JSON_EXTRACT(igdb_metadata, '$.game_modes') IS NOT NULL + ), + companies AS ( + SELECT + r.id as rom_id, + r.path_cover_s as path_cover_s, + r.path_cover_l as path_cover_l, + CONCAT(j.company) as collection_name, + 'company' as collection_type + FROM + roms r + CROSS JOIN JSON_TABLE( + JSON_EXTRACT(igdb_metadata, '$.companies'), + '$[*]' COLUMNS (company VARCHAR(255) PATH '$') + ) j + WHERE + JSON_EXTRACT(igdb_metadata, '$.companies') IS NOT NULL + ) + SELECT + collection_name as name, + collection_type as type, + CONCAT('Autogenerated ', collection_name, ' collection') AS description, + NOW() AS created_at, + NOW() AS updated_at, + JSON_ARRAYAGG(DISTINCT rom_id) as rom_ids, + JSON_ARRAYAGG(DISTINCT path_cover_s) as path_covers_s, + JSON_ARRAYAGG(DISTINCT path_cover_l) as path_covers_l + FROM + ( + SELECT * FROM genres + UNION ALL + SELECT * FROM franchises + UNION ALL + SELECT * FROM collections + UNION ALL + SELECT * FROM modes + UNION ALL + SELECT * FROM companies + ) combined + GROUP BY collection_type, collection_name + HAVING COUNT(DISTINCT rom_id) > 2 + ORDER BY collection_type, collection_name; + """ + ), + ) + + op.drop_column("collections", "roms") + + +def downgrade() -> None: + with op.batch_alter_table("collections", schema=None) as batch_op: + batch_op.add_column(sa.Column("roms", CustomJSON(), nullable=False)) + + connection = op.get_bind() + if is_postgresql(connection): + connection.execute( + sa.text( + """ + UPDATE collections c + SET roms = ( + SELECT jsonb_agg(rom_id) + FROM collections_roms cr + WHERE cr.collection_id = c.id + ); + """ + ) + ) + else: + connection.execute( + sa.text( + """ + UPDATE collections c + JOIN ( + SELECT collection_id, JSON_ARRAYAGG(rom_id) as roms + FROM collections_roms + GROUP BY collection_id + ) cr + ON c.id = cr.collection_id + SET c.roms = cr.roms; + """ + ) + ) + + op.drop_table("collections_roms") + + connection.execute( + sa.text( + """ + DROP VIEW virtual_collections; + """ + ), + ) diff --git a/backend/endpoints/collections.py b/backend/endpoints/collections.py index 7a3ecfb5e..6fbf9932c 100644 --- a/backend/endpoints/collections.py +++ b/backend/endpoints/collections.py @@ -6,7 +6,7 @@ from anyio import Path from config import RESOURCES_BASE_PATH from decorators.auth import protected_route from endpoints.responses import MessageResponse -from endpoints.responses.collection import CollectionSchema +from endpoints.responses.collection import CollectionSchema, VirtualCollectionSchema from exceptions.endpoint_exceptions import ( CollectionAlreadyExistsException, CollectionNotFoundInDatabaseException, @@ -29,7 +29,7 @@ router = APIRouter( ) -@protected_route(router.post, "/", [Scope.COLLECTIONS_WRITE]) +@protected_route(router.post, "", [Scope.COLLECTIONS_WRITE]) async def add_collection( request: Request, artwork: UploadFile | None = None, @@ -98,7 +98,7 @@ async def add_collection( return CollectionSchema.model_validate(created_collection) -@protected_route(router.get, "/", [Scope.COLLECTIONS_READ]) +@protected_route(router.get, "", [Scope.COLLECTIONS_READ]) def get_collections(request: Request) -> list[CollectionSchema]: """Get collections endpoint @@ -111,9 +111,30 @@ def get_collections(request: Request) -> list[CollectionSchema]: """ collections = db_collection_handler.get_collections() + return CollectionSchema.for_user(request.user.id, [c for c in collections]) +@protected_route(router.get, "/virtual", [Scope.COLLECTIONS_READ]) +def get_virtual_collections( + request: Request, + type: str, + limit: int | None = None, +) -> list[VirtualCollectionSchema]: + """Get virtual collections endpoint + + Args: + request (Request): Fastapi Request object + + Returns: + list[VirtualCollectionSchema]: List of virtual collections + """ + + virtual_collections = db_collection_handler.get_virtual_collections(type, limit) + + return [VirtualCollectionSchema.model_validate(vc) for vc in virtual_collections] + + @protected_route(router.get, "/{id}", [Scope.COLLECTIONS_READ]) def get_collection(request: Request, id: int) -> CollectionSchema: """Get collections endpoint @@ -127,13 +148,31 @@ def get_collection(request: Request, id: int) -> CollectionSchema: """ collection = db_collection_handler.get_collection(id) - if not collection: raise CollectionNotFoundInDatabaseException(id) return CollectionSchema.model_validate(collection) +@protected_route(router.get, "/virtual/{id}", [Scope.COLLECTIONS_READ]) +def get_virtual_collection(request: Request, id: str) -> VirtualCollectionSchema: + """Get virtual collections endpoint + + Args: + request (Request): Fastapi Request object + id (str): Virtual collection id + + Returns: + VirtualCollectionSchema: Virtual collection + """ + + virtual_collection = db_collection_handler.get_virtual_collection(id) + if not virtual_collection: + raise CollectionNotFoundInDatabaseException(id) + + return VirtualCollectionSchema.model_validate(virtual_collection) + + @protected_route(router.put, "/{id}", [Scope.COLLECTIONS_WRITE]) async def update_collection( request: Request, @@ -164,17 +203,14 @@ async def update_collection( raise CollectionNotFoundInDatabaseException(id) try: - roms = json.loads(data["roms"]) # type: ignore + rom_ids = json.loads(data["rom_ids"]) # type: ignore except json.JSONDecodeError as e: - raise ValueError("Invalid list for roms field in update collection") from e - except KeyError: - roms = collection.roms + raise ValueError("Invalid list for rom_ids field in update collection") from e cleaned_data = { "name": data.get("name", collection.name), "description": data.get("description", collection.description), "is_public": is_public if is_public is not None else collection.is_public, - "roms": list(set(roms)), "user_id": request.user.id, } @@ -219,7 +255,9 @@ async def update_collection( {"path_cover_s": path_cover_s, "path_cover_l": path_cover_l} ) - updated_collection = db_collection_handler.update_collection(id, cleaned_data) + updated_collection = db_collection_handler.update_collection( + id, cleaned_data, rom_ids + ) return CollectionSchema.model_validate(updated_collection) diff --git a/backend/endpoints/configs.py b/backend/endpoints/configs.py index 0cbb4926a..b195ef13c 100644 --- a/backend/endpoints/configs.py +++ b/backend/endpoints/configs.py @@ -17,7 +17,7 @@ router = APIRouter( ) -@router.get("/") +@router.get("") def get_config() -> ConfigResponse: """Get config endpoint diff --git a/backend/endpoints/firmware.py b/backend/endpoints/firmware.py index 318f1fc37..f50924061 100644 --- a/backend/endpoints/firmware.py +++ b/backend/endpoints/firmware.py @@ -17,7 +17,7 @@ router = APIRouter( ) -@protected_route(router.post, "/", [Scope.FIRMWARE_WRITE]) +@protected_route(router.post, "", [Scope.FIRMWARE_WRITE]) def add_firmware( request: Request, platform_id: int, @@ -84,7 +84,7 @@ def add_firmware( } -@protected_route(router.get, "/", [Scope.FIRMWARE_READ]) +@protected_route(router.get, "", [Scope.FIRMWARE_READ]) def get_platform_firmware( request: Request, platform_id: int | None = None, diff --git a/backend/endpoints/platform.py b/backend/endpoints/platform.py index e58cbecb0..dacbe7a99 100644 --- a/backend/endpoints/platform.py +++ b/backend/endpoints/platform.py @@ -20,7 +20,7 @@ router = APIRouter( ) -@protected_route(router.post, "/", [Scope.PLATFORMS_WRITE]) +@protected_route(router.post, "", [Scope.PLATFORMS_WRITE]) async def add_platforms(request: Request) -> PlatformSchema: """Create platform endpoint @@ -43,7 +43,7 @@ async def add_platforms(request: Request) -> PlatformSchema: ) -@protected_route(router.get, "/", [Scope.PLATFORMS_READ]) +@protected_route(router.get, "", [Scope.PLATFORMS_READ]) def get_platforms(request: Request) -> list[PlatformSchema]: """Get platforms endpoint diff --git a/backend/endpoints/responses/collection.py b/backend/endpoints/responses/collection.py index da8d6ee70..c394252e8 100644 --- a/backend/endpoints/responses/collection.py +++ b/backend/endpoints/responses/collection.py @@ -9,15 +9,17 @@ class CollectionSchema(BaseModel): id: int name: str description: str - path_cover_l: str | None - path_cover_s: str | None - has_cover: bool + path_cover_small: str | None + path_cover_large: str | None + path_covers_small: list[str] + path_covers_large: list[str] url_cover: str - roms: set[int] + rom_ids: set[int] rom_count: int user_id: int user__username: str is_public: bool + is_favorite: bool created_at: datetime updated_at: datetime @@ -34,3 +36,17 @@ class CollectionSchema(BaseModel): for c in collections if c.user_id == user_id or c.is_public ] + + +class VirtualCollectionSchema(BaseModel): + id: str + name: str + type: str + description: str + rom_ids: set[int] + rom_count: int + path_covers_small: list[str] + path_covers_large: list[str] + + class Config: + from_attributes = True diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 307518190..3d25c0fd1 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -147,7 +147,7 @@ class RomSchema(BaseModel): alternative_names: list[str] genres: list[str] franchises: list[str] - collections: list[str] + meta_collections: list[str] companies: list[str] game_modes: list[str] age_ratings: list[str] @@ -155,14 +155,16 @@ class RomSchema(BaseModel): moby_metadata: RomMobyMetadata | None ss_metadata: RomMobyMetadata | None - path_cover_s: str | None - path_cover_l: str | None - has_cover: bool + path_cover_small: str | None + path_cover_large: str | None url_cover: str | None + has_manual: bool path_manual: str | None url_manual: str | None + is_unidentified: bool + revision: str | None regions: list[str] languages: list[str] @@ -242,7 +244,7 @@ class DetailedRomSchema(RomSchema): if s.user_id == user_id ] db_rom.user_collections = CollectionSchema.for_user( # type: ignore - user_id, [c for c in db_rom.get_collections()] + user_id, db_rom.collections ) return cls.model_validate(db_rom) diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 95e65057b..2bc222bec 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -27,7 +27,7 @@ from exceptions.fs_exceptions import RomAlreadyExistsException from fastapi import HTTPException, Request, UploadFile, status from fastapi.responses import Response from handler.auth.constants import Scope -from handler.database import db_collection_handler, db_platform_handler, db_rom_handler +from handler.database import db_platform_handler, db_rom_handler from handler.filesystem import fs_resource_handler, fs_rom_handler from handler.filesystem.base_handler import CoverSize from handler.metadata import meta_igdb_handler, meta_moby_handler, meta_ss_handler @@ -50,7 +50,7 @@ router = APIRouter( ) -@protected_route(router.post, "/", [Scope.ROMS_WRITE]) +@protected_route(router.post, "", [Scope.ROMS_WRITE]) async def add_rom(request: Request): """Upload single rom endpoint @@ -112,11 +112,12 @@ async def add_rom(request: Request): return Response(status_code=status.HTTP_201_CREATED) -@protected_route(router.get, "/", [Scope.ROMS_READ]) +@protected_route(router.get, "", [Scope.ROMS_READ]) def get_roms( request: Request, platform_id: int | None = None, collection_id: int | None = None, + virtual_collection_id: str | None = None, search_term: str = "", limit: int | None = None, offset: int | None = None, @@ -130,6 +131,7 @@ def get_roms( request (Request): Fastapi Request object platform_id (int, optional): Platform ID to filter ROMs collection_id (int, optional): Collection ID to filter ROMs + virtual_collection_id (str, optional): Virtual Collection ID to filter ROMs search_term (str, optional): Search term to filter ROMs limit (int, optional): Limit the number of ROMs returned offset (int, optional): Offset for pagination @@ -145,6 +147,7 @@ def get_roms( roms = db_rom_handler.get_roms( platform_id=platform_id, collection_id=collection_id, + virtual_collection_id=virtual_collection_id, search_term=search_term.lower(), order_by=order_by.lower(), order_dir=order_dir.lower(), @@ -156,6 +159,7 @@ def get_roms( user_id=request.user.id, platform_id=platform_id, collection_id=collection_id, + virtual_collection_id=virtual_collection_id, search_term=search_term, order_by=order_by, order_dir=order_dir, @@ -694,14 +698,6 @@ async def delete_roms( log.info(f"Deleting {rom.fs_name} from database") db_rom_handler.delete_rom(id) - # Update collections to remove the deleted rom - collections = db_collection_handler.get_collections_by_rom_id(id) - for collection in collections: - collection.roms = {rom_id for rom_id in collection.roms if rom_id != id} - db_collection_handler.update_collection( - collection.id, {"roms": collection.roms} - ) - try: rmtree(f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}") except FileNotFoundError: diff --git a/backend/endpoints/saves.py b/backend/endpoints/saves.py index 829ab2463..2f520ae7d 100644 --- a/backend/endpoints/saves.py +++ b/backend/endpoints/saves.py @@ -18,7 +18,7 @@ router = APIRouter( ) -@protected_route(router.post, "/", [Scope.ASSETS_WRITE]) +@protected_route(router.post, "", [Scope.ASSETS_WRITE]) def add_saves( request: Request, rom_id: int, @@ -87,7 +87,7 @@ def add_saves( } -# @protected_route(router.get, "/", [Scope.ASSETS_READ]) +# @protected_route(router.get, "", [Scope.ASSETS_READ]) # def get_saves(request: Request) -> MessageResponse: # pass diff --git a/backend/endpoints/screenshots.py b/backend/endpoints/screenshots.py index 946ab650d..616ee6a6d 100644 --- a/backend/endpoints/screenshots.py +++ b/backend/endpoints/screenshots.py @@ -15,7 +15,7 @@ router = APIRouter( ) -@protected_route(router.post, "/", [Scope.ASSETS_WRITE]) +@protected_route(router.post, "", [Scope.ASSETS_WRITE]) def add_screenshots( request: Request, rom_id: int, diff --git a/backend/endpoints/states.py b/backend/endpoints/states.py index d9eedaa5d..9767a9b52 100644 --- a/backend/endpoints/states.py +++ b/backend/endpoints/states.py @@ -18,7 +18,7 @@ router = APIRouter( ) -@protected_route(router.post, "/", [Scope.ASSETS_WRITE]) +@protected_route(router.post, "", [Scope.ASSETS_WRITE]) def add_states( request: Request, rom_id: int, @@ -86,7 +86,7 @@ def add_states( } -# @protected_route(router.get, "/", [Scope.ASSETS_READ]) +# @protected_route(router.get, "", [Scope.ASSETS_READ]) # def get_states(request: Request) -> MessageResponse: # pass diff --git a/backend/endpoints/stats.py b/backend/endpoints/stats.py index 081dc98bd..7fbd358d7 100644 --- a/backend/endpoints/stats.py +++ b/backend/endpoints/stats.py @@ -8,7 +8,7 @@ router = APIRouter( ) -@router.get("/") +@router.get("") def stats() -> StatsReturn: """Endpoint to return the current RomM stats diff --git a/backend/endpoints/user.py b/backend/endpoints/user.py index 687afcd83..c423705de 100644 --- a/backend/endpoints/user.py +++ b/backend/endpoints/user.py @@ -24,7 +24,7 @@ router = APIRouter( @protected_route( router.post, - "/", + "", [], status_code=status.HTTP_201_CREATED, ) @@ -80,7 +80,7 @@ def add_user( return UserSchema.model_validate(db_user_handler.add_user(user)) -@protected_route(router.get, "/", [Scope.USERS_READ]) +@protected_route(router.get, "", [Scope.USERS_READ]) def get_users(request: Request) -> list[UserSchema]: """Get all users endpoint diff --git a/backend/handler/database/collections_handler.py b/backend/handler/database/collections_handler.py index b6de43094..cfc83f6cd 100644 --- a/backend/handler/database/collections_handler.py +++ b/backend/handler/database/collections_handler.py @@ -1,11 +1,9 @@ -from typing import Any, Sequence +from typing import Sequence from decorators.database import begin_session -from models.collection import Collection -from sqlalchemy import delete, select, update +from models.collection import Collection, CollectionRom, VirtualCollection +from sqlalchemy import delete, insert, literal, or_, select, update from sqlalchemy.orm import Session -from sqlalchemy.sql import ColumnExpressionArgument -from utils.database import json_array_contains_value from .base_handler import DBBaseHandler @@ -24,6 +22,15 @@ class DBCollectionsHandler(DBBaseHandler): 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_virtual_collection( + self, id: str, session: Session = None + ) -> VirtualCollection | None: + name, type = VirtualCollection.from_id(id) + return session.scalar( + select(VirtualCollection).filter_by(name=name, type=type).limit(1) + ) + @begin_session def get_collection_by_name( self, name: str, user_id: int, session: Session = None @@ -41,24 +48,27 @@ class DBCollectionsHandler(DBBaseHandler): ) @begin_session - def get_collections_by_rom_id( - self, - rom_id: int, - *, - order_by: Sequence[str | ColumnExpressionArgument[Any]] | None = None, - session: Session = None, - ) -> Sequence[Collection]: - query = select(Collection).filter( - json_array_contains_value(Collection.roms, rom_id, session=session) + def get_virtual_collections( + self, type: str, limit: int | None = None, session: Session = None + ) -> Sequence[VirtualCollection]: + return ( + session.scalars( + select(VirtualCollection) + .filter(or_(VirtualCollection.type == type, literal(type == "all"))) + .limit(limit) + .order_by(VirtualCollection.name.asc()) + ) + .unique() + .all() ) - if order_by is not None: - query = query.order_by(*order_by) - - return session.scalars(query).all() @begin_session def update_collection( - self, id: int, data: dict, session: Session = None + self, + id: int, + data: dict, + rom_ids: list[int] | None = None, + session: Session = None, ) -> Collection: session.execute( update(Collection) @@ -66,6 +76,18 @@ class DBCollectionsHandler(DBBaseHandler): .values(**data) .execution_options(synchronize_session="evaluate") ) + + if rom_ids: + # Delete all existing CollectionRom entries for this collection + session.execute( + delete(CollectionRom).where(CollectionRom.collection_id == id) + ) + # Insert new CollectionRom entries for this collection + session.execute( + insert(CollectionRom), + [{"collection_id": id, "rom_id": rom_id} for rom_id in set(rom_ids)], + ) + return session.query(Collection).filter_by(id=id).one() @begin_session diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index c2b32cba1..b00a1950f 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -3,7 +3,7 @@ from collections.abc import Iterable from typing import Sequence from decorators.database import begin_session -from models.collection import Collection +from models.collection import Collection, VirtualCollection from models.rom import Rom, RomFile, RomUser from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.orm import Query, Session, selectinload @@ -26,6 +26,7 @@ def with_details(func): selectinload(Rom.screenshots), selectinload(Rom.rom_users), selectinload(Rom.sibling_roms), + selectinload(Rom.collections), ) return func(*args, **kwargs) @@ -55,6 +56,7 @@ class DBRomsHandler(DBBaseHandler): data, platform_id: int | None, collection_id: int | None, + virtual_collection_id: str | None, search_term: str, session: Session, ): @@ -68,7 +70,17 @@ class DBRomsHandler(DBBaseHandler): .one_or_none() ) if collection: - data = data.filter(Rom.id.in_(collection.roms)) + data = data.filter(Rom.id.in_(collection.rom_ids)) + + if virtual_collection_id: + name, type = VirtualCollection.from_id(virtual_collection_id) + v_collection = ( + session.query(VirtualCollection) + .filter(VirtualCollection.name == name, VirtualCollection.type == type) + .one_or_none() + ) + if v_collection: + data = data.filter(Rom.id.in_(v_collection.rom_ids)) if search_term: data = data.filter( @@ -113,6 +125,7 @@ class DBRomsHandler(DBBaseHandler): *, platform_id: int | None = None, collection_id: int | None = None, + virtual_collection_id: str | None = None, search_term: str = "", order_by: str = "name", order_dir: str = "asc", @@ -122,7 +135,12 @@ class DBRomsHandler(DBBaseHandler): session: Session = None, ) -> Sequence[Rom]: filtered_query = self._filter( - query, platform_id, collection_id, search_term, session + query, + platform_id, + collection_id, + virtual_collection_id, + search_term, + session, ) ordered_query = self._order(filtered_query, order_by, order_dir) offset_query = ordered_query.offset(offset) @@ -246,6 +264,7 @@ class DBRomsHandler(DBBaseHandler): user_id: int, platform_id: int | None = None, collection_id: int | None = None, + virtual_collection_id: str | None = None, search_term: str = "", order_by: str = "name", order_dir: str = "asc", @@ -260,7 +279,12 @@ class DBRomsHandler(DBBaseHandler): .order_by(RomUser.last_played.desc()) ) filtered_query = self._filter( - filtered_query, platform_id, collection_id, search_term, session + filtered_query, + platform_id, + collection_id, + virtual_collection_id, + search_term, + session, ) offset_query = filtered_query.offset(offset) limited_query = offset_query.limit(limit) diff --git a/backend/models/collection.py b/backend/models/collection.py index 2de5e96bd..934c10d5e 100644 --- a/backend/models/collection.py +++ b/backend/models/collection.py @@ -1,10 +1,13 @@ from __future__ import annotations -from functools import cached_property +import base64 +import json +from config import FRONTEND_RESOURCES_PATH from models.base import BaseModel +from models.rom import Rom from models.user import User -from sqlalchemy import ForeignKey, String, Text +from sqlalchemy import ForeignKey, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from utils.database import CustomJSON @@ -16,37 +19,141 @@ class Collection(BaseModel): name: Mapped[str] = mapped_column(String(length=400)) description: Mapped[str | None] = mapped_column(Text) - + is_public: Mapped[bool] = mapped_column(default=False) 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( - CustomJSON(), default=[], doc="Rom id's that belong to this collection" + roms: Mapped[list[Rom]] = relationship( + "Rom", + secondary="collections_roms", + collection_class=set, + back_populates="collections", + lazy="joined", ) 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_ids(self) -> list[int]: + return [r.id for r in self.roms] + @property def rom_count(self) -> int: 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)}" + @property + def path_cover_small(self) -> str: + return ( + f"{FRONTEND_RESOURCES_PATH}/{self.path_cover_s}?ts={self.updated_at}" + if self.path_cover_s + else "" + ) + + @property + def path_cover_large(self) -> str: + return ( + f"{FRONTEND_RESOURCES_PATH}/{self.path_cover_l}?ts={self.updated_at}" + if self.path_cover_l + else "" + ) + + @property + def path_covers_small(self) -> list[str]: + return [ + f"{FRONTEND_RESOURCES_PATH}/{r.path_cover_s}?ts={self.updated_at}" + for r in self.roms + if r.path_cover_s + ] + + @property + def path_covers_large(self) -> list[str]: + return [ + f"{FRONTEND_RESOURCES_PATH}/{r.path_cover_l}?ts={self.updated_at}" + for r in self.roms + if r.path_cover_l + ] + + @property + def is_favorite(self) -> bool: + return self.name.lower() == "favourites" + def __repr__(self) -> str: return self.name + + +class CollectionRom(BaseModel): + __tablename__ = "collections_roms" + + collection_id: Mapped[int] = mapped_column( + ForeignKey("collections.id", ondelete="CASCADE"), primary_key=True + ) + rom_id: Mapped[int] = mapped_column( + ForeignKey("roms.id", ondelete="CASCADE"), primary_key=True + ) + + __table_args__ = ( + UniqueConstraint("collection_id", "rom_id", name="unique_collection_rom"), + ) + + +class VirtualCollection(BaseModel): + __tablename__ = "virtual_collections" + + name: Mapped[str] = mapped_column(String(length=400), primary_key=True) + type: Mapped[str] = mapped_column(String(length=50), primary_key=True) + description: Mapped[str | None] = mapped_column(Text) + path_covers_s: Mapped[list[str]] = mapped_column(CustomJSON(), default=[]) + path_covers_l: Mapped[list[str]] = mapped_column(CustomJSON(), default=[]) + + rom_ids: Mapped[set[int]] = mapped_column( + CustomJSON(), default=[], doc="Rom IDs that belong to this collection" + ) + + @property + def id(self) -> str: + # Create a reversible encoded ID + data = json.dumps({"name": self.name, "type": self.type}) + return base64.urlsafe_b64encode(data.encode()).decode() + + @classmethod + def from_id(cls, id_: str): + data = json.loads(base64.urlsafe_b64decode(id_).decode()) + return data["name"], data["type"] + + @property + def rom_count(self) -> int: + return len(self.rom_ids) + + @property + def path_covers_small(self) -> list[str]: + return [ + f"{FRONTEND_RESOURCES_PATH}/{cover}?ts={self.updated_at}" + for cover in self.path_covers_s + ] + + @property + def path_covers_large(self) -> list[str]: + return [ + f"{FRONTEND_RESOURCES_PATH}/{cover}?ts={self.updated_at}" + for cover in self.path_covers_l + ] + + __table_args__ = ( + UniqueConstraint( + "name", + "type", + name="unique_virtual_collection_name_type", + ), + ) diff --git a/backend/models/firmware.py b/backend/models/firmware.py index 06b2c73f5..f0a95dae8 100644 --- a/backend/models/firmware.py +++ b/backend/models/firmware.py @@ -39,7 +39,9 @@ class Firmware(BaseModel): md5_hash: Mapped[str] = mapped_column(String(length=100)) sha1_hash: Mapped[str] = mapped_column(String(length=100)) - platform: Mapped[Platform] = relationship(lazy="joined", back_populates="firmware") + platform: Mapped[Platform] = relationship( + lazy="immediate", back_populates="firmware" + ) @property def platform_slug(self) -> str: diff --git a/backend/models/fixtures/known_bios_files.json b/backend/models/fixtures/known_bios_files.json index 34e844225..8d9877009 100644 --- a/backend/models/fixtures/known_bios_files.json +++ b/backend/models/fixtures/known_bios_files.json @@ -173,6 +173,12 @@ "md5": "79ae0d2bb1901b7e606b6dc339b79a97", "sha1": "06fc753d015b43ca1787f4cfd9331b1674202e64" }, + "arcade:naomi2.zip": { + "size": "2189528", + "crc": "f25cf3a8", + "md5": "0ea5bf0345e27b1cf51bbde1bd398eca", + "sha1": "533ef12a6d22726da81a50f08871c6e9a377a328" + }, "atari8bit:ATARIBAS.ROM": { "size": "8192", "crc": "7d684184", @@ -335,6 +341,12 @@ "md5": "80dcd1ad1a4cf65d64b7ba10504e8190", "sha1": "032cb1c1c75b9a191fa1230978971698d9d2a17f" }, + "msx2:DISK.ROM": { + "size": "16384", + "crc": "721f61df", + "md5": "80dcd1ad1a4cf65d64b7ba10504e8190", + "sha1": "032cb1c1c75b9a191fa1230978971698d9d2a17f" + }, "msx:FMPAC.ROM": { "size": "65536", "crc": "0e84505d", @@ -1439,6 +1451,12 @@ "md5": "08ca8b2dba6662e8024f9e789711c6fc", "sha1": "5142f205912869b673a71480c5828b1eaed782a8" }, + "neo-geo-cd:neocdz.zip": { + "size": "214876", + "crc": "19681e91", + "md5": "c38cb8e50321783e413dc5ff292a3ff8", + "sha1": "bf6b379c204da77dece1aedf83ff35227a623e5d" + }, "ps:PSXONPSP660.BIN": { "size": "524288", "crc": "5660f34f", @@ -2122,5 +2140,107 @@ "crc": "1466aed4", "md5": "88dc7876d584f90e4106f91444ab23b7", "sha1": "072c6160d4e7d406f5d8f5b1b66066c797d35561" + }, + "doom:DOOM1.WAD": { + "size": "4196020", + "crc": "162b696a", + "md5": "f0cefca49926d00903cf57551d901abe", + "sha1": "5b2e249b9c5133ec987b3ea77596381dc0d6bc1d" + }, + "doom:DOOM.WAD": { + "size": "12733492", + "crc": "cff03d9f", + "md5": "4461d4511386518e784c647e3128e7bc", + "sha1": "997bae5e5a190c5bb3b1fb9e7e3e75b2da88cb27" + }, + "doom:DOOM2.WAD": { + "size": "14802506", + "crc": "09b8a6ae", + "md5": "9aa3cbf65b961d0bdac98ec403b832e1", + "sha1": "c745f04a6abc2e6d2a2d52382f45500dd2a260be" + }, + "doom:DOOM2F.WAD": { + "size": "14607420", + "crc": "27eaae69", + "md5": "3cb02349b3df649c86290907eed64e7b", + "sha1": "d510c877031bbd5f3d198581a2c8651e09b9861f" + }, + "doom:DOOM64.WAD": { + "size": "15103212", + "crc": "65816192", + "md5": "0aaba212339c72250f8a53a0a2b6189e", + "sha1": "d041456bea851c173f65ac6ab3f2ee61bb0b8b53" + }, + "doom:TNT.WAD": { + "size": "18304630", + "crc": "15f18ddb", + "md5": "8974e3117ed4a1839c752d5e11ab1b7b", + "sha1": "9820e2a3035f0cdd87f69a7d57c59a7a267c9409" + }, + "doom:PLUTONIA.WAD": { + "size": "17531493", + "crc": "650b998d", + "md5": "24037397056e919961005e08611623f4", + "sha1": " 816c7c6b0098f66c299c9253f62bd908456efb63" + }, + "doom:HERETIC1.WAD": { + "size": "5120920", + "crc": "22d3f0ca", + "md5": "ae779722390ec32fa37b0d361f7d82f8", + "sha1": "b4c50ca9bea07f7c35250a1a11906091971c05ae" + }, + "doom:HERETIC.WAD": { + "size": "14189976", + "crc": "5b16049e", + "md5": "66d686b1ed6d35ff103f15dbd30e0341", + "sha1": "f489d479371df32f6d280a0cb23b59a35ba2b833" + }, + "doom:HEXEN.WAD": { + "size": "20083672", + "crc": "dca9114c", + "md5": "abb033caf81e26f12a2103e1fa25453f", + "sha1": "4b53832f0733c1e29e5f1de2428e5475e891af29" + }, + "doom:HEXDD.WAD": { + "size": "4440584", + "crc": "fd5eb11d", + "md5": "78d5898e99e220e4de64edaa0e479593", + "sha1": "081f6a2024643b54ef4a436a85508539b6d20a1e" + }, + "doom:STRIFE0.WAD": { + "size": "9934413", + "crc": "93c144dd", + "md5": "bb545b9c4eca0ff92c14d466b3294023", + "sha1": "bc0a110bf27aee89a0b2fc8111e2391ede891b8d" + }, + "doom:STRIFE1.WAD": { + "size": "28377364", + "crc": "4234ace5", + "md5": "2fed2031a5b03892106e0f117f17901f", + "sha1": "64c13b951a845ca7f8081f68138a6181557458d1" + }, + "doom:VOICES.WAD": { + "size": "27319149", + "crc": "cd12ebcf", + "md5": "082234d6a3f7086424856478b5aa9e95", + "sha1": "ec6883100d807b894a98f426d024d22c77b63e7f" + }, + "doom:CHEX.WAD": { + "size": "12361532", + "crc": "298dd5b5", + "md5": "25485721882b050afa96a56e5758dd52", + "sha1": "eca9cff1014ce5081804e193588d96c6ddb35432" + }, + "xbox:Complex_4627.bin": { + "size": "1048576", + "crc": "ccb97a84", + "md5": "ec00e31e746de2473acfe7903c5a4cb7", + "sha1": "6639b6693784574d204c42703a74fd8b088a3a5e" + }, + "xbox:mcpx_1.0.bin": { + "size": "512", + "crc": "0b07d1f1", + "md5": "d49c52a4102f6df7bcf8d0617ac475ed", + "sha1": "5d270675b54eb8071b480e42d22a3015ac211cef" } } diff --git a/backend/models/rom.py b/backend/models/rom.py index ca2d6181c..77bce6b3c 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -3,7 +3,7 @@ from __future__ import annotations import enum from datetime import datetime from functools import cached_property -from typing import TYPE_CHECKING, Any, Sequence +from typing import TYPE_CHECKING, Any from config import FRONTEND_RESOURCES_PATH from models.base import BaseModel @@ -17,7 +17,6 @@ from sqlalchemy import ( String, Text, UniqueConstraint, - func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from utils.database import CustomJSON, safe_float, safe_int @@ -134,12 +133,19 @@ class Rom(BaseModel): primaryjoin="Rom.id == SiblingRom.rom_id", secondaryjoin="Rom.id == SiblingRom.sibling_rom_id", ) - files: Mapped[list[RomFile]] = relationship(back_populates="rom", lazy="immediate") + files: Mapped[list[RomFile]] = relationship(lazy="immediate", back_populates="rom") saves: Mapped[list[Save]] = relationship(back_populates="rom") states: Mapped[list[State]] = relationship(back_populates="rom") screenshots: Mapped[list[Screenshot]] = relationship(back_populates="rom") rom_users: Mapped[list[RomUser]] = relationship(back_populates="rom") + collections: Mapped[list[Collection]] = relationship( + "Collection", + secondary="collections_roms", + collection_class=set, + back_populates="roms", + ) + @property def platform_slug(self) -> str: return self.platform.slug @@ -164,10 +170,6 @@ class Rom(BaseModel): def full_path(self) -> str: return f"{self.fs_path}/{self.fs_name}" - @cached_property - def has_cover(self) -> bool: - return bool(self.path_cover_s or self.path_cover_l) - @cached_property def has_manual(self) -> bool: return bool(self.path_manual) @@ -189,14 +191,30 @@ class Rom(BaseModel): def fs_size_bytes(self) -> int: return sum(f.file_size_bytes for f in self.files) - def get_collections(self) -> Sequence[Collection]: - from handler.database import db_collection_handler + @property + def fs_resources_path(self) -> str: + return f"roms/{str(self.platform_id)}/{str(self.id)}" - return db_collection_handler.get_collections_by_rom_id( - self.id, - order_by=[func.lower("name")], + @property + def path_cover_small(self) -> str: + return ( + f"{FRONTEND_RESOURCES_PATH}/{self.path_cover_s}?ts={self.updated_at}" + if self.path_cover_s + else "" ) + @property + def path_cover_large(self) -> str: + return ( + f"{FRONTEND_RESOURCES_PATH}/{self.path_cover_l}?ts={self.updated_at}" + if self.path_cover_l + else "" + ) + + @property + def is_unidentified(self) -> bool: + return not self.igdb_id and not self.moby_id + # Metadata fields @property def youtube_video_id(self) -> str: @@ -251,7 +269,7 @@ class Rom(BaseModel): return [] @property - def collections(self) -> list[str]: + def meta_collections(self) -> list[str]: if self.igdb_metadata: return self.igdb_metadata.get("collections", []) return [] @@ -274,10 +292,6 @@ class Rom(BaseModel): return [r["rating"] for r in self.igdb_metadata.get("age_ratings", [])] return [] - @property - def fs_resources_path(self) -> str: - return f"roms/{str(self.platform_id)}/{str(self.id)}" - def __repr__(self) -> str: return self.fs_name diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index dfb688fa2..39859c038 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -4,11 +4,11 @@ /* eslint-disable */ export type { AddFirmwareResponse } from './models/AddFirmwareResponse'; -export type { Body_add_collection_api_collections_post } from './models/Body_add_collection_api_collections_post'; -export type { Body_add_firmware_api_firmware_post } from './models/Body_add_firmware_api_firmware_post'; -export type { Body_add_saves_api_saves_post } from './models/Body_add_saves_api_saves_post'; -export type { Body_add_screenshots_api_screenshots_post } from './models/Body_add_screenshots_api_screenshots_post'; -export type { Body_add_states_api_states_post } from './models/Body_add_states_api_states_post'; +export type { Body_add_collection_api_collections__post } from './models/Body_add_collection_api_collections__post'; +export type { Body_add_firmware_api_firmware__post } from './models/Body_add_firmware_api_firmware__post'; +export type { Body_add_saves_api_saves__post } from './models/Body_add_saves_api_saves__post'; +export type { Body_add_screenshots_api_screenshots__post } from './models/Body_add_screenshots_api_screenshots__post'; +export type { Body_add_states_api_states__post } from './models/Body_add_states_api_states__post'; export type { Body_token_api_token_post } from './models/Body_token_api_token_post'; export type { Body_update_collection_api_collections__id__put } from './models/Body_update_collection_api_collections__id__put'; export type { Body_update_rom_api_roms__id__put } from './models/Body_update_rom_api_roms__id__put'; @@ -58,6 +58,7 @@ export type { UploadedStatesResponse } from './models/UploadedStatesResponse'; export type { UserNotesSchema } from './models/UserNotesSchema'; export type { UserSchema } from './models/UserSchema'; export type { ValidationError } from './models/ValidationError'; +export type { VirtualCollectionSchema } from './models/VirtualCollectionSchema'; export type { WatcherDict } from './models/WatcherDict'; export type { WebrcadeFeedCategorySchema } from './models/WebrcadeFeedCategorySchema'; export type { WebrcadeFeedItemPropsSchema } from './models/WebrcadeFeedItemPropsSchema'; diff --git a/frontend/src/__generated__/models/Body_add_collection_api_collections_post.ts b/frontend/src/__generated__/models/Body_add_collection_api_collections__post.ts similarity index 74% rename from frontend/src/__generated__/models/Body_add_collection_api_collections_post.ts rename to frontend/src/__generated__/models/Body_add_collection_api_collections__post.ts index 4f606a8ae..23a4fe182 100644 --- a/frontend/src/__generated__/models/Body_add_collection_api_collections_post.ts +++ b/frontend/src/__generated__/models/Body_add_collection_api_collections__post.ts @@ -2,7 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Body_add_collection_api_collections_post = { +export type Body_add_collection_api_collections__post = { artwork?: (Blob | null); }; diff --git a/frontend/src/__generated__/models/Body_add_firmware_api_firmware_post.ts b/frontend/src/__generated__/models/Body_add_firmware_api_firmware__post.ts similarity index 75% rename from frontend/src/__generated__/models/Body_add_firmware_api_firmware_post.ts rename to frontend/src/__generated__/models/Body_add_firmware_api_firmware__post.ts index 3982a508c..deacbe467 100644 --- a/frontend/src/__generated__/models/Body_add_firmware_api_firmware_post.ts +++ b/frontend/src/__generated__/models/Body_add_firmware_api_firmware__post.ts @@ -2,7 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Body_add_firmware_api_firmware_post = { +export type Body_add_firmware_api_firmware__post = { files: Array; }; diff --git a/frontend/src/__generated__/models/Body_add_saves_api_saves_post.ts b/frontend/src/__generated__/models/Body_add_saves_api_saves__post.ts similarity index 77% rename from frontend/src/__generated__/models/Body_add_saves_api_saves_post.ts rename to frontend/src/__generated__/models/Body_add_saves_api_saves__post.ts index 8a2d0b0e2..0a8461047 100644 --- a/frontend/src/__generated__/models/Body_add_saves_api_saves_post.ts +++ b/frontend/src/__generated__/models/Body_add_saves_api_saves__post.ts @@ -2,7 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Body_add_saves_api_saves_post = { +export type Body_add_saves_api_saves__post = { saves: Array; }; diff --git a/frontend/src/__generated__/models/Body_add_screenshots_api_screenshots_post.ts b/frontend/src/__generated__/models/Body_add_screenshots_api_screenshots__post.ts similarity index 73% rename from frontend/src/__generated__/models/Body_add_screenshots_api_screenshots_post.ts rename to frontend/src/__generated__/models/Body_add_screenshots_api_screenshots__post.ts index 73ef65873..1294df7ec 100644 --- a/frontend/src/__generated__/models/Body_add_screenshots_api_screenshots_post.ts +++ b/frontend/src/__generated__/models/Body_add_screenshots_api_screenshots__post.ts @@ -2,7 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Body_add_screenshots_api_screenshots_post = { +export type Body_add_screenshots_api_screenshots__post = { screenshots: Array; }; diff --git a/frontend/src/__generated__/models/Body_add_states_api_states_post.ts b/frontend/src/__generated__/models/Body_add_states_api_states__post.ts similarity index 76% rename from frontend/src/__generated__/models/Body_add_states_api_states_post.ts rename to frontend/src/__generated__/models/Body_add_states_api_states__post.ts index 35e27869d..a8f399b2b 100644 --- a/frontend/src/__generated__/models/Body_add_states_api_states_post.ts +++ b/frontend/src/__generated__/models/Body_add_states_api_states__post.ts @@ -2,7 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Body_add_states_api_states_post = { +export type Body_add_states_api_states__post = { states: Array; }; diff --git a/frontend/src/__generated__/models/CollectionSchema.ts b/frontend/src/__generated__/models/CollectionSchema.ts index 63ced7993..3ef2c2741 100644 --- a/frontend/src/__generated__/models/CollectionSchema.ts +++ b/frontend/src/__generated__/models/CollectionSchema.ts @@ -6,15 +6,17 @@ export type CollectionSchema = { id: number; name: string; description: string; - path_cover_l: (string | null); - path_cover_s: (string | null); - has_cover: boolean; + path_cover_small: (string | null); + path_cover_large: (string | null); + path_covers_small: Array; + path_covers_large: Array; url_cover: string; - roms: Array; + rom_ids: Array; rom_count: number; user_id: number; user__username: string; is_public: boolean; + is_favorite: boolean; created_at: string; updated_at: string; }; diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index 76ea377e4..561be5a4d 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -39,20 +39,21 @@ export type DetailedRomSchema = { alternative_names: Array; genres: Array; franchises: Array; - collections: Array; + meta_collections: Array; companies: Array; game_modes: Array; age_ratings: Array; igdb_metadata: (RomIGDBMetadata | null); moby_metadata: (RomMobyMetadata | null); ss_metadata: (RomMobyMetadata | null); - path_cover_s: (string | null); - path_cover_l: (string | null); has_cover: boolean; - url_cover: (string | null); has_manual: boolean; path_manual: (string | null); url_manual: (string | null); + path_cover_small: (string | null); + path_cover_large: (string | null); + url_cover: (string | null); + is_unidentified: boolean; revision: (string | null); regions: Array; languages: Array; diff --git a/frontend/src/__generated__/models/RomSchema.ts b/frontend/src/__generated__/models/RomSchema.ts index 8bd289cc9..89b7755f4 100644 --- a/frontend/src/__generated__/models/RomSchema.ts +++ b/frontend/src/__generated__/models/RomSchema.ts @@ -32,20 +32,21 @@ export type RomSchema = { alternative_names: Array; genres: Array; franchises: Array; - collections: Array; + meta_collections: Array; companies: Array; game_modes: Array; age_ratings: Array; igdb_metadata: (RomIGDBMetadata | null); moby_metadata: (RomMobyMetadata | null); ss_metadata: (RomMobyMetadata | null); - path_cover_s: (string | null); - path_cover_l: (string | null); has_cover: boolean; - url_cover: (string | null); has_manual: boolean; path_manual: (string | null); url_manual: (string | null); + path_cover_small: (string | null); + path_cover_large: (string | null); + url_cover: (string | null); + is_unidentified: boolean; revision: (string | null); regions: Array; languages: Array; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index 25ddaf08e..9d5d1c7f7 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -34,20 +34,21 @@ export type SimpleRomSchema = { alternative_names: Array; genres: Array; franchises: Array; - collections: Array; + meta_collections: Array; companies: Array; game_modes: Array; age_ratings: Array; igdb_metadata: (RomIGDBMetadata | null); moby_metadata: (RomMobyMetadata | null); ss_metadata: (RomMobyMetadata | null); - path_cover_s: (string | null); - path_cover_l: (string | null); has_cover: boolean; - url_cover: (string | null); has_manual: boolean; path_manual: (string | null); url_manual: (string | null); + path_cover_small: (string | null); + path_cover_large: (string | null); + url_cover: (string | null); + is_unidentified: boolean; revision: (string | null); regions: Array; languages: Array; diff --git a/frontend/src/__generated__/models/VirtualCollectionSchema.ts b/frontend/src/__generated__/models/VirtualCollectionSchema.ts new file mode 100644 index 000000000..ed142be4a --- /dev/null +++ b/frontend/src/__generated__/models/VirtualCollectionSchema.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type VirtualCollectionSchema = { + id: string; + name: string; + type: string; + description: string; + rom_ids: Array; + rom_count: number; + path_covers_small: Array; + path_covers_large: Array; +}; + diff --git a/frontend/src/components/Details/BackgroundHeader.vue b/frontend/src/components/Details/BackgroundHeader.vue index 7842b613e..138fe0525 100644 --- a/frontend/src/components/Details/BackgroundHeader.vue +++ b/frontend/src/components/Details/BackgroundHeader.vue @@ -29,11 +29,7 @@ const unmatchedCoverImage = computed(() => > diff --git a/frontend/src/components/Details/Info/GameInfo.vue b/frontend/src/components/Details/Info/GameInfo.vue index 6c3d0fb76..796d64303 100644 --- a/frontend/src/components/Details/Info/GameInfo.vue +++ b/frontend/src/components/Details/Info/GameInfo.vue @@ -20,7 +20,7 @@ const router = useRouter(); const filters = [ { value: "genres", name: t("rom.genres") }, { value: "franchises", name: t("rom.franchises") }, - { value: "collections", name: t("rom.collections") }, + { value: "meta_collections", name: t("rom.collections") }, { value: "companies", name: t("rom.companies") }, ] as const; diff --git a/frontend/src/components/Gallery/AppBar/Search/PlatformSelector.vue b/frontend/src/components/Gallery/AppBar/Search/PlatformSelector.vue index 639a0c635..7a03b309b 100644 --- a/frontend/src/components/Gallery/AppBar/Search/PlatformSelector.vue +++ b/frontend/src/components/Gallery/AppBar/Search/PlatformSelector.vue @@ -48,7 +48,7 @@ function setFilters() { galleryFilterStore.setFilterCollections([ ...new Set( romsStore.filteredRoms - .flatMap((rom) => rom.collections.map((collection) => collection)) + .flatMap((rom) => rom.meta_collections.map((collection) => collection)) .sort(), ), ]); diff --git a/frontend/src/components/Gallery/AppBar/Search/SearchTextField.vue b/frontend/src/components/Gallery/AppBar/Search/SearchTextField.vue index e20d71b7a..bd582e748 100644 --- a/frontend/src/components/Gallery/AppBar/Search/SearchTextField.vue +++ b/frontend/src/components/Gallery/AppBar/Search/SearchTextField.vue @@ -50,7 +50,7 @@ function setFilters() { galleryFilterStore.setFilterCollections([ ...new Set( romsStore.filteredRoms - .flatMap((rom) => rom.collections.map((collection) => collection)) + .flatMap((rom) => rom.meta_collections.map((collection) => collection)) .sort(), ), ]); diff --git a/frontend/src/components/Gallery/FabOverlay.vue b/frontend/src/components/Gallery/FabOverlay.vue index 8107c2f09..73a6e156c 100644 --- a/frontend/src/components/Gallery/FabOverlay.vue +++ b/frontend/src/components/Gallery/FabOverlay.vue @@ -68,7 +68,7 @@ function resetSelection() { async function addToFavourites() { if (!favCollection.value) return; - favCollection.value.roms = favCollection.value.roms.concat( + favCollection.value.rom_ids = favCollection.value.rom_ids.concat( selectedRoms.value.map((r) => r.id), ); await collectionApi @@ -97,7 +97,7 @@ async function addToFavourites() { async function removeFromFavourites() { if (!favCollection.value) return; - favCollection.value.roms = favCollection.value.roms.filter( + favCollection.value.rom_ids = favCollection.value.rom_ids.filter( (value) => !selectedRoms.value.map((r) => r.id).includes(value), ); if (romsStore.currentCollection?.name.toLowerCase() == "favourites") { diff --git a/frontend/src/components/Home/Collections.vue b/frontend/src/components/Home/Collections.vue index 8866626ce..3ab082f1a 100644 --- a/frontend/src/components/Home/Collections.vue +++ b/frontend/src/components/Home/Collections.vue @@ -36,7 +36,7 @@ const gridCollections = isNull(localStorage.getItem("settings.gridCollections")) +import VirtualCollectionCard from "@/components/common/Collection/Virtual/Card.vue"; +import RSection from "@/components/common/RSection.vue"; +import storeCollections from "@/stores/collections"; +import { views } from "@/utils"; +import { isNull } from "lodash"; +import { useI18n } from "vue-i18n"; + +// Props +const { t } = useI18n(); +const collections = storeCollections(); +const gridCollections = isNull(localStorage.getItem("settings.gridCollections")) + ? true + : localStorage.getItem("settings.gridCollections") === "true"; + + diff --git a/frontend/src/components/Settings/UserInterface/Interface.vue b/frontend/src/components/Settings/UserInterface/Interface.vue index 4fc004f60..07ce9e627 100644 --- a/frontend/src/components/Settings/UserInterface/Interface.vue +++ b/frontend/src/components/Settings/UserInterface/Interface.vue @@ -1,12 +1,16 @@ + diff --git a/frontend/src/components/common/Collection/Dialog/AddRoms.vue b/frontend/src/components/common/Collection/Dialog/AddRoms.vue index de19a7705..db73276ce 100644 --- a/frontend/src/components/common/Collection/Dialog/AddRoms.vue +++ b/frontend/src/components/common/Collection/Dialog/AddRoms.vue @@ -37,7 +37,7 @@ const HEADERS = [ async function addRomsToCollection() { if (!selectedCollection.value) return; - selectedCollection.value.roms.push(...roms.value.map((r) => r.id)); + selectedCollection.value.rom_ids.push(...roms.value.map((r) => r.id)); await collectionApi .updateCollection({ collection: selectedCollection.value }) .then(({ data }) => { diff --git a/frontend/src/components/common/Collection/Dialog/RemoveRoms.vue b/frontend/src/components/common/Collection/Dialog/RemoveRoms.vue index 9c4e325c6..8a82fce21 100644 --- a/frontend/src/components/common/Collection/Dialog/RemoveRoms.vue +++ b/frontend/src/components/common/Collection/Dialog/RemoveRoms.vue @@ -7,11 +7,13 @@ import type { UpdatedCollection } from "@/services/api/collection"; import collectionApi from "@/services/api/collection"; import storeCollections from "@/stores/collections"; import storeRoms, { type SimpleRom } from "@/stores/roms"; +import { ROUTES } from "@/plugins/router"; import type { Events } from "@/types/emitter"; import type { Emitter } from "mitt"; import { inject, ref, watch } from "vue"; import { useDisplay } from "vuetify"; import { useI18n } from "vue-i18n"; +import { useRouter } from "vue-router"; // Props const { t } = useI18n(); @@ -21,9 +23,13 @@ const romsStore = storeRoms(); const collectionsStore = storeCollections(); const selectedCollection = ref(); const roms = ref([]); +const router = useRouter(); const emitter = inject>("emitter"); emitter?.on("showRemoveFromCollectionDialog", (romsToRemove) => { + if (!romsStore.currentCollection) return; + roms.value = romsToRemove; + selectedCollection.value = romsStore.currentCollection; show.value = true; }); const HEADERS = [ @@ -37,7 +43,7 @@ const HEADERS = [ async function removeRomsFromCollection() { if (!selectedCollection.value) return; - selectedCollection.value.roms = selectedCollection.value.roms.filter( + selectedCollection.value.rom_ids = selectedCollection.value.rom_ids.filter( (id) => !roms.value.map((r) => r.id).includes(id), ); await collectionApi @@ -63,6 +69,10 @@ async function removeRomsFromCollection() { .finally(() => { emitter?.emit("showLoadingDialog", { loading: false, scrim: false }); romsStore.resetSelection(); + if (selectedCollection.value?.rom_ids.length == 0) { + router.push({ name: "home" }); + } + closeDialog(); }); closeDialog(); } @@ -84,9 +94,9 @@ function closeDialog() { >