Merge branch 'master' into feature/screenscraper-integration

This commit is contained in:
zurdi
2025-02-12 12:41:08 +00:00
88 changed files with 1732 additions and 318 deletions

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ router = APIRouter(
)
@router.get("/")
@router.get("")
def get_config() -> ConfigResponse:
"""Get config endpoint

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ router = APIRouter(
)
@router.get("/")
@router.get("")
def stats() -> StatsReturn:
"""Endpoint to return the current RomM stats

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Blob>;
};

View File

@@ -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<Blob>;
};

View File

@@ -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<Blob>;
};

View File

@@ -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<Blob>;
};

View File

@@ -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<string>;
path_covers_large: Array<string>;
url_cover: string;
roms: Array<number>;
rom_ids: Array<number>;
rom_count: number;
user_id: number;
user__username: string;
is_public: boolean;
is_favorite: boolean;
created_at: string;
updated_at: string;
};

View File

@@ -39,20 +39,21 @@ export type DetailedRomSchema = {
alternative_names: Array<string>;
genres: Array<string>;
franchises: Array<string>;
collections: Array<string>;
meta_collections: Array<string>;
companies: Array<string>;
game_modes: Array<string>;
age_ratings: Array<string>;
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<string>;
languages: Array<string>;

View File

@@ -32,20 +32,21 @@ export type RomSchema = {
alternative_names: Array<string>;
genres: Array<string>;
franchises: Array<string>;
collections: Array<string>;
meta_collections: Array<string>;
companies: Array<string>;
game_modes: Array<string>;
age_ratings: Array<string>;
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<string>;
languages: Array<string>;

View File

@@ -34,20 +34,21 @@ export type SimpleRomSchema = {
alternative_names: Array<string>;
genres: Array<string>;
franchises: Array<string>;
collections: Array<string>;
meta_collections: Array<string>;
companies: Array<string>;
game_modes: Array<string>;
age_ratings: Array<string>;
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<string>;
languages: Array<string>;

View File

@@ -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<number>;
rom_count: number;
path_covers_small: Array<string>;
path_covers_large: Array<string>;
};

View File

@@ -29,11 +29,7 @@ const unmatchedCoverImage = computed(() =>
>
<v-img
id="background-image"
:src="
!currentRom.igdb_id && !currentRom.moby_id && !currentRom.has_cover
? unmatchedCoverImage
: `/assets/romm/resources/${currentRom.path_cover_l}?ts=${currentRom.updated_at}`
"
:src="currentRom?.path_cover_large || unmatchedCoverImage"
lazy
cover
>

View File

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

View File

@@ -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(),
),
]);

View File

@@ -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(),
),
]);

View File

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

View File

@@ -36,7 +36,7 @@ const gridCollections = isNull(localStorage.getItem("settings.gridCollections"))
<collection-card
show-rom-count
transform-scale
:key="collection.updated_at"
:key="collection.id"
:collection="collection"
with-link
title-on-hover

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
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";
</script>
<template>
<r-section
icon="mdi-bookmark-box-multiple"
:title="t('common.virtual-collections')"
>
<template #content>
<v-row
:class="{ 'flex-nowrap overflow-x-auto': !gridCollections }"
no-gutters
>
<v-col
v-for="collection in collections.virtualCollections"
:key="collection.name"
class="pa-1"
:cols="views[0]['size-cols']"
:sm="views[0]['size-sm']"
:md="views[0]['size-md']"
:lg="views[0]['size-lg']"
:xl="views[0]['size-xl']"
>
<virtual-collection-card
show-rom-count
show-title
transform-scale
:key="collection.id"
:collection="collection"
with-link
/>
</v-col>
</v-row>
</template>
</r-section>
</template>

View File

@@ -1,12 +1,16 @@
<script setup lang="ts">
import InterfaceOption from "@/components/Settings/UserInterface/InterfaceOption.vue";
import RSection from "@/components/common/RSection.vue";
import collectionApi from "@/services/api/collection";
import storeCollections from "@/stores/collections";
import { computed, ref } from "vue";
import { isNull } from "lodash";
import { useI18n } from "vue-i18n";
// Props
const { t } = useI18n();
const collectionsStore = storeCollections();
// Initializing refs from localStorage
const storedShowRecentRoms = localStorage.getItem("settings.showRecentRoms");
const showRecentRomsRef = ref(
@@ -48,6 +52,22 @@ const storedGridCollections = localStorage.getItem("settings.gridCollections");
const gridCollectionsRef = ref(
isNull(storedGridCollections) ? true : storedGridCollections === "true",
);
const storedShowVirtualCollections = localStorage.getItem(
"settings.showVirtualCollections",
);
const showVirtualCollectionsRef = ref(
isNull(storedShowVirtualCollections)
? true
: storedShowVirtualCollections === "true",
);
const storedVirtualCollectionType = localStorage.getItem(
"settings.virtualCollectionType",
);
const virtualCollectionTypeRef = ref(
isNull(storedVirtualCollectionType)
? "collection"
: storedVirtualCollectionType,
);
const storedGroupRoms = localStorage.getItem("settings.groupRoms");
const groupRomsRef = ref(
@@ -214,6 +234,20 @@ const toggleGridCollections = (value: boolean) => {
gridCollectionsRef.value = value;
localStorage.setItem("settings.gridCollections", value.toString());
};
const toggleShowVirtualCollections = (value: boolean) => {
showVirtualCollectionsRef.value = value;
localStorage.setItem("settings.showVirtualCollections", value.toString());
};
const setVirtualCollectionType = async (value: string) => {
virtualCollectionTypeRef.value = value;
localStorage.setItem("settings.virtualCollectionType", value);
await collectionApi
.getVirtualCollections({ type: value })
.then(({ data: virtualCollections }) => {
collectionsStore.setVirtual(virtualCollections);
});
};
const toggleGroupRoms = (value: boolean) => {
groupRomsRef.value = value;
@@ -295,6 +329,50 @@ const toggleStatus = (value: boolean) => {
/>
</v-col>
</v-row>
<v-chip
label
variant="text"
prepend-icon="mdi-view-grid"
class="ml-2 mt-4"
>{{ t("common.virtual-collections") }}</v-chip
>
<v-divider class="border-opacity-25 mx-2 mb-2" />
<v-row class="py-1" no-gutters>
<v-col cols="12" md="6">
<interface-option
class="mx-2"
:title="t('settings.show-virtual-collections')"
:description="t('settings.show-virtual-collections-desc')"
:icon="
showVirtualCollectionsRef
? 'mdi-bookmark-box-multiple'
: 'mdi-bookmark-box-multiple'
"
v-model="showVirtualCollectionsRef"
@update:model-value="toggleShowVirtualCollections"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="virtualCollectionTypeRef"
:items="[
{ title: 'IGDB Collection', value: 'collection' },
{ title: 'Franchise', value: 'franchise' },
{ title: 'Genre', value: 'genre' },
{ title: 'Play Mode', value: 'mode' },
{ title: 'Developer', value: 'company' },
{ title: 'All (slow)', value: 'all' },
]"
:label="t('settings.virtual-collection-type')"
class="mx-2"
dense
outlined
hide-details
:disabled="!showVirtualCollectionsRef"
@update:model-value="setVirtualCollectionType"
/>
</v-col>
</v-row>
</template>
</r-section>
</template>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { Collection } from "@/stores/collections";
import storeGalleryView from "@/stores/galleryView";
import { computed, ref, watchEffect } from "vue";
import { getCollectionCoverImage, getFavoriteCoverImage } from "@/utils/covers";
import { computed } from "vue";
// Props
const props = withDefaults(
@@ -24,12 +24,65 @@ const props = withDefaults(
);
const galleryViewStore = storeGalleryView();
const memoizedCovers = ref({
large: ["", ""],
small: ["", ""],
});
const collectionCoverImage = computed(() =>
getCollectionCoverImage(props.collection.name),
);
const favoriteCoverImage = computed(() =>
getFavoriteCoverImage(props.collection.name),
props.collection.name?.toLowerCase() == "favourites"
? getFavoriteCoverImage(props.collection.name)
: getCollectionCoverImage(props.collection.name),
);
watchEffect(() => {
if (props.src) {
memoizedCovers.value = {
large: [props.src, props.src],
small: [props.src, props.src],
};
return;
}
if (props.collection.path_cover_large && props.collection.path_cover_small) {
memoizedCovers.value = {
large: [
props.collection.path_cover_large,
props.collection.path_cover_large,
],
small: [
props.collection.path_cover_small,
props.collection.path_cover_small,
],
};
return;
}
const largeCoverUrls = props.collection.path_covers_large || [];
const smallCoverUrls = props.collection.path_covers_small || [];
if (largeCoverUrls.length < 2) {
memoizedCovers.value = {
large: [collectionCoverImage.value, collectionCoverImage.value],
small: [collectionCoverImage.value, collectionCoverImage.value],
};
return;
}
const shuffledLarge = [...largeCoverUrls].sort(() => Math.random() - 0.5);
const shuffledSmall = [...smallCoverUrls].sort(() => Math.random() - 0.5);
memoizedCovers.value = {
large: [shuffledLarge[0], shuffledLarge[1]],
small: [shuffledSmall[0], shuffledSmall[1]],
};
});
const firstCover = computed(() => memoizedCovers.value.large[0]);
const secondCover = computed(() => memoizedCovers.value.large[1]);
const firstSmallCover = computed(() => memoizedCovers.value.small[0]);
const secondSmallCover = computed(() => memoizedCovers.value.small[1]);
</script>
<template>
@@ -49,61 +102,35 @@ const favoriteCoverImage = computed(() =>
}"
:elevation="isHovering && transformScale ? 20 : 3"
>
<v-img
cover
:src="
src
? src
: collection.has_cover
? `/assets/romm/resources/${collection.path_cover_l}?ts=${collection.updated_at}`
: collection.name && collection.name.toLowerCase() == 'favourites'
? favoriteCoverImage
: collectionCoverImage
"
:lazy-src="
src
? src
: collection.has_cover
? `/assets/romm/resources/${collection.path_cover_s}?ts=${collection.updated_at}`
: collection.name && collection.name.toLowerCase() == 'favourites'
? favoriteCoverImage
: collectionCoverImage
"
:aspect-ratio="galleryViewStore.defaultAspectRatioCollection"
>
<template v-if="titleOnHover">
<v-expand-transition>
<div
v-if="isHovering || !collection.has_cover"
class="translucent-dark text-caption text-center text-white"
>
<v-list-item>{{ collection.name }}</v-list-item>
</div>
</v-expand-transition>
</template>
<div class="position-absolute append-inner">
<slot name="append-inner"></slot>
<v-row class="pa-1 justify-center bg-primary">
<div
:title="collection.name?.toString()"
class="py-4 px-6 text-truncate text-caption"
>
<span>{{ collection.name }}</span>
</div>
<template #error>
</v-row>
<div
class="image-container"
:style="{ aspectRatio: galleryViewStore.defaultAspectRatioCollection }"
>
<div class="split-image first-image">
<v-img
:src="collectionCoverImage"
cover
:src="firstCover"
:lazy-src="firstSmallCover"
:aspect-ratio="galleryViewStore.defaultAspectRatioCollection"
/>
</template>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
:width="2"
:size="40"
color="primary"
indeterminate
/>
</div>
</template>
</v-img>
</div>
<div class="split-image second-image">
<v-img
cover
:src="secondCover"
:lazy-src="secondSmallCover"
:aspect-ratio="galleryViewStore.defaultAspectRatioCollection"
/>
</div>
</div>
<v-chip
v-if="showRomCount"
class="bg-background position-absolute"
@@ -116,9 +143,28 @@ const favoriteCoverImage = computed(() =>
</v-card>
</v-hover>
</template>
<style scoped>
.append-inner {
bottom: 0rem;
right: 0rem;
.image-container {
position: relative;
width: 100%;
overflow: hidden;
}
.split-image {
position: absolute;
top: 0;
width: 100%;
height: 100%;
}
.first-image {
clip-path: polygon(0 0, 100% 0, 0% 100%, 0 100%);
z-index: 1;
}
.second-image {
clip-path: polygon(0% 100%, 100% 0, 100% 100%);
z-index: 0;
}
</style>

View File

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

View File

@@ -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<UpdatedCollection>();
const roms = ref<SimpleRom[]>([]);
const router = useRouter();
const emitter = inject<Emitter<Events>>("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() {
>
<template #header>
<v-row no-gutters class="justify-center">
<span>{{ t("rom.remove-from-collection-part1") }}</span>
<span>{{ t("rom.removing-from-collection-part1") }}</span>
<span class="text-primary mx-1">{{ roms.length }}</span>
<span>{{ t("rom.remove-from-collection-part2") }}</span>
<span>{{ t("rom.removing-from-collection-part2") }}</span>
</v-row>
</template>
<template #prepend>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { Collection } from "@/stores/collections";
import { getCollectionCoverImage, getFavoriteCoverImage } from "@/utils/covers";
import { computed } from "vue";
import { computed, ref, watchEffect } from "vue";
import { useTheme } from "vuetify";
const props = withDefaults(
defineProps<{ collection: Collection; size?: number }>(),
@@ -9,29 +10,104 @@ const props = withDefaults(
size: 45,
},
);
const theme = useTheme();
const memoizedCovers = ref({
large: ["", ""],
small: ["", ""],
});
const collectionCoverImage = computed(() =>
getCollectionCoverImage(props.collection.name),
);
const favoriteCoverImage = computed(() =>
getFavoriteCoverImage(props.collection.name),
props.collection.is_favorite
? getFavoriteCoverImage(props.collection.name)
: getCollectionCoverImage(props.collection.name),
);
watchEffect(() => {
if (props.collection.path_cover_large && props.collection.path_cover_small) {
memoizedCovers.value = {
large: [
props.collection.path_cover_large,
props.collection.path_cover_large,
],
small: [
props.collection.path_cover_small,
props.collection.path_cover_small,
],
};
return;
}
const largeCoverUrls = props.collection.path_covers_large || [];
const smallCoverUrls = props.collection.path_covers_small || [];
if (largeCoverUrls.length < 2) {
memoizedCovers.value = {
large: [collectionCoverImage.value, collectionCoverImage.value],
small: [collectionCoverImage.value, collectionCoverImage.value],
};
return;
}
const shuffledLarge = [...largeCoverUrls].sort(() => Math.random() - 0.5);
const shuffledSmall = [...smallCoverUrls].sort(() => Math.random() - 0.5);
memoizedCovers.value = {
large: [shuffledLarge[0], shuffledLarge[1]],
small: [shuffledSmall[0], shuffledSmall[1]],
};
});
const firstCover = computed(() => memoizedCovers.value.large[0]);
const secondCover = computed(() => memoizedCovers.value.large[1]);
const firstSmallCover = computed(() => memoizedCovers.value.small[0]);
const secondSmallCover = computed(() => memoizedCovers.value.small[1]);
</script>
<template>
<v-avatar :size="size" rounded="0">
<v-img
:src="
collection.has_cover
? `/assets/romm/resources/${collection.path_cover_l}?ts=${collection.updated_at}`
: collection.name?.toLowerCase() == 'favourites'
? favoriteCoverImage
: collectionCoverImage
"
>
<template #error>
<v-img :src="collectionCoverImage" />
</template>
</v-img>
<v-avatar :rounded="0" :size="size">
<div class="image-container" :style="{ aspectRatio: 1 / 1 }">
<div class="split-image first-image">
<v-img
cover
:src="firstCover"
:lazy-src="firstSmallCover"
:aspect-ratio="1 / 1"
/>
</div>
<div class="split-image second-image">
<v-img
cover
:src="secondCover"
:lazy-src="secondSmallCover"
:aspect-ratio="1 / 1"
/>
</div>
</div>
</v-avatar>
</template>
<style scoped>
.image-container {
position: relative;
width: 100%;
overflow: hidden;
}
.split-image {
position: absolute;
top: 0;
width: 100%;
height: 100%;
}
.first-image {
clip-path: polygon(0 0, 100% 0, 0% 100%, 0 100%);
z-index: 1;
}
.second-image {
clip-path: polygon(0% 100%, 100% 0, 100% 100%);
z-index: 0;
}
</style>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import type { VirtualCollection } from "@/stores/collections";
import storeGalleryView from "@/stores/galleryView";
import { useTheme } from "vuetify";
import { computed, ref, watchEffect } from "vue";
// Props
const props = withDefaults(
defineProps<{
collection: VirtualCollection;
transformScale?: boolean;
showTitle?: boolean;
showRomCount?: boolean;
withLink?: boolean;
}>(),
{
transformScale: false,
showTitle: false,
showRomCount: false,
withLink: false,
},
);
const theme = useTheme();
const galleryViewStore = storeGalleryView();
const memoizedCovers = ref({
large: ["", ""],
small: ["", ""],
});
watchEffect(() => {
const largeCoverUrls = props.collection.path_covers_large || [];
const smallCoverUrls = props.collection.path_covers_small || [];
if (largeCoverUrls.length < 2) {
memoizedCovers.value = {
large: [
`/assets/default/cover/big_${theme.global.name.value}_collection.png`,
`/assets/default/cover/big_${theme.global.name.value}_collection.png`,
],
small: [
`/assets/default/cover/small_${theme.global.name.value}_collection.png`,
`/assets/default/cover/small_${theme.global.name.value}_collection.png`,
],
};
return;
}
const shuffledLarge = [...largeCoverUrls].sort(() => Math.random() - 0.5);
const shuffledSmall = [...smallCoverUrls].sort(() => Math.random() - 0.5);
memoizedCovers.value = {
large: [shuffledLarge[0], shuffledLarge[1]],
small: [shuffledSmall[0], shuffledSmall[1]],
};
});
const firstCover = computed(() => memoizedCovers.value.large[0]);
const secondCover = computed(() => memoizedCovers.value.large[1]);
const firstSmallCover = computed(() => memoizedCovers.value.small[0]);
const secondSmallCover = computed(() => memoizedCovers.value.small[1]);
</script>
<template>
<v-hover v-slot="{ isHovering, props: hoverProps }">
<v-card
v-bind="{
...hoverProps,
...(withLink && collection
? {
to: { name: 'collection', params: { collection: collection.id } },
}
: {}),
}"
:class="{
'on-hover': isHovering,
'transform-scale': transformScale,
}"
:elevation="isHovering && transformScale ? 20 : 3"
>
<v-row v-if="showTitle" class="pa-1 justify-center bg-primary">
<div
:title="collection.name?.toString()"
class="py-4 px-6 text-truncate text-caption"
>
<span>{{ collection.name }}</span>
</div>
</v-row>
<div
class="image-container"
:style="{ aspectRatio: galleryViewStore.defaultAspectRatioCollection }"
>
<div class="split-image first-image">
<v-img
cover
:src="firstCover"
:lazy-src="firstSmallCover"
:aspect-ratio="galleryViewStore.defaultAspectRatioCollection"
/>
</div>
<div class="split-image second-image">
<v-img
cover
:src="secondCover"
:lazy-src="secondSmallCover"
:aspect-ratio="galleryViewStore.defaultAspectRatioCollection"
/>
</div>
</div>
<v-chip
v-if="showRomCount"
class="bg-chip position-absolute"
size="x-small"
style="bottom: 0.5rem; right: 0.5rem"
label
>
{{ collection.rom_count }}
</v-chip>
</v-card>
</v-hover>
</template>
<style scoped>
.image-container {
position: relative;
width: 100%;
overflow: hidden;
}
.split-image {
position: absolute;
top: 0;
width: 100%;
height: 100%;
}
.first-image {
clip-path: polygon(0 0, 100% 0, 0% 100%, 0 100%);
z-index: 1;
}
.second-image {
clip-path: polygon(0% 100%, 100% 0, 100% 100%);
z-index: 0;
}
</style>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import type { VirtualCollection } from "@/stores/collections";
import RAvatar from "@/components/common/Collection/Virtual/RAvatar.vue";
// Props
withDefaults(
defineProps<{
collection: VirtualCollection;
withTitle?: boolean;
withDescription?: boolean;
withRomCount?: boolean;
withLink?: boolean;
}>(),
{
withTitle: true,
withDescription: true,
withRomCount: true,
withLink: false,
},
);
</script>
<template>
<v-list-item
:key="collection.id"
v-bind="{
...(withLink && collection
? {
to: { name: 'collection', params: { collection: collection.id } },
}
: {}),
}"
:value="collection.name"
class="py-1 pl-1"
>
<template #prepend>
<r-avatar :size="48" :collection="collection" />
</template>
<v-row v-if="withTitle" no-gutters
><v-col
><span class="text-body-1">{{ collection.name }}</span></v-col
></v-row
>
<v-row v-if="withDescription" no-gutters>
<v-col>
<span class="text-caption text-grey">{{ collection.description }}</span>
</v-col>
</v-row>
<template v-if="withRomCount" #append>
<v-chip class="ml-2" size="x-small" label>
{{ collection.rom_count }}
</v-chip>
</template>
</v-list-item>
</template>
<style scoped>
.not-found-icon {
position: absolute;
bottom: 0;
right: 0;
}
</style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { VirtualCollection } from "@/stores/collections";
import { computed, ref, watchEffect } from "vue";
import { useTheme } from "vuetify";
const props = withDefaults(
defineProps<{ collection: VirtualCollection; size?: number }>(),
{
size: 45,
},
);
const theme = useTheme();
const memoizedCovers = ref({
large: ["", ""],
small: ["", ""],
});
// Watch for collection changes and update the memoized selection
watchEffect(() => {
const largeCoverUrls = props.collection.path_covers_large || [];
const smallCoverUrls = props.collection.path_covers_small || [];
if (largeCoverUrls.length < 2) {
memoizedCovers.value = {
large: [
`/assets/default/cover/big_${theme.global.name.value}_collection.png`,
`/assets/default/cover/big_${theme.global.name.value}_collection.png`,
],
small: [
`/assets/default/cover/small_${theme.global.name.value}_collection.png`,
`/assets/default/cover/small_${theme.global.name.value}_collection.png`,
],
};
return;
}
const shuffledLarge = [...largeCoverUrls].sort(() => Math.random() - 0.5);
const shuffledSmall = [...smallCoverUrls].sort(() => Math.random() - 0.5);
memoizedCovers.value = {
large: [shuffledLarge[0], shuffledLarge[1]],
small: [shuffledSmall[0], shuffledSmall[1]],
};
});
// Computed properties now use the memoized values
const firstCover = computed(() => memoizedCovers.value.large[0]);
const secondCover = computed(() => memoizedCovers.value.large[1]);
const firstSmallCover = computed(() => memoizedCovers.value.small[0]);
const secondSmallCover = computed(() => memoizedCovers.value.small[1]);
</script>
<template>
<v-avatar :rounded="0" :size="size">
<div class="image-container" :style="{ aspectRatio: 1 / 1 }">
<div class="split-image first-image">
<v-img
cover
:src="firstCover"
:lazy-src="firstSmallCover"
:aspect-ratio="1 / 1"
/>
</div>
<div class="split-image second-image">
<v-img
cover
:src="secondCover"
:lazy-src="secondSmallCover"
:aspect-ratio="1 / 1"
/>
</div>
</div>
</v-avatar>
</template>
<style scoped>
.image-container {
position: relative;
width: 100%;
overflow: hidden;
}
.split-image {
position: absolute;
top: 0;
width: 100%;
height: 100%;
}
.first-image {
clip-path: polygon(0 0, 100% 0, 0% 100%, 0 100%);
z-index: 1;
}
.second-image {
clip-path: polygon(0% 100%, 100% 0, 100% 100%);
z-index: 0;
}
</style>

View File

@@ -54,10 +54,10 @@ async function switchFromFavourites() {
});
}
if (!collectionsStore.isFav(props.rom)) {
favCollection.value?.roms.push(props.rom.id);
favCollection.value?.rom_ids.push(props.rom.id);
} else {
if (favCollection.value) {
favCollection.value.roms = favCollection.value.roms.filter(
favCollection.value.rom_ids = favCollection.value.rom_ids.filter(
(id) => id !== props.rom.id,
);
if (romsStore.currentCollection?.name.toLowerCase() == "favourites") {

View File

@@ -74,11 +74,10 @@ const computedAspectRatio = computed(() => {
galleryViewStore.defaultAspectRatioCover;
return parseFloat(ratio.toString());
});
const unmatchedCoverImage = computed(() =>
getUnmatchedCoverImage(props.rom.name || props.rom.slug || ""),
);
const missingCoverImage = computed(() =>
getMissingCoverImage(props.rom.name || props.rom.slug || ""),
const fallbackCoverImage = computed(() =>
props.rom.igdb_id || props.rom.moby_id || props.rom.ss_id
? getMissingCoverImage(props.rom.name || props.rom.slug || "")
: getUnmatchedCoverImage(props.rom.name || props.rom.slug || ""),
);
</script>
@@ -125,31 +124,20 @@ const missingCoverImage = computed(() =>
:src="
src ||
(romsStore.isSimpleRom(rom)
? !rom.igdb_id && !rom.moby_id && !rom.ss_id && !rom.has_cover
? unmatchedCoverImage
: (rom.igdb_id || rom.moby_id || rom.ss_id) && !rom.has_cover
? missingCoverImage
: `/assets/romm/resources/${rom.path_cover_l}?ts=${rom.updated_at}`
: !rom.igdb_url_cover &&
!rom.moby_url_cover &&
!rom.ss_url_cover
? missingCoverImage
: rom.igdb_url_cover ||
rom.moby_url_cover ||
rom.ss_url_cover)
? rom.path_cover_large || fallbackCoverImage
: rom.igdb_url_cover ||
rom.moby_url_cover ||
rom.ss_url_cover ||
fallbackCoverImage)
"
:lazy-src="
romsStore.isSimpleRom(rom)
? !rom.igdb_id && !rom.moby_id && !rom.ss_id && !rom.has_cover
? unmatchedCoverImage
: (rom.igdb_id || rom.moby_id || rom.ss_id) && !rom.has_cover
? missingCoverImage
: `/assets/romm/resources/${rom.path_cover_s}?ts=${rom.updated_at}`
: !rom.igdb_url_cover &&
!rom.moby_url_cover &&
!rom.ss_url_cover
? missingCoverImage
: rom.igdb_url_cover || rom.moby_url_cover || rom.ss_url_cover
src ||
(romsStore.isSimpleRom(rom)
? rom.path_cover_small || fallbackCoverImage
: rom.igdb_url_cover ||
rom.moby_url_cover ||
rom.ss_url_cover ||
fallbackCoverImage)
"
:aspect-ratio="computedAspectRatio"
>
@@ -159,7 +147,7 @@ const missingCoverImage = computed(() =>
<div
v-if="
isHovering ||
(romsStore.isSimpleRom(rom) && !rom.has_cover) ||
(romsStore.isSimpleRom(rom) && rom.is_unidentified) ||
(!romsStore.isSimpleRom(rom) &&
!rom.igdb_url_cover &&
!rom.moby_url_cover &&
@@ -223,7 +211,7 @@ const missingCoverImage = computed(() =>
</div>
<template #error>
<v-img
:src="missingCoverImage"
:src="fallbackCoverImage"
cover
:aspect-ratio="computedAspectRatio"
></v-img>

View File

@@ -47,10 +47,10 @@ async function switchFromFavourites() {
});
}
if (!collectionsStore.isFav(props.rom)) {
favCollection.value?.roms.push(props.rom.id);
favCollection.value?.rom_ids.push(props.rom.id);
} else {
if (favCollection.value) {
favCollection.value.roms = favCollection.value.roms.filter(
favCollection.value.rom_ids = favCollection.value.rom_ids.filter(
(id) => id !== props.rom.id,
);
if (romsStore.currentCollection?.name.toLowerCase() == "favourites") {

View File

@@ -7,27 +7,18 @@ const props = withDefaults(defineProps<{ rom: SimpleRom; size?: number }>(), {
size: 45,
});
const unmatchedCoverImage = computed(() =>
getUnmatchedCoverImage(props.rom.name || props.rom.fs_name),
);
const missingCoverImage = computed(() =>
getMissingCoverImage(props.rom.name || props.rom.fs_name),
const fallbackCoverImage = computed(() =>
props.rom.igdb_id || props.rom.moby_id
? getMissingCoverImage(props.rom.name || props.rom.fs_name)
: getUnmatchedCoverImage(props.rom.name || props.rom.fs_name),
);
</script>
<template>
<v-avatar :width="size" rounded="0">
<v-img
:src="
!rom.igdb_id && !rom.moby_id && !rom.has_cover
? unmatchedCoverImage
: rom.has_cover
? `/assets/romm/resources/${rom.path_cover_s}?ts=${rom.updated_at}`
: missingCoverImage
"
>
<v-img :src="props.rom.path_cover_small || fallbackCoverImage">
<template #error>
<v-img :src="missingCoverImage" />
<v-img :src="fallbackCoverImage" />
</template>
</v-img>
</v-avatar>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import CollectionListItem from "@/components/common/Collection/ListItem.vue";
import VirtualCollectionListItem from "@/components/common/Collection/Virtual/ListItem.vue";
import storeCollections from "@/stores/collections";
import CreateCollectionDialog from "@/components/common/Collection/Dialog/CreateCollection.vue";
import storeNavigation from "@/stores/navigation";
@@ -9,16 +10,24 @@ import { storeToRefs } from "pinia";
import { inject } from "vue";
import { useDisplay } from "vuetify";
import { useI18n } from "vue-i18n";
import { isNull } from "lodash";
// Props
const { t } = useI18n();
const navigationStore = storeNavigation();
const { smAndDown } = useDisplay();
const collectionsStore = storeCollections();
const { filteredCollections, searchText } = storeToRefs(collectionsStore);
const { filteredCollections, filteredVirtualCollections, searchText } =
storeToRefs(collectionsStore);
const { activeCollectionsDrawer } = storeToRefs(navigationStore);
const emitter = inject<Emitter<Events>>("emitter");
const showVirtualCollections = isNull(
localStorage.getItem("settings.showVirtualCollections"),
)
? true
: localStorage.getItem("settings.showVirtualCollections") === "true";
async function addCollection() {
emitter?.emit("showCreateCollectionDialog", null);
}
@@ -65,6 +74,16 @@ function clear() {
:collection="collection"
with-link
/>
<template v-if="showVirtualCollections">
<v-list-subheader class="uppercase">{{
t("common.virtual-collections").toUpperCase()
}}</v-list-subheader>
<virtual-collection-list-item
v-for="collection in filteredVirtualCollections"
:collection="collection"
with-link
/>
</template>
</v-list>
<template #append>
<v-btn

View File

@@ -18,7 +18,8 @@ import storeNavigation from "@/stores/navigation";
import storePlatforms from "@/stores/platforms";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { inject, onBeforeMount } from "vue";
import { inject, onBeforeMount, ref } from "vue";
import { isNull } from "lodash";
// Props
const navigationStore = storeNavigation();
@@ -30,6 +31,21 @@ emitter?.on("refreshDrawer", async () => {
platformsStore.set(platformData);
});
const showVirtualCollections = isNull(
localStorage.getItem("settings.showVirtualCollections"),
)
? true
: localStorage.getItem("settings.showVirtualCollections") === "true";
const storedVirtualCollectionType = localStorage.getItem(
"settings.virtualCollectionType",
);
const virtualCollectionTypeRef = ref(
isNull(storedVirtualCollectionType)
? "collection"
: storedVirtualCollectionType,
);
// Functions
onBeforeMount(async () => {
await platformApi
@@ -40,6 +56,7 @@ onBeforeMount(async () => {
.catch((error) => {
console.error(error);
});
await collectionApi
.getCollections()
.then(({ data: collections }) => {
@@ -53,6 +70,15 @@ onBeforeMount(async () => {
.catch((error) => {
console.error(error);
});
if (showVirtualCollections) {
await collectionApi
.getVirtualCollections({ type: virtualCollectionTypeRef.value })
.then(({ data: virtualCollections }) => {
collectionsStore.setVirtual(virtualCollections);
});
}
navigationStore.resetDrawers();
});
</script>

View File

@@ -7,6 +7,8 @@
"games-n": "{n} Spiel | {n} Spiele",
"collection": "Sammlung",
"collections": "Sammlungen",
"virtual-collection": "Autogenerierte Sammlung",
"virtual-collections": "Autogenerierte Sammlungen",
"save": "Speichern",
"saves": "Speicherstände",
"saves-n": "{n} Speichern | {n} Speicherstände",

View File

@@ -19,11 +19,11 @@
"add-to-fav": "Zu Favoriten hinzufügen",
"remove-from-fav": "Aus Favoriten entfernen",
"add-to-collection": "Zu Sammlung hinzufügen",
"remove-from-collection": "Aus Sammlung entfernen",
"adding-to-collection-part1": "Füge",
"adding-to-collection-part2": "Roms zu Sammlung hinzu",
"remove-from-collection": "Aus Sammlung entfernen",
"remove-from-collection-part1": "Entferne",
"remove-from-collection-part2": "Roms aus Sammlung",
"removing-from-collection-part1": "Entferne",
"removing-from-collection-part2": "Roms aus Sammlung",
"delete-rom": "Löschen",
"copy-link": "Download-Link kopieren",
"cant-copy-link": "Link kann nicht in Zwischenablage kopiert werden. Bitte manuell kopieren.",

View File

@@ -26,6 +26,9 @@
"show-collections-desc": "Sammlungen werden auf der Startseite dargestellt",
"show-collections-as-grid": "Sammlungen als Raster darstellen",
"show-collections-as-grid-desc": "Sammlungen werden als Raster auf der Startseite gezeigt",
"show-virtual-collections": "Zeige automatisch generierte Sammlungen",
"show-virtual-collections-desc": "Wird auf der Startseite und in der Sammlungs-Seitenleiste angezeigt",
"virtual-collection-type": "Typ der automatisch generierten Sammlung (ROM-Gruppierungsmethode)",
"group-roms": "Roms gruppieren",
"group-roms-desc": "Gruppiere verschiedene Versionen des gleichen Roms in der Galerie",
"show-siblings": "Zeige Anzahl der Versionen",

View File

@@ -7,6 +7,8 @@
"games-n": "{n} Game | {n} Games",
"collection": "Collection",
"collections": "Collections",
"virtual-collection": "Autogenerated collection",
"virtual-collections": "Autogenerated collections",
"save": "Save",
"saves": "Saves",
"saves-n": "{n} Save | {n} Saves",

View File

@@ -19,11 +19,11 @@
"add-to-fav": "Add to favourites",
"remove-from-fav": "Remove from favourites",
"add-to-collection": "Add to collection",
"remove-from-collection": "Remove from collection",
"adding-to-collection-part1": "Adding",
"adding-to-collection-part2": "roms to collection",
"remove-from-collection": "Remove from collection",
"remove-from-collection-part1": "Removing",
"remove-from-collection-part2": "roms from collection",
"removing-from-collection-part1": "Removing",
"removing-from-collection-part2": "roms from collection",
"delete-rom": "Delete",
"copy-link": "Copy download link",
"cant-copy-link": "Can't copy link to clipboard, copy it manually",

View File

@@ -26,6 +26,9 @@
"show-collections-desc": "Show collections section at the home page",
"show-collections-as-grid": "Collections as grid",
"show-collections-as-grid-desc": "View collection cards as a grid at the home page",
"show-virtual-collections": "Show autogenerated collections",
"show-virtual-collections-desc": "Displayed in the homepage and collections sidebar.",
"virtual-collection-type": "Virtual collection type (ROM grouping method)",
"group-roms": "Group roms",
"group-roms-desc": "Group versions of the same rom together in the gallery",
"show-siblings": "Show siblings",

View File

@@ -7,6 +7,8 @@
"games-n": "{n} Game | {n} Games",
"collection": "Collection",
"collections": "Collections",
"virtual-collection": "Autogenerated collection",
"virtual-collections": "Autogenerated collections",
"save": "Save",
"saves": "Saves",
"saves-n": "{n} Save | {n} Saves",

View File

@@ -19,11 +19,11 @@
"add-to-fav": "Add to favourites",
"remove-from-fav": "Remove from favourites",
"add-to-collection": "Add to collection",
"remove-from-collection": "Remove from collection",
"adding-to-collection-part1": "Adding",
"adding-to-collection-part2": "roms to collection",
"remove-from-collection": "Remove from collection",
"remove-from-collection-part1": "Removing",
"remove-from-collection-part2": "roms from collection",
"removing-from-collection-part1": "Removing",
"removing-from-collection-part2": "roms from collection",
"delete-rom": "Delete",
"copy-link": "Copy download link",
"cant-copy-link": "Can't copy link to clipboard, copy it manually",

View File

@@ -26,6 +26,9 @@
"show-collections-desc": "Show collections section at the home page",
"show-collections-as-grid": "Collections as grid",
"show-collections-as-grid-desc": "View collection cards as a grid at the home page",
"show-virtual-collections": "Show autogenerated collections",
"show-virtual-collections-desc": "Displayed in the homepage and collections sidebar.",
"virtual-collection-type": "Virtual collection type (ROM grouping method)",
"group-roms": "Group roms",
"group-roms-desc": "Group versions of the same rom together in the gallery",
"show-siblings": "Show siblings",

View File

@@ -7,6 +7,8 @@
"games-n": "{n} Juego | {n} Juegos",
"collection": "Colección",
"collections": "Colecciones",
"virtual-collection": "Autogenerada colección",
"virtual-collections": "Autogeneradas colecciones",
"save": "Guardado",
"saves": "Guardados",
"saves-n": "{n} Guardado | {n} Guardados",

View File

@@ -19,11 +19,11 @@
"add-to-fav": "Añadir a favoritos",
"remove-from-fav": "Eliminar de favoritos",
"add-to-collection": "Añadir a la colección",
"remove-from-collection": "Eliminar de la colección",
"adding-to-collection-part1": "Añadiendo",
"adding-to-collection-part2": "roms a la colección",
"remove-from-collection": "Quitar de la collección",
"remove-from-collection-part1": "Quitando",
"remove-from-collection-part2": "roms de la colección",
"removing-from-collection-part1": "Quitando",
"removing-from-collection-part2": "roms de la colección",
"delete-rom": "Eliminar",
"copy-link": "Copiar link de descarga",
"cant-copy-link": "No se pudo copiar el link al portapapeles, copialo manualmente",

View File

@@ -26,6 +26,9 @@
"show-collections-desc": "Mostrar la sección de colecciones en la página principal",
"show-collections-as-grid": "Mostrar colecciones como cuadrícula",
"show-collections-as-grid-desc": "Mostrar la sección de colecciones como cuadrícula en la página principal",
"show-virtual-collections": "Mostrar colecciones generadas automáticamente",
"show-virtual-collections-desc": "Mostrado en la página principal y en la barra lateral de colecciones.",
"virtual-collection-type": "Tipo de colección virtual (método de agrupación de ROM)",
"group-roms": "Agrupar roms",
"group-roms-desc": "Agrupar roms con la misma versión en una única entrada en la galería",
"show-siblings": "Mostrar siblings",

View File

@@ -7,6 +7,8 @@
"games-n": "{n} Jeu | {n} Jeux",
"collection": "Collection",
"collections": "Collections",
"virtual-collection": "Collection autogénérée",
"virtual-collections": "Collections autogénérées",
"save": "Sauvegarder",
"saves": "Sauvegardes",
"saves-n": "{n} Sauvegarder | {n} Sauvegardes",

View File

@@ -19,11 +19,11 @@
"add-to-fav": "Ajouter aux favoris",
"remove-from-fav": "Retirer des favoris",
"add-to-collection": "Ajouter à la collection",
"remove-from-collection": "Retirer de la collection",
"adding-to-collection-part1": "Ajout de",
"adding-to-collection-part2": "roms à la collection",
"remove-from-collection": "Retirer de la collection",
"remove-from-collection-part1": "Retrait de",
"remove-from-collection-part2": "roms de la collection",
"removing-from-collection-part1": "Retrait de",
"removing-from-collection-part2": "roms de la collection",
"delete-rom": "Supprimer",
"copy-link": "Copier le lien de téléchargement",
"cant-copy-link": "Impossible de copier le lien dans le presse-papiers, copiez-le manuellement",

View File

@@ -26,6 +26,10 @@
"show-collections-desc": "Afficher la section des collections sur la page d'accueil",
"show-collections-as-grid": "Collections en grille",
"show-collections-as-grid-desc": "Voir les cartes des collections en grille sur la page d'accueil",
"show-virtual-collections": "Afficher les collections générées automatiquement",
"show-virtual-collections-desc": "Affiché sur la page d'accueil et dans la barre latérale des collections.",
"virtual-collection-type": "Type de collection virtuelle (méthode de regroupement des roms)",
"group-roms": "Grouper les roms",
"group-roms-desc": "Grouper les versions du même rom ensemble dans la galerie",
"show-siblings": "Afficher les frères et sœurs",

View File

@@ -7,6 +7,8 @@
"games-n": "게임 {n}개 | 게임 {n}개",
"collection": "모음집",
"collections": "모음집",
"virtual-collection": "자동 생성된 모음집",
"virtual-collections": "자동 생성된 모음집",
"save": "세이브",
"saves": "세이브",
"saves-n": "세이브 {n}개 | 세이브 {n}개",

View File

@@ -19,11 +19,11 @@
"add-to-fav": "즐겨찾기에 추가",
"remove-from-fav": "즐겨찾기에서 제거",
"add-to-collection": "모음집에 추가",
"remove-from-collection": "모음집에서 제거",
"adding-to-collection-part1": "",
"adding-to-collection-part2": "개의 롬을 모음집에 추가합니다",
"remove-from-collection": "모음집에서 제거",
"remove-from-collection-part1": "",
"remove-from-collection-part2": "개의 롬을 모음집에서 제거합니다",
"removing-from-collection-part1": "",
"removing-from-collection-part2": "개의 롬을 모음집에서 제거합니다",
"delete-rom": "삭제",
"copy-link": "다운로드 링크 복사",
"cant-copy-link": "링크를 클립보드에 복사할 수 없습니다. 수동으로 복사합니다",

View File

@@ -22,6 +22,9 @@
"show-collections-desc": "홈페이지에 모음집 페이지를 보여줍니다",
"show-collections-as-grid": "모음집을 그리드 뷰로",
"show-collections-as-grid-desc": "홈페이지에 모음집들을 그리드 뷰로 보여줍니다",
"show-virtual-collections": "자동 생성된 모음집 보이기",
"show-virtual-collections-desc": "홈페이지와 모음집 사이드바에 표시됩니다",
"virtual-collection-type": "자동 생성된 모음집 유형(롬 그룹화 방법)",
"group-roms": "롬 묶음",
"group-roms-desc": "갤러리에서 같은 롬의 다른 버전들을 하나로 묶습니다",
"show-siblings": "파생롬 보이기",

View File

@@ -7,6 +7,8 @@
"games-n": "{n} Jogo | {n} Jogos",
"collection": "Coleção",
"collections": "Coleções",
"virtual-collection": "Coleção autogerada",
"virtual-collections": "Coleções autogeradas",
"save": "Salvar",
"saves": "Salvar",
"saves-n": "{n} Salvar | {n} Salvar",

View File

@@ -19,11 +19,11 @@
"add-to-fav": "Adicionar aos favoritos",
"remove-from-fav": "Remover dos favoritos",
"add-to-collection": "Adicionar à coleção",
"remove-from-collection": "Remover da coleção",
"adding-to-collection-part1": "Adicionando",
"adding-to-collection-part2": "roms à coleção",
"remove-from-collection": "Remover da coleção",
"remove-from-collection-part1": "Removendo",
"remove-from-collection-part2": "roms da coleção",
"removing-from-collection-part1": "Removendo",
"removing-from-collection-part2": "roms da coleção",
"delete-rom": "Excluir",
"copy-link": "Copiar link de download",
"cant-copy-link": "Não é possível copiar o link para a área de transferência, copie manualmente",

View File

@@ -26,6 +26,9 @@
"show-collections-desc": "Mostrar seção de coleções na página inicial",
"show-collections-as-grid": "Coleções como grade",
"show-collections-as-grid-desc": "Ver cartões de coleções como uma grade na página inicial",
"show-virtual-collections": "Mostrar coleções geradas automaticamente",
"show-virtual-collections-desc": "Exibido na página inicial e na barra lateral de coleções.",
"virtual-collection-type": "Tipo de coleção virtual (método de agrupamento de ROM)",
"group-roms": "Agrupar roms",
"group-roms-desc": "Agrupar versões do mesmo rom juntos na galeria",
"show-siblings": "Mostrar irmãos",

View File

@@ -7,6 +7,8 @@
"games-n": "{n} Игра | {n} Игры",
"collection": "Коллекция",
"collections": "Коллекции",
"virtual-collection": "Автосгенерированная коллекция",
"virtual-collections": "Автосгенерированные коллекции",
"save": "Сохранить",
"saves": "Сохранения",
"saves-n": "{n} Сохранить | {n} Сохранения",

View File

@@ -19,11 +19,11 @@
"add-to-fav": "Добавить в избранное",
"remove-from-fav": "Удалить из избранного",
"add-to-collection": "Добавить в коллекцию",
"remove-from-collection": "Удалить из коллекции",
"adding-to-collection-part1": "Добавление",
"adding-to-collection-part2": "ромов в коллекцию",
"remove-from-collection": "Удалить из коллекции",
"remove-from-collection-part1": "Удаление",
"remove-from-collection-part2": "ромов из коллекции",
"removing-from-collection-part1": "Удаление",
"removing-from-collection-part2": "ромов из коллекции",
"delete-rom": "Удалить",
"copy-link": "Скопировать ссылку для скачивания",
"cant-copy-link": "Не удается скопировать ссылку в буфер обмена, скопируйте ее вручную",

View File

@@ -26,6 +26,9 @@
"show-collections-desc": "Показать раздел коллекций на главной странице",
"show-collections-as-grid": "Коллекции в виде сетки",
"show-collections-as-grid-desc": "Просмотр карточек коллекций в виде сетки на главной странице",
"show-virtual-collections": "Показать автоматически созданные коллекции",
"show-virtual-collections-desc": "Отображается на главной странице и в боковой панели коллекций.",
"virtual-collection-type": "Тип виртуальной коллекции (метод группировки ромов)",
"group-roms": "Группировать ромы",
"group-roms-desc": "Группировать версии одного и того же рома вместе в галерее",
"show-siblings": "Показать родственные",

View File

@@ -7,6 +7,8 @@
"games-n": "{n} 游戏 | {n} 游戏",
"collection": "收藏",
"collections": "收藏",
"virtual-collection": "自动生成的收藏",
"virtual-collections": "自动生成的收藏",
"save": "存档",
"saves": "存档",
"saves-n": "{n} 存档 | {n} 存档",

View File

@@ -20,11 +20,11 @@
"add-to-fav": "添加至喜好",
"remove-from-fav": "从喜好中移除",
"add-to-collection": "添加至收藏",
"remove-from-collection": "从收藏中移除",
"adding-to-collection-part1": "添加",
"adding-to-collection-part2": "Roms 至收藏",
"remove-from-collection": "从收藏中移除",
"remove-from-collection-part1": "从收藏中移除",
"remove-from-collection-part2": "Roms",
"removing-from-collection-part1": "从收藏中移除",
"removing-from-collection-part2": "Roms",
"delete-rom": "删除",
"copy-link": "复制下载链接",
"cant-copy-link": "无法将链接复制到剪贴板,请手动复制",

View File

@@ -26,6 +26,9 @@
"show-collections-desc": "在主页上显示收藏部分",
"show-collections-as-grid": "收藏(网格)",
"show-collections-as-grid-desc": "以网格形式在主页上显示收藏(卡片)部分",
"show-virtual-collections": "显示自动生成的收藏",
"show-virtual-collections-desc": "在主页和收藏侧边栏中显示",
"virtual-collection-type": "虚拟收藏类型Rom 文件分组方法)",
"group-roms": "Rom 文件组",
"group-roms-desc": "对游戏库中相同 Rom 文件的不同版本进行分组",
"show-siblings": "显示版本数量",

View File

@@ -1,6 +1,6 @@
import type { MessageResponse } from "@/__generated__";
import api from "@/services/api/index";
import type { Collection } from "@/stores/collections";
import type { Collection, VirtualCollection } from "@/stores/collections";
export type UpdatedCollection = Collection & {
artwork?: File;
@@ -18,7 +18,7 @@ async function createCollection({
formData.append("name", collection.name || "");
formData.append("description", collection.description || "");
formData.append("url_cover", collection.url_cover || "");
formData.append("roms", JSON.stringify(collection.roms));
formData.append("rom_ids", JSON.stringify(collection.rom_ids));
if (collection.artwork) formData.append("artwork", collection.artwork);
return api.post(`/collections`, formData);
}
@@ -27,12 +27,26 @@ async function getCollections(): Promise<{ data: Collection[] }> {
return api.get("/collections");
}
async function getCollection(
id: number | undefined,
): Promise<{ data: Collection }> {
async function getVirtualCollections({
type = "collection",
limit = 100,
}: {
type?: string;
limit?: number;
}): Promise<{ data: VirtualCollection[] }> {
return api.get("/collections/virtual", { params: { type, limit } });
}
async function getCollection(id: number): Promise<{ data: Collection }> {
return api.get(`/collections/${id}`);
}
async function getVirtualCollection(
id: string,
): Promise<{ data: VirtualCollection }> {
return api.get(`/collections/virtual/${id}`);
}
async function updateCollection({
collection,
removeCover = false,
@@ -44,7 +58,7 @@ async function updateCollection({
formData.append("name", collection.name || "");
formData.append("description", collection.description || "");
formData.append("url_cover", collection.url_cover || "");
formData.append("roms", JSON.stringify(collection.roms));
formData.append("rom_ids", JSON.stringify(collection.rom_ids));
if (collection.artwork) formData.append("artwork", collection.artwork);
return api.put(`/collections/${collection.id}`, formData, {
params: { is_public: collection.is_public, remove_cover: removeCover },
@@ -62,7 +76,9 @@ async function deleteCollection({
export default {
createCollection,
getCollections,
getVirtualCollections,
getCollection,
getVirtualCollection,
updateCollection,
deleteCollection,
};

View File

@@ -58,12 +58,14 @@ async function uploadRoms({
async function getRoms({
platformId = null,
collectionId = null,
virtualCollectionId = null,
searchTerm = "",
orderBy = "name",
orderDir = "asc",
}: {
platformId?: number | null;
collectionId?: number | null;
virtualCollectionId?: string | null;
searchTerm?: string | null;
orderBy?: string | null;
orderDir?: string | null;
@@ -72,6 +74,7 @@ async function getRoms({
params: {
platform_id: platformId,
collection_id: collectionId,
virtual_collection_id: virtualCollectionId,
search_term: searchTerm,
order_by: orderBy,
order_dir: orderDir,

View File

@@ -1,14 +1,19 @@
import type { CollectionSchema } from "@/__generated__";
import type {
CollectionSchema,
VirtualCollectionSchema,
} from "@/__generated__";
import { uniqBy } from "lodash";
import { defineStore } from "pinia";
import type { SimpleRom } from "./roms";
export type Collection = CollectionSchema;
export type VirtualCollection = VirtualCollectionSchema;
export default defineStore("collections", {
state: () => {
return {
allCollections: [] as Collection[],
virtualCollections: [] as VirtualCollection[],
favCollection: {} as Collection | undefined,
searchText: "" as string,
};
@@ -18,6 +23,10 @@ export default defineStore("collections", {
allCollections.filter((p) =>
p.name.toLowerCase().includes(searchText.toLowerCase()),
),
filteredVirtualCollections: ({ virtualCollections, searchText }) =>
virtualCollections.filter((p) =>
p.name.toLowerCase().includes(searchText.toLowerCase()),
),
},
actions: {
_reorder() {
@@ -32,6 +41,9 @@ export default defineStore("collections", {
set(collections: Collection[]) {
this.allCollections = collections;
},
setVirtual(collections: VirtualCollection[]) {
this.virtualCollections = collections;
},
add(collection: Collection) {
this.allCollections.push(collection);
this._reorder();
@@ -56,7 +68,7 @@ export default defineStore("collections", {
return this.allCollections.find((p) => p.id === collectionId);
},
isFav(rom: SimpleRom) {
return this.favCollection?.roms?.includes(rom.id);
return this.favCollection?.rom_ids?.includes(rom.id);
},
},
});

View File

@@ -7,7 +7,7 @@ export type Platform = PlatformSchema;
const filters = [
"genres",
"franchises",
"collections",
"meta_collections",
"companies",
"age_ratings",
"status",

View File

@@ -1,6 +1,9 @@
import type { SearchRomSchema } from "@/__generated__";
import type { DetailedRomSchema, SimpleRomSchema } from "@/__generated__/";
import storeCollection, { type Collection } from "@/stores/collections";
import storeCollection, {
type Collection,
type VirtualCollection,
} from "@/stores/collections";
import storeGalleryFilter from "@/stores/galleryFilter";
import { type Platform } from "@/stores/platforms";
import type { ExtractPiniaStoreType } from "@/types";
@@ -18,6 +21,7 @@ export default defineStore("roms", {
state: () => ({
currentPlatform: null as Platform | null,
currentCollection: null as Collection | null,
currentVirtualCollection: null as VirtualCollection | null,
currentRom: null as DetailedRom | null,
allRoms: [] as SimpleRom[],
_grouped: [] as SimpleRom[],
@@ -98,6 +102,9 @@ export default defineStore("roms", {
setCurrentCollection(collection: Collection | null) {
this.currentCollection = collection;
},
setCurrentVirtualCollection(collection: VirtualCollection | null) {
this.currentVirtualCollection = collection;
},
set(roms: SimpleRom[]) {
this.allRoms = roms;
this._reorder();
@@ -233,7 +240,7 @@ export default defineStore("roms", {
const byFavourites = new Set(
this.filteredRoms
.filter((rom) =>
collectionStore.favCollection?.roms?.includes(rom.id),
collectionStore.favCollection?.rom_ids?.includes(rom.id),
)
.map((roms) => roms.id),
);
@@ -277,7 +284,7 @@ export default defineStore("roms", {
const byCollection = new Set(
this.filteredRoms
.filter((rom) =>
rom.collections.some(
rom.meta_collections.some(
(collection) => collection === collectionToFilter,
),
)

View File

@@ -18,16 +18,14 @@ import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { onBeforeRouteUpdate, useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
// Props
const { smAndDown } = useDisplay();
const route = useRoute();
const galleryViewStore = storeGalleryView();
const galleryFilterStore = storeGalleryFilter();
const { scrolledToTop, currentView } = storeToRefs(galleryViewStore);
const collectionsStore = storeCollections();
const { allCollections } = storeToRefs(collectionsStore);
const { allCollections, virtualCollections } = storeToRefs(collectionsStore);
const romsStore = storeRoms();
const {
allRoms,
@@ -35,6 +33,7 @@ const {
selectedRoms,
currentPlatform,
currentCollection,
currentVirtualCollection,
itemsPerBatch,
gettingRoms,
} = storeToRefs(romsStore);
@@ -58,6 +57,7 @@ async function fetchRoms() {
try {
const { data } = await romApi.getRoms({
collectionId: romsStore.currentCollection?.id,
virtualCollectionId: romsStore.currentVirtualCollection?.id,
});
romsStore.set(data);
romsStore.setFiltered(data, galleryFilterStore);
@@ -112,7 +112,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(),
),
]);
@@ -215,11 +215,44 @@ function resetGallery() {
}
onMounted(async () => {
const routeCollectionId = Number(route.params.collection);
const routeCollectionId = route.params.collection;
currentPlatform.value = null;
watch(
() => allCollections.value,
async (collections) => {
if (
collections.length > 0 &&
collections.some(
(collection) => collection.id === Number(routeCollectionId),
)
) {
const collection = collections.find(
(collection) => collection.id === Number(routeCollectionId),
);
// Check if the current platform is different or no ROMs have been loaded
if (
(currentVirtualCollection.value?.id !== routeCollectionId ||
allRoms.value.length === 0) &&
collection
) {
romsStore.setCurrentCollection(collection);
romsStore.setCurrentVirtualCollection(null);
resetGallery();
await fetchRoms();
setFilters();
}
window.addEventListener("wheel", onScroll);
window.addEventListener("scroll", onScroll);
}
},
{ immediate: true }, // Ensure watcher is triggered immediately
);
watch(
() => virtualCollections.value,
async (collections) => {
if (
collections.length > 0 &&
@@ -231,11 +264,12 @@ onMounted(async () => {
// Check if the current platform is different or no ROMs have been loaded
if (
(currentCollection.value?.id !== routeCollectionId ||
(currentVirtualCollection.value?.id !== routeCollectionId ||
allRoms.value.length === 0) &&
collection
) {
romsStore.setCurrentCollection(collection);
romsStore.setCurrentCollection(null);
romsStore.setCurrentVirtualCollection(collection);
resetGallery();
await fetchRoms();
setFilters();
@@ -256,10 +290,34 @@ onBeforeRouteUpdate(async (to, from) => {
resetGallery();
const routeCollectionId = Number(to.params.collection);
const routeCollectionId = to.params.collection;
watch(
() => allCollections.value,
async (collections) => {
if (collections.length > 0) {
const collection = collections.find(
(collection) => collection.id === Number(routeCollectionId),
);
// Only trigger fetchRoms if switching platforms or ROMs are not loaded
if (
(currentCollection.value?.id !== Number(routeCollectionId) ||
allRoms.value.length === 0) &&
collection
) {
romsStore.setCurrentCollection(collection);
romsStore.setCurrentVirtualCollection(null);
await fetchRoms();
setFilters();
}
}
},
{ immediate: true }, // Ensure watcher is triggered immediately
);
watch(
() => virtualCollections.value,
async (collections) => {
if (collections.length > 0) {
const collection = collections.find(
@@ -268,11 +326,12 @@ onBeforeRouteUpdate(async (to, from) => {
// Only trigger fetchRoms if switching platforms or ROMs are not loaded
if (
(currentCollection.value?.id !== routeCollectionId ||
(currentVirtualCollection.value?.id !== routeCollectionId ||
allRoms.value.length === 0) &&
collection
) {
romsStore.setCurrentCollection(collection);
romsStore.setCurrentCollection(null);
romsStore.setCurrentVirtualCollection(collection);
await fetchRoms();
setFilters();
}

View File

@@ -105,7 +105,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(),
),
]);
@@ -210,7 +210,7 @@ function resetGallery() {
const filterToSetFilter: Record<FilterType, Function> = {
genres: galleryFilterStore.setSelectedFilterGenre,
franchises: galleryFilterStore.setSelectedFilterFranchise,
collections: galleryFilterStore.setSelectedFilterCollection,
meta_collections: galleryFilterStore.setSelectedFilterCollection,
companies: galleryFilterStore.setSelectedFilterCompany,
age_ratings: galleryFilterStore.setSelectedFilterAgeRating,
status: galleryFilterStore.setSelectedFilterStatus,

View File

@@ -67,7 +67,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(),
),
]);

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import Collections from "@/components/Home/Collections.vue";
import VirtualCollections from "@/components/Home/VirtualCollections.vue";
import Platforms from "@/components/Home/Platforms.vue";
import RecentSkeletonLoader from "@/components/Home/RecentSkeletonLoader.vue";
import RecentAdded from "@/components/Home/RecentAdded.vue";
@@ -22,7 +23,7 @@ const { recentRoms, continuePlayingRoms: recentPlayedRoms } =
const platforms = storePlatforms();
const { filledPlatforms } = storeToRefs(platforms);
const collections = storeCollections();
const { allCollections } = storeToRefs(collections);
const { allCollections, virtualCollections } = storeToRefs(collections);
const showRecentRoms = isNull(localStorage.getItem("settings.showRecentRoms"))
? true
: localStorage.getItem("settings.showRecentRoms") === "true";
@@ -37,6 +38,11 @@ const showPlatforms = isNull(localStorage.getItem("settings.showPlatforms"))
const showCollections = isNull(localStorage.getItem("settings.showCollections"))
? true
: localStorage.getItem("settings.showCollections") === "true";
const showVirtualCollections = isNull(
localStorage.getItem("settings.showVirtualCollections"),
)
? true
: localStorage.getItem("settings.showVirtualCollections") === "true";
const fetchingRecentAdded = ref(false);
const fetchingContinuePlaying = ref(false);
@@ -93,4 +99,7 @@ onMounted(async () => {
/>
<platforms v-if="filledPlatforms.length > 0 && showPlatforms" />
<collections v-if="allCollections.length > 0 && showCollections" />
<virtual-collections
v-if="virtualCollections.length > 0 && showVirtualCollections"
/>
</template>