Merge branch 'master' into save-sync

This commit is contained in:
Georges-Antoine Assi
2026-04-05 21:47:53 -04:00
103 changed files with 1903 additions and 437 deletions
@@ -3,6 +3,9 @@ name: Copilot Setup Steps
on:
workflow_dispatch:
permissions:
contents: read
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
+3 -1
View File
@@ -87,11 +87,13 @@ jobs:
- name: Comment PR with Docker image link
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
HEAD_REF: ${{ github.head_ref }}
with:
script: |
github.rest.issues.updateComment({
comment_id: ${{ steps.build-comment.outputs.comment-id }},
owner: context.repo.owner,
repo: context.repo.repo,
body: `✅ Preview build completed!\n\nDocker image: \`rommapp/romm-testing:${{ github.head_ref }}\``
body: `✅ Preview build completed!\n\nDocker image: \`rommapp/romm-testing:${process.env.HEAD_REF}\``
})
+1 -2
View File
@@ -76,9 +76,8 @@ sudo apt install libmariadb3 libmariadb-dev libpq-dev
# Users on macOS can skip this step as RAHasher is not supported
git clone --recursive https://github.com/RetroAchievements/RALibretro.git
cd ./RALibretro
git checkout 1.8.0
git checkout 1.8.3
git submodule update --init --recursive
sed -i '22a #include <ctime>' ./src/Util.h
make HAVE_CHD=1 -f ./Makefile.RAHasher
cp ./bin64/RAHasher /usr/bin/RAHasher
```
+3 -8
View File
@@ -43,14 +43,9 @@ RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | b
ENV PATH="$NVM_DIR/versions/node/v24.13.1/bin:$PATH"
# Build and install RAHasher (optional for RA hashes)
RUN git clone --recursive --branch 1.8.1 --depth 1 https://github.com/RetroAchievements/RALibretro.git /tmp/RALibretro
RUN git clone --recursive --branch 1.8.3 --depth 1 https://github.com/RetroAchievements/RALibretro.git /tmp/RALibretro
WORKDIR /tmp/RALibretro
RUN sed -i '22a #include <ctime>' ./src/Util.h \
&& sed -i '6a #include <unistd.h>' \
./src/libchdr/deps/zlib-1.3.1/gzlib.c \
./src/libchdr/deps/zlib-1.3.1/gzread.c \
./src/libchdr/deps/zlib-1.3.1/gzwrite.c \
&& make HAVE_CHD=1 -f ./Makefile.RAHasher \
RUN make HAVE_CHD=1 -f ./Makefile.RAHasher \
&& cp ./bin64/RAHasher /usr/bin/RAHasher
RUN rm -rf /tmp/RALibretro
@@ -63,7 +58,7 @@ RUN npm install
WORKDIR /app
# Install uv for the non-root user
COPY --from=ghcr.io/astral-sh/uv:0.7.19 /uv /uvx /usr/local/bin/
COPY --from=ghcr.io/astral-sh/uv:0.11.2 /uv /uvx /usr/local/bin/
# Install Python
RUN uv python install 3.13
+2
View File
@@ -55,6 +55,7 @@ Here are a few projects maintained by members of our community. Please note that
- 🔷 [Argosy][argosy-launcher]: Native client for installing and launching games by [@tmgast](https://github.com/tmgast)
- [romm-ios-app][romm-ios-app]: Native iOS app by [@ilyas-hallak](https://github.com/ilyas-hallak)
- [romm-mobile][romm-mobile]: Android (and soon iOS) app by [@mattsays](https://github.com/mattsays)
### Desktop
@@ -159,6 +160,7 @@ Here are a few projects that we think you might like:
[playnite-app]: https://github.com/rommapp/playnite-plugin
[ggrequestz]: https://github.com/XTREEMMAK/ggrequestz
[syncthing-sync]: https://github.com/amn-96/romm_syncthing_sync
[romm-mobile]: https://github.com/mattsays/romm-mobile
[romm-client]: https://github.com/chaun14/romm-client
[romm-retroarch-sync]: https://github.com/Covin90/romm-retroarch-sync
[rommate]: https://github.com/brenoprata10/rommate
+2 -1
View File
@@ -65,6 +65,7 @@ PLATFORM_SLUG_TO_RETROACHIEVEMENTS_ID: dict[UPS, int] = {
UPS.WASM_4: 72,
UPS.SUPERVISION: 63,
UPS.WIN: 102,
UPS.WII: 19,
UPS.WONDERSWAN: 53,
UPS.WONDERSWAN_COLOR: 53,
}
@@ -96,7 +97,7 @@ class RAHasherService:
return ""
return_code = await proc.wait()
if return_code != 1:
if return_code != 0:
if proc.stderr is not None:
stderr = (await proc.stderr.read()).decode("utf-8")
else:
@@ -0,0 +1,125 @@
"""Remove fs_name_no_tags matching from sibling_roms view
Revision ID: 0073_sibling_roms_metadata_only
Revises: 0072_client_tokens
Create Date: 2026-04-05 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from utils.database import is_postgresql
# revision identifiers, used by Alembic.
revision = "0073_sibling_roms_metadata_only"
down_revision = "0072_client_tokens"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Drop the fs_name_no_tags index created in 0069, no longer used
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.drop_index("idx_roms_fs_name_no_tags")
connection = op.get_bind()
null_safe_equal_operator = (
"IS NOT DISTINCT FROM" if is_postgresql(connection) else "<=>"
)
connection.execute(
sa.text(f"""
CREATE OR REPLACE VIEW sibling_roms AS
SELECT
r1.id AS rom_id,
r2.id AS sibling_rom_id,
r1.platform_id AS platform_id,
NOW() AS created_at,
NOW() AS updated_at,
CASE WHEN r1.igdb_id {null_safe_equal_operator} r2.igdb_id THEN r1.igdb_id END AS igdb_id,
CASE WHEN r1.moby_id {null_safe_equal_operator} r2.moby_id THEN r1.moby_id END AS moby_id,
CASE WHEN r1.ss_id {null_safe_equal_operator} r2.ss_id THEN r1.ss_id END AS ss_id,
CASE WHEN r1.launchbox_id {null_safe_equal_operator} r2.launchbox_id THEN r1.launchbox_id END AS launchbox_id,
CASE WHEN r1.ra_id {null_safe_equal_operator} r2.ra_id THEN r1.ra_id END AS ra_id,
CASE WHEN r1.hasheous_id {null_safe_equal_operator} r2.hasheous_id THEN r1.hasheous_id END AS hasheous_id,
CASE WHEN r1.tgdb_id {null_safe_equal_operator} r2.tgdb_id THEN r1.tgdb_id END AS tgdb_id
FROM
roms r1
JOIN
roms r2
ON
r1.platform_id = r2.platform_id
AND r1.id != r2.id
AND (
(r1.igdb_id = r2.igdb_id AND r1.igdb_id IS NOT NULL)
OR
(r1.moby_id = r2.moby_id AND r1.moby_id IS NOT NULL)
OR
(r1.ss_id = r2.ss_id AND r1.ss_id IS NOT NULL)
OR
(r1.launchbox_id = r2.launchbox_id AND r1.launchbox_id IS NOT NULL)
OR
(r1.ra_id = r2.ra_id AND r1.ra_id IS NOT NULL)
OR
(r1.hasheous_id = r2.hasheous_id AND r1.hasheous_id IS NOT NULL)
OR
(r1.tgdb_id = r2.tgdb_id AND r1.tgdb_id IS NOT NULL)
);
"""), # nosec B608
)
def downgrade() -> None:
connection = op.get_bind()
null_safe_equal_operator = (
"IS NOT DISTINCT FROM" if is_postgresql(connection) else "<=>"
)
# Recreate the fs_name_no_tags index needed by the restored view
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.create_index("idx_roms_fs_name_no_tags", ["fs_name_no_tags"])
# Restore view with fs_name_no_tags matching (from 0071)
connection.execute(
sa.text(f"""
CREATE OR REPLACE VIEW sibling_roms AS
SELECT
r1.id AS rom_id,
r2.id AS sibling_rom_id,
r1.platform_id AS platform_id,
NOW() AS created_at,
NOW() AS updated_at,
CASE WHEN r1.igdb_id {null_safe_equal_operator} r2.igdb_id THEN r1.igdb_id END AS igdb_id,
CASE WHEN r1.moby_id {null_safe_equal_operator} r2.moby_id THEN r1.moby_id END AS moby_id,
CASE WHEN r1.ss_id {null_safe_equal_operator} r2.ss_id THEN r1.ss_id END AS ss_id,
CASE WHEN r1.launchbox_id {null_safe_equal_operator} r2.launchbox_id THEN r1.launchbox_id END AS launchbox_id,
CASE WHEN r1.ra_id {null_safe_equal_operator} r2.ra_id THEN r1.ra_id END AS ra_id,
CASE WHEN r1.hasheous_id {null_safe_equal_operator} r2.hasheous_id THEN r1.hasheous_id END AS hasheous_id,
CASE WHEN r1.tgdb_id {null_safe_equal_operator} r2.tgdb_id THEN r1.tgdb_id END AS tgdb_id
FROM
roms r1
JOIN
roms r2
ON
r1.platform_id = r2.platform_id
AND r1.id != r2.id
AND (
(r1.igdb_id = r2.igdb_id AND r1.igdb_id IS NOT NULL)
OR
(r1.moby_id = r2.moby_id AND r1.moby_id IS NOT NULL)
OR
(r1.ss_id = r2.ss_id AND r1.ss_id IS NOT NULL)
OR
(r1.launchbox_id = r2.launchbox_id AND r1.launchbox_id IS NOT NULL)
OR
(r1.ra_id = r2.ra_id AND r1.ra_id IS NOT NULL)
OR
(r1.hasheous_id = r2.hasheous_id AND r1.hasheous_id IS NOT NULL)
OR
(r1.tgdb_id = r2.tgdb_id AND r1.tgdb_id IS NOT NULL)
OR
(r1.fs_name_no_tags = r2.fs_name_no_tags AND r1.fs_name_no_tags != '')
);
"""), # nosec B608
)
+12
View File
@@ -256,3 +256,15 @@ SENTRY_DSN: Final[str | None] = _get_env("SENTRY_DSN")
# TESTING
IS_PYTEST_RUN: Final = bool(_get_env("PYTEST_VERSION"))
# PROXY
def has_proxy_env() -> bool:
return any(
_get_env(var)
for var in (
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
)
)
+59 -30
View File
@@ -45,6 +45,7 @@ DEFAULT_EXCLUDED_FILES: Final = [
".stfolder",
"@SynoResource",
"gamelist.xml",
"metadata.pegasus.xml",
]
DEFAULT_EXCLUDED_DIRS: Final = [
"@eaDir",
@@ -224,39 +225,67 @@ class ConfigManager:
self.config = Config(
CONFIG_FILE_MOUNTED=self._config_file_mounted,
CONFIG_FILE_WRITABLE=self._config_file_writable,
EXCLUDED_PLATFORMS=pydash.get(
self._raw_config, "exclude.platforms", DEFAULT_EXCLUDED_DIRS
EXCLUDED_PLATFORMS=sorted(
{
*DEFAULT_EXCLUDED_DIRS,
*pydash.get(self._raw_config, "exclude.platforms", []),
}
),
EXCLUDED_SINGLE_EXT=[
e.lower()
for e in pydash.get(
self._raw_config,
"exclude.roms.single_file.extensions",
DEFAULT_EXCLUDED_EXTENSIONS,
)
],
EXCLUDED_SINGLE_FILES=pydash.get(
self._raw_config,
"exclude.roms.single_file.names",
DEFAULT_EXCLUDED_FILES,
EXCLUDED_SINGLE_EXT=sorted(
{
*(e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS),
*(
e.lower()
for e in pydash.get(
self._raw_config,
"exclude.roms.single_file.extensions",
[],
)
),
}
),
EXCLUDED_MULTI_FILES=pydash.get(
self._raw_config,
"exclude.roms.multi_file.names",
DEFAULT_EXCLUDED_DIRS,
EXCLUDED_SINGLE_FILES=sorted(
{
*DEFAULT_EXCLUDED_FILES,
*pydash.get(
self._raw_config,
"exclude.roms.single_file.names",
[],
),
}
),
EXCLUDED_MULTI_PARTS_EXT=[
e.lower()
for e in pydash.get(
self._raw_config,
"exclude.roms.multi_file.parts.extensions",
DEFAULT_EXCLUDED_EXTENSIONS,
)
],
EXCLUDED_MULTI_PARTS_FILES=pydash.get(
self._raw_config,
"exclude.roms.multi_file.parts.names",
DEFAULT_EXCLUDED_FILES,
EXCLUDED_MULTI_FILES=sorted(
{
*DEFAULT_EXCLUDED_DIRS,
*pydash.get(
self._raw_config,
"exclude.roms.multi_file.names",
[],
),
}
),
EXCLUDED_MULTI_PARTS_EXT=sorted(
{
*(e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS),
*(
e.lower()
for e in pydash.get(
self._raw_config,
"exclude.roms.multi_file.parts.extensions",
[],
)
),
}
),
EXCLUDED_MULTI_PARTS_FILES=sorted(
{
*DEFAULT_EXCLUDED_FILES,
*pydash.get(
self._raw_config,
"exclude.roms.multi_file.parts.names",
[],
),
}
),
PLATFORMS_BINDING=pydash.get(self._raw_config, "system.platforms", {}),
PLATFORMS_VERSIONS=pydash.get(self._raw_config, "system.versions", {}),
+8
View File
@@ -1,6 +1,11 @@
from fastapi import HTTPException, Request, status
from pydantic import BaseModel
from config.config_manager import (
DEFAULT_EXCLUDED_DIRS,
DEFAULT_EXCLUDED_EXTENSIONS,
DEFAULT_EXCLUDED_FILES,
)
from config.config_manager import config_manager as cm
from decorators.auth import protected_route
from endpoints.responses.config import ConfigResponse
@@ -43,6 +48,9 @@ def get_config(request: Request) -> ConfigResponse:
EXCLUDED_MULTI_FILES=cfg.EXCLUDED_MULTI_FILES,
EXCLUDED_MULTI_PARTS_EXT=cfg.EXCLUDED_MULTI_PARTS_EXT,
EXCLUDED_MULTI_PARTS_FILES=cfg.EXCLUDED_MULTI_PARTS_FILES,
DEFAULT_EXCLUDED_DIRS=list(DEFAULT_EXCLUDED_DIRS),
DEFAULT_EXCLUDED_FILES=list(DEFAULT_EXCLUDED_FILES),
DEFAULT_EXCLUDED_EXTENSIONS=list(DEFAULT_EXCLUDED_EXTENSIONS),
PLATFORMS_BINDING=cfg.PLATFORMS_BINDING,
PLATFORMS_VERSIONS=cfg.PLATFORMS_VERSIONS,
SKIP_HASH_CALCULATION=cfg.SKIP_HASH_CALCULATION,
+11 -1
View File
@@ -41,7 +41,7 @@ class UpdateTaskMeta(TypedDict):
update_stats: UpdateStats | None
class CleanupStats(TypedDict):
class OrphanedResourcesCleanupStats(TypedDict):
platforms_in_db: int
roms_in_db: int
platforms_in_fs: int
@@ -50,6 +50,16 @@ class CleanupStats(TypedDict):
removed_fs_roms: int
class MissingRomsCleanupStats(TypedDict):
platform_id: int | None
roms_found: int
roms_deleted: int
errors: int
CleanupStats = Union[OrphanedResourcesCleanupStats, MissingRomsCleanupStats]
class CleanupTaskMeta(TypedDict):
cleanup_stats: CleanupStats | None
+3
View File
@@ -12,6 +12,9 @@ class ConfigResponse(TypedDict):
EXCLUDED_MULTI_FILES: list[str]
EXCLUDED_MULTI_PARTS_EXT: list[str]
EXCLUDED_MULTI_PARTS_FILES: list[str]
DEFAULT_EXCLUDED_DIRS: list[str]
DEFAULT_EXCLUDED_FILES: list[str]
DEFAULT_EXCLUDED_EXTENSIONS: list[str]
PLATFORMS_BINDING: dict[str, str]
PLATFORMS_VERSIONS: dict[str, str]
SKIP_HASH_CALCULATION: bool
+10 -2
View File
@@ -200,8 +200,16 @@ class RomMetadataSchema(BaseModel):
def sort_game_modes(cls, v: list[str]) -> list[str]:
return sorted(v)
@field_validator("age_ratings")
def sort_age_ratings(cls, v: list[str]) -> list[str]:
@field_validator("age_ratings", mode="before")
def normalize_age_ratings(cls, v: str | list[str] | None) -> list[str]:
if not v:
return []
# MySQL/MariaDB returns a scalar string instead of a single-element array
# when using JSON_EXTRACT with a [*] wildcard path on a single-element array.
if isinstance(v, str):
return sorted([v])
return sorted(v)
+1 -1
View File
@@ -11,7 +11,7 @@ class RegionBreakdownItem(TypedDict):
count: int
class StatsReturn(TypedDict):
class StatsReturn(TypedDict, total=False):
PLATFORMS: int
ROMS: int
SAVES: int
+47 -29
View File
@@ -164,19 +164,6 @@ class RomUserData(BaseModel):
)
class RomUserUpdatePayload(BaseModel):
data: RomUserData = Field(
default_factory=RomUserData,
description="Partial rom user data to update. Only provided fields will be updated.",
)
update_last_played: bool = Field(
default=False, description="Set last played timestamp to now."
)
remove_last_played: bool = Field(
default=False, description="Clear the last played timestamp."
)
async def parse_rom_update_form(
request: Request,
igdb_id: str | None = Form(default=None),
@@ -1472,6 +1459,37 @@ async def delete_roms(
continue
try:
if id in delete_from_fs:
log.info(f"Deleting {hl(rom.fs_name)} from filesystem")
try:
rom_path = f"{rom.fs_path}/{rom.fs_name}"
full_path = fs_rom_handler.validate_path(rom_path)
if full_path.is_dir():
await fs_rom_handler.remove_directory(rom_path)
else:
await fs_rom_handler.remove_file(rom_path)
# Clean up empty parent directory if it becomes empty
parent = full_path.parent
if (
parent != fs_rom_handler.base_path
and parent.is_dir()
and not any(parent.iterdir())
):
try:
await fs_rom_handler.remove_directory(
str(parent.relative_to(fs_rom_handler.base_path))
)
except OSError as dir_err:
log.warning(
f"Couldn't clean up empty parent directory for {hl(rom.fs_name)}: {dir_err}"
)
except FileNotFoundError:
error = f"Rom file {hl(rom.fs_name)} not found for platform {hl(rom.platform_display_name, color=BLUE)}[{hl(rom.platform_slug)}]"
log.error(error)
errors.append(error)
failed_items += 1
continue
log.info(
f"Deleting {hl(str(rom.name or 'ROM'), color=BLUE)} [{hl(rom.fs_name)}] from database"
)
@@ -1484,18 +1502,6 @@ async def delete_roms(
f"Couldn't find resources to delete for {hl(str(rom.name or 'ROM'), color=BLUE)}"
)
if id in delete_from_fs:
log.info(f"Deleting {hl(rom.fs_name)} from filesystem")
try:
file_path = f"{rom.fs_path}/{rom.fs_name}"
await fs_rom_handler.remove_file(file_path=file_path)
except FileNotFoundError:
error = f"Rom file {hl(rom.fs_name)} not found for platform {hl(rom.platform_display_name, color=BLUE)}[{hl(rom.platform_slug)}]"
log.error(error)
errors.append(error)
failed_items += 1
continue
successful_items += 1
except Exception as e:
failed_items += 1
@@ -1517,7 +1523,13 @@ async def delete_roms(
async def update_rom_user(
request: Request,
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
payload: Annotated[RomUserUpdatePayload, Body()],
data: Annotated[RomUserData, Body()],
update_last_played: Annotated[
bool, Query(description="Set last played timestamp to now.")
] = False,
remove_last_played: Annotated[
bool, Query(description="Clear the last played timestamp.")
] = False,
) -> RomUserSchema:
"""Update rom data associated to the current user."""
rom = db_rom_handler.get_rom(id)
@@ -1529,11 +1541,17 @@ async def update_rom_user(
id, request.user.id
) or db_rom_handler.add_rom_user(id, request.user.id)
cleaned_data = payload.data.model_dump(exclude_unset=True)
if update_last_played and remove_last_played:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="update_last_played and remove_last_played are mutually exclusive.",
)
if payload.update_last_played:
cleaned_data = data.model_dump(exclude_unset=True)
if update_last_played:
cleaned_data.update({"last_played": datetime.now(timezone.utc)})
elif payload.remove_last_played:
elif remove_last_played:
cleaned_data.update({"last_played": None})
rom_user = db_rom_handler.update_rom_user(db_rom_user.id, cleaned_data)
+9 -5
View File
@@ -30,7 +30,7 @@ from handler.filesystem import (
fs_rom_handler,
)
from handler.filesystem.roms_handler import FSRom
from handler.metadata import meta_gamelist_handler
from handler.metadata import meta_gamelist_handler, meta_hltb_handler
from handler.metadata.ss_handler import get_preferred_media_types
from handler.redis_handler import get_job_func_name, high_prio_queue, redis_client
from handler.scan_handler import (
@@ -413,7 +413,7 @@ async def _identify_rom(
)
# Handle special media files from Screenscraper
if _added_rom.ss_metadata:
if _added_rom.ss_metadata and MetadataSource.SS in metadata_sources:
preferred_media_types = get_preferred_media_types()
for media_type in preferred_media_types:
if _added_rom.ss_metadata.get(f"{media_type.value}_path"):
@@ -423,7 +423,7 @@ async def _identify_rom(
)
# Handle special media files from ES-DE gamelist.xml
if _added_rom.gamelist_metadata:
if _added_rom.gamelist_metadata and MetadataSource.GAMELIST in metadata_sources:
preferred_media_types = get_preferred_media_types()
for media_type in preferred_media_types:
if _added_rom.gamelist_metadata.get(f"{media_type.value}_path"):
@@ -433,7 +433,7 @@ async def _identify_rom(
)
# Store normal and locked badges
if _added_rom.ra_metadata:
if _added_rom.ra_metadata and MetadataSource.RA in metadata_sources:
for ach in _added_rom.ra_metadata.get("achievements", []):
badge_url_lock = ach.get("badge_url_lock", None)
badge_path_lock = ach.get("badge_path_lock", None)
@@ -637,9 +637,13 @@ async def scan_platforms(
await socket_manager.emit("scan:done_ko", e.message)
return scan_stats
# Clear the gamelist cache to ensure we're using fresh gamelist.xml data
# Clear the gamelist cache to ensure we're using fresh gamelist.xml data
meta_gamelist_handler.clear_cache()
# Initialize HLTB handler (fetches current search endpoint and security token)
if MetadataSource.HLTB in metadata_sources:
meta_hltb_handler.initialize()
# Precalculate total platforms and ROMs
total_roms = 0
for platform_slug in fs_platforms:
+10 -4
View File
@@ -9,20 +9,26 @@ router = APIRouter(
@router.get("")
def stats() -> StatsReturn:
def stats(include_platform_stats: bool = False) -> StatsReturn:
"""Endpoint to return the current RomM stats
Returns:
dict: Dictionary with all the stats
"""
return {
result: StatsReturn = {
"PLATFORMS": db_stats_handler.get_platforms_count(),
"ROMS": db_stats_handler.get_roms_count(),
"SAVES": db_stats_handler.get_saves_count(),
"STATES": db_stats_handler.get_states_count(),
"SCREENSHOTS": db_stats_handler.get_screenshots_count(),
"TOTAL_FILESIZE_BYTES": db_stats_handler.get_total_filesize(),
"METADATA_COVERAGE": db_stats_handler.get_metadata_coverage_by_platform(),
"REGION_BREAKDOWN": db_stats_handler.get_region_breakdown_by_platform(),
}
if include_platform_stats:
result["METADATA_COVERAGE"] = (
db_stats_handler.get_metadata_coverage_by_platform()
)
result["REGION_BREAKDOWN"] = db_stats_handler.get_region_breakdown_by_platform()
return result
+11 -2
View File
@@ -3,6 +3,7 @@ from typing import Any, TypedDict
from fastapi import Body, HTTPException, Request
from rq import Worker
from rq.exceptions import NoSuchJobError
from rq.job import Job, JobStatus
from rq.registry import FailedJobRegistry, FinishedJobRegistry
@@ -288,7 +289,11 @@ async def get_tasks_status(request: Request) -> list[TaskStatusResponse]:
# Process finished jobs
for registry in finished_registries:
for job_id in registry.get_job_ids():
job = Job.fetch(job_id, connection=redis_client)
try:
job = Job.fetch(job_id, connection=redis_client)
except NoSuchJobError:
registry.remove(job_id)
continue
all_tasks.append(
_build_task_status_response(
job,
@@ -298,7 +303,11 @@ async def get_tasks_status(request: Request) -> list[TaskStatusResponse]:
# Process failed jobs
for registry in failed_registries:
for job_id in registry.get_job_ids():
job = Job.fetch(job_id, connection=redis_client)
try:
job = Job.fetch(job_id, connection=redis_client)
except NoSuchJobError:
registry.remove(job_id)
continue
all_tasks.append(_build_task_status_response(job))
all_tasks.sort(
@@ -130,13 +130,19 @@ class DBCollectionsHandler(DBBaseHandler):
)
# Insert new CollectionRom entries for this collection
if rom_ids:
session.execute(
insert(CollectionRom),
[
{"collection_id": id, "rom_id": rom_id}
for rom_id in set(rom_ids)
],
# Filter out rom_ids that no longer exist in the roms table to
# avoid foreign key constraint violations (e.g. after a rescan)
valid_rom_ids = set(
session.scalars(select(Rom.id).where(Rom.id.in_(rom_ids))).all()
)
if valid_rom_ids:
session.execute(
insert(CollectionRom),
[
{"collection_id": id, "rom_id": rom_id}
for rom_id in valid_rom_ids
],
)
return session.scalar(query.filter_by(id=id).limit(1))
-6
View File
@@ -641,7 +641,6 @@ class DBRomsHandler(DBBaseHandler):
.with_only_columns(
base_subquery.c.id,
base_subquery.c.fs_name_no_ext,
base_subquery.c.fs_name_no_tags,
base_subquery.c.platform_id,
base_subquery.c.igdb_id,
base_subquery.c.ss_id,
@@ -702,11 +701,6 @@ class DBRomsHandler(DBBaseHandler):
base_subquery.c.flashpoint_id,
base_subquery.c.platform_id,
),
_create_metadata_id_case(
"fs",
func.nullif(base_subquery.c.fs_name_no_tags, ""),
base_subquery.c.platform_id,
),
_create_metadata_id_case(
"romm",
base_subquery.c.id,
+61 -22
View File
@@ -20,6 +20,16 @@ from utils.validation import validate_url_for_http_request
from .base_handler import CoverSize, FSHandler
def _check_content_type(
response: httpx.Response, allowed_prefixes: tuple[str, ...], label: str
) -> bool:
content_type = response.headers.get("content-type", "").lower()
if not any(content_type.startswith(p) for p in allowed_prefixes):
log.warning(f"Unexpected content type for {label}: {content_type}")
return False
return True
class FSResourcesHandler(FSHandler):
def __init__(self) -> None:
super().__init__(base_path=RESOURCES_BASE_PATH)
@@ -75,11 +85,15 @@ class FSResourcesHandler(FSHandler):
# Handle file:// URLs for gamelist.xml
if url_cover.startswith("file://"):
try:
file_path = AnyioPath(url_cover[7:]) # Remove "file://" prefix
if await file_path.exists():
from handler.filesystem import fs_rom_handler
validated = fs_rom_handler.validate_path(
url_cover[7:] # Remove "file://" prefix
)
if await AnyioPath(validated).exists():
# Copy the file to the resources directory
dest_path = f"{cover_file}/{size.value}.png"
await self.copy_file(Path(str(file_path)), dest_path)
await self.copy_file(validated, dest_path)
if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
self.image_converter.convert_to_webp(
@@ -87,14 +101,13 @@ class FSResourcesHandler(FSHandler):
force=True,
)
else:
log.warning(f"Cover file not found: {file_path}")
log.warning(f"Cover file not found: {str(validated)}")
return None
except Exception as exc:
log.error(f"Unable to copy cover file {url_cover}: {str(exc)}")
return None
else:
# Handle HTTP URLs
# Validate URL to prevent SSRF attacks
validate_url_for_http_request(url_cover, "url_cover")
httpx_client = ctx_httpx_client.get()
@@ -103,6 +116,9 @@ class FSResourcesHandler(FSHandler):
"GET", url_cover, timeout=120
) as response:
if response.status_code == status.HTTP_200_OK:
if not _check_content_type(response, ("image/",), "cover"):
return None
# Check if content is gzipped from response headers
is_gzipped = (
response.headers.get("content-encoding", "").lower()
@@ -249,21 +265,22 @@ class FSResourcesHandler(FSHandler):
# Handle file:// URLs for gamelist.xml
if url_screenhot.startswith("file://"):
try:
file_path = AnyioPath(url_screenhot[7:]) # Remove "file://" prefix
if await file_path.exists():
from handler.filesystem import fs_rom_handler
validated = fs_rom_handler.validate_path(
url_screenhot[7:] # Remove "file://" prefix
)
if await AnyioPath(validated).exists():
# Copy the file to the resources directory
await self.copy_file(
Path(str(file_path)), f"{screenshot_path}/{idx}.jpg"
)
await self.copy_file(validated, f"{screenshot_path}/{idx}.jpg")
else:
log.warning(f"Screenshot file not found: {file_path}")
log.warning(f"Screenshot file not found: {str(validated)}")
return None
except Exception as exc:
log.error(f"Unable to copy screenshot file {url_screenhot}: {str(exc)}")
return None
else:
# Handle HTTP URLs
# Validate URL to prevent SSRF attacks
validate_url_for_http_request(url_screenhot, "url_screenshot")
httpx_client = ctx_httpx_client.get()
@@ -272,6 +289,9 @@ class FSResourcesHandler(FSHandler):
"GET", url_screenhot, timeout=120
) as response:
if response.status_code == status.HTTP_200_OK:
if not _check_content_type(response, ("image/",), "screenshot"):
return None
# Check if content is gzipped from response headers
is_gzipped = (
response.headers.get("content-encoding", "").lower()
@@ -365,21 +385,22 @@ class FSResourcesHandler(FSHandler):
# Handle file:// URLs for gamelist.xml
if url_manual.startswith("file://"):
try:
file_path = AnyioPath(url_manual[7:]) # Remove "file://" prefix
if await file_path.exists():
from handler.filesystem import fs_rom_handler
validated = fs_rom_handler.validate_path(
url_manual[7:] # Remove "file://" prefix
)
if await AnyioPath(validated).exists():
# Copy the file to the resources directory
await self.copy_file(
Path(str(file_path)), f"{manual_path}/{rom.id}.pdf"
)
await self.copy_file(validated, f"{manual_path}/{rom.id}.pdf")
else:
log.warning(f"Manual file not found: {file_path}")
log.warning(f"Manual file not found: {str(validated)}")
return None
except Exception as exc:
log.error(f"Unable to copy manual file {url_manual}: {str(exc)}")
return None
else:
# Handle HTTP URL
# Validate URL to prevent SSRF attacks
validate_url_for_http_request(url_manual, "url_manual")
httpx_client = ctx_httpx_client.get()
@@ -388,6 +409,11 @@ class FSResourcesHandler(FSHandler):
"GET", url_manual, timeout=120
) as response:
if response.status_code == status.HTTP_200_OK:
if not _check_content_type(
response, ("application/pdf",), "manual"
):
return None
# Check if content is gzipped from response headers
is_gzipped = (
response.headers.get("content-encoding", "").lower()
@@ -440,7 +466,6 @@ class FSResourcesHandler(FSHandler):
# Retroachievements
async def store_ra_badge(self, url: str, path: str) -> None:
# Validate URL to prevent SSRF attacks
validate_url_for_http_request(url, "url_badge")
httpx_client = ctx_httpx_client.get()
@@ -456,6 +481,9 @@ class FSResourcesHandler(FSHandler):
try:
async with httpx_client.stream("GET", url, timeout=120) as response:
if response.status_code == status.HTTP_200_OK:
if not _check_content_type(response, ("image/",), "badge"):
return
async with await self.write_file_streamed(
path=directory, filename=filename
) as f:
@@ -498,7 +526,12 @@ class FSResourcesHandler(FSHandler):
# Handle file:// URLs for gamelist.xml
if url_media.startswith("file://"):
try:
file_path = AnyioPath(url_media[7:]) # Remove "file://" prefix
from handler.filesystem import fs_rom_handler
validated = fs_rom_handler.validate_path(
url_media[7:] # Remove "file://" prefix
)
file_path = AnyioPath(validated)
if await file_path.exists():
await self.copy_file(Path(str(file_path)), dest_path)
except Exception as exc:
@@ -506,7 +539,6 @@ class FSResourcesHandler(FSHandler):
return None
else:
# Handle HTTP URLs
# Validate URL to prevent SSRF attacks
validate_url_for_http_request(url_media, "url_media")
httpx_client = ctx_httpx_client.get()
@@ -515,6 +547,13 @@ class FSResourcesHandler(FSHandler):
"GET", url_media, timeout=120
) as response:
if response.status_code == status.HTTP_200_OK:
if not _check_content_type(
response,
("image/", "video/", "application/pdf"),
"media",
):
return None
async with await self.write_file_streamed(
path=directory, filename=filename
) as f:
+2 -2
View File
@@ -745,6 +745,6 @@ class FSRomsHandler(FSHandler):
if platform_slug == UPS.PICO and fs_name.lower().endswith(
PICO8_CARTRIDGE_EXTENSION
):
rom_path = self.validate_path(f"{fs_path}/{fs_name}")
return f"file://{rom_path}"
self.validate_path(f"{fs_path}/{fs_name}")
return f"file://{fs_path}/{fs_name}"
return None
@@ -1 +1 @@
https://howlongtobeat.com/api/finder
https://howlongtobeat.com/api/find
+6 -5
View File
@@ -99,10 +99,9 @@ XML_TAG_MAP: Final = {
def _make_file_uri(platform_dir: str, raw_text: str) -> str:
cleaned_text = raw_text.replace("./", "")
validated_path = fs_platform_handler.validate_path(
os.path.join(platform_dir, cleaned_text)
)
return f"file://{str(validated_path)}"
joined_path = Path(platform_dir, cleaned_text)
fs_platform_handler.validate_path(str(joined_path))
return f"file://{joined_path.as_posix()}"
def extract_media_from_gamelist_rom(
@@ -148,7 +147,9 @@ def extract_media_from_gamelist_rom(
found_files = glob.glob(str(search_path))
if found_files:
# trunk-ignore(mypy/literal-required)
gamelist_media[media_key] = f"file://{str(found_files[0])}"
gamelist_media[media_key] = (
f"file://{str(Path(found_files[0]).relative_to(fs_platform_handler.base_path))}"
)
return gamelist_media
+22 -11
View File
@@ -10,7 +10,7 @@ from config import HLTB_API_ENABLED
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
from logger.logger import log
from utils import get_version
from utils.context import ctx_httpx_client
from utils.context import create_httpx_client, ctx_httpx_client
from .base_handler import BaseRom, MetadataHandler
@@ -178,28 +178,31 @@ class HLTBHandler(MetadataHandler):
self.base_url = "https://howlongtobeat.com"
self.user_endpoint = f"{self.base_url}/api/user"
self.stats_endpoint = f"{self.base_url}/api/stats/games?platform=1&year=2000"
self.search_url = f"{self.base_url}/api/search"
self.search_url = f"{self.base_url}/api/find"
self.search_init_url = f"{self.search_url}/init"
self.security_token = None
self.hp_key = None
self.hp_val = None
self.min_similarity_score: Final = 0.85
@classmethod
def is_enabled(cls) -> bool:
return HLTB_API_ENABLED
def initialize(self) -> None:
# HLTB rotates their search endpoint regularly
self._fetch_search_endpoint()
# HLTB now requires a security token
self._fetch_security_token()
@classmethod
def is_enabled(cls) -> bool:
return HLTB_API_ENABLED
def _fetch_search_endpoint(self):
"""Fetch the API endpoint URL from Github."""
if not HLTB_API_ENABLED:
return
try:
with httpx.Client() as client:
with create_httpx_client() as client:
response = client.get(GITHUB_FILE_URL, timeout=10)
response.raise_for_status()
self.search_url = response.text.strip()
@@ -218,7 +221,7 @@ class HLTBHandler(MetadataHandler):
params = {"t": int(time.time())}
try:
with httpx.Client() as client:
with create_httpx_client() as client:
response = client.get(
self.search_init_url,
params=params,
@@ -226,7 +229,10 @@ class HLTBHandler(MetadataHandler):
timeout=10,
)
response.raise_for_status()
self.security_token = response.json().get("token", None)
data = response.json()
self.security_token = data.get("token", None)
self.hp_key = data.get("hpKey", None)
self.hp_val = data.get("hpVal", None)
except Exception as e:
log.warning("Unexpected error fetching HLTB security token: %s", e)
@@ -253,7 +259,7 @@ class HLTBHandler(MetadataHandler):
:return: A dictionary with the json result.
:raises HTTPException: If the request fails or the service is unavailable.
"""
if not self.security_token:
if not self.security_token or not self.hp_key or not self.hp_val:
return {}
httpx_client = ctx_httpx_client.get()
@@ -262,9 +268,14 @@ class HLTBHandler(MetadataHandler):
"Content-Type": "application/json",
"Referer": "https://howlongtobeat.com",
"User-Agent": f"RomM/{get_version()}",
"X-Auth-Token": self.security_token,
"x-auth-token": self.security_token,
"x-hp-key": self.hp_key,
"x-hp-val": self.hp_val,
}
# Some HLTB endpoints require the key:val in the payload
payload[self.hp_key] = self.hp_val
log.debug(
"HowLongToBeat API request: URL=%s, Headers=%s, Payload=%s, Timeout=%s",
url,
@@ -17,10 +17,10 @@ def sanitize_filename(stem: str) -> str:
def file_uri_for_local_path(path: Path) -> str | None:
try:
_ = path.resolve().relative_to(LAUNCHBOX_LOCAL_DIR.resolve())
relative = path.resolve().relative_to(LAUNCHBOX_LOCAL_DIR.resolve())
except ValueError:
return None
return f"file://{str(path)}"
return f"file://{relative.as_posix()}"
def coalesce(*values: object | None) -> str | None:
+1
View File
@@ -464,6 +464,7 @@ RA_PLATFORM_LIST: dict[UPS, SlugToRAId] = {
"name": "Watara/QuickShot Supervision",
},
UPS.WIN: {"id": 102, "name": "Windows"},
UPS.WII: {"id": 19, "name": "Wii"},
UPS.WONDERSWAN: {"id": 53, "name": "WonderSwan"},
UPS.WONDERSWAN_COLOR: {"id": 53, "name": "WonderSwan Color"},
}
@@ -1213,21 +1213,21 @@
},
"zxs:plus3-0.rom": {
"size": "16384",
"crc": "a10230c0",
"md5": "3abdc20e72890a750dd3c745d286dfba",
"sha1": "a837f66977040f7b51ed053a2483c10f3d070ab7"
"crc": "17373da2",
"md5": "9833b8b73384dd5fa3678377ff00a2bb",
"sha1": "e319ed08b4d53a5e421a75ea00ea02039ba6555b"
},
"zxs:plus3-1.rom": {
"size": "16384",
"crc": "09b9c3ca",
"md5": "8361a1d9c8bcef89c0c39293776564ad",
"sha1": "6a4364f25513e4079f048f2de131a896d30edc64"
"crc": "f1d1d99e",
"md5": "0f711ceb5ab801b4701989982e0f334c",
"sha1": "c9969fc36095a59787554026a9adc3b87678c794"
},
"zxs:plus3-2.rom": {
"size": "16384",
"crc": "a60285a0",
"md5": "f36c5c2d1f2a682caadeaa6f947db0da",
"sha1": "0a747cc0b827a94b4fd74cfd818ca792437a38f7"
"crc": "3dbf351d",
"md5": "3b6dd659d5e4ec97f0e2f7878152c987",
"sha1": "22e50c6ba4157a3f6a821bd9937cd26e292775c6"
},
"zxs:plus3-3.rom": {
"size": "16384",
-1
View File
@@ -173,7 +173,6 @@ class Rom(BaseModel):
Index("idx_roms_flashpoint_id", "flashpoint_id"),
Index("idx_roms_hltb_id", "hltb_id"),
Index("idx_roms_gamelist_id", "gamelist_id"),
Index("idx_roms_fs_name_no_tags", "fs_name_no_tags"),
)
fs_name: Mapped[str] = mapped_column(String(length=FILE_NAME_MAX_LENGTH))
@@ -51,7 +51,7 @@ class TestRAHasherService:
async def test_calculate_hash_success(self, service):
"""Test successful hash calculation."""
mock_proc = AsyncMock()
mock_proc.wait.return_value = 1 # RAHasher returns 1 on success
mock_proc.wait.return_value = 0 # RAHasher returns 0 on success
mock_proc.stdout.read.return_value = b"a1b2c3d4e5f6789012345678901234ab\n"
mock_proc.stderr = None
@@ -136,7 +136,7 @@ class TestRAHasherService:
async def test_calculate_hash_with_extra_output(self, service):
"""Test when RAHasher returns hash with extra text."""
mock_proc = AsyncMock()
mock_proc.wait.return_value = 1
mock_proc.wait.return_value = 0
mock_proc.stdout.read.return_value = (
b"Processing file... Hash: a1b2c3d4e5f6789012345678901234ab Done.\n"
)
@@ -181,7 +181,7 @@ class TestRAHasherService:
for platform_id, file_path, platform_slug in test_cases:
mock_proc = AsyncMock()
mock_proc.wait.return_value = 1
mock_proc.wait.return_value = 0
mock_proc.stdout.read.return_value = b"a1b2c3d4e5f6789012345678901234ab\n"
mock_proc.stderr = None
@@ -289,7 +289,7 @@ class TestRAHasherServicePerformance:
async def test_concurrent_hash_calculations(self, service):
"""Test multiple concurrent hash calculations."""
mock_proc = AsyncMock()
mock_proc.wait.return_value = 1
mock_proc.wait.return_value = 0
mock_proc.stdout.read.return_value = b"a1b2c3d4e5f6789012345678901234ab\n"
mock_proc.stderr = None
+39 -16
View File
@@ -14,12 +14,35 @@ def test_config_loader():
os.path.join(Path(__file__).resolve().parent, "fixtures", "config/config.yml")
)
assert loader.config.EXCLUDED_PLATFORMS == ["romm"]
assert loader.config.EXCLUDED_SINGLE_EXT == ["xml"]
assert loader.config.EXCLUDED_SINGLE_FILES == ["info.txt"]
assert loader.config.EXCLUDED_MULTI_FILES == ["my_multi_file_game", "DLC"]
assert loader.config.EXCLUDED_MULTI_PARTS_EXT == ["txt"]
assert loader.config.EXCLUDED_MULTI_PARTS_FILES == ["data.xml"]
assert loader.config.EXCLUDED_PLATFORMS == sorted({*DEFAULT_EXCLUDED_DIRS, "romm"})
assert loader.config.EXCLUDED_SINGLE_EXT == sorted(
{
*(e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS),
"xml",
}
)
assert loader.config.EXCLUDED_SINGLE_FILES == sorted(
{*DEFAULT_EXCLUDED_FILES, "info.txt"}
)
assert loader.config.EXCLUDED_MULTI_FILES == sorted(
{
*DEFAULT_EXCLUDED_DIRS,
"my_multi_file_game",
"DLC",
}
)
assert loader.config.EXCLUDED_MULTI_PARTS_EXT == sorted(
{
*(e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS),
"txt",
}
)
assert loader.config.EXCLUDED_MULTI_PARTS_FILES == sorted(
{
*DEFAULT_EXCLUDED_FILES,
"data.xml",
}
)
assert loader.config.PLATFORMS_BINDING == {"gc": "ngc"}
assert loader.config.PLATFORMS_VERSIONS == {"naomi": "arcade"}
assert loader.config.ROMS_FOLDER_NAME == "ROMS"
@@ -63,16 +86,16 @@ def test_empty_config_loader():
)
)
assert loader.config.EXCLUDED_PLATFORMS == DEFAULT_EXCLUDED_DIRS
assert loader.config.EXCLUDED_SINGLE_EXT == [
e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS
]
assert loader.config.EXCLUDED_SINGLE_FILES == DEFAULT_EXCLUDED_FILES
assert loader.config.EXCLUDED_MULTI_FILES == DEFAULT_EXCLUDED_DIRS
assert loader.config.EXCLUDED_MULTI_PARTS_EXT == [
e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS
]
assert loader.config.EXCLUDED_MULTI_PARTS_FILES == DEFAULT_EXCLUDED_FILES
assert loader.config.EXCLUDED_PLATFORMS == sorted(DEFAULT_EXCLUDED_DIRS)
assert loader.config.EXCLUDED_SINGLE_EXT == sorted(
{e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS}
)
assert loader.config.EXCLUDED_SINGLE_FILES == sorted(DEFAULT_EXCLUDED_FILES)
assert loader.config.EXCLUDED_MULTI_FILES == sorted(DEFAULT_EXCLUDED_DIRS)
assert loader.config.EXCLUDED_MULTI_PARTS_EXT == sorted(
{e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS}
)
assert loader.config.EXCLUDED_MULTI_PARTS_FILES == sorted(DEFAULT_EXCLUDED_FILES)
assert loader.config.PLATFORMS_BINDING == {}
assert loader.config.PLATFORMS_VERSIONS == {}
assert loader.config.ROMS_FOLDER_NAME == "roms"
+145 -8
View File
@@ -115,13 +115,150 @@ def test_delete_roms(client: TestClient, access_token: str, rom: Rom):
assert body["successful_items"] == 1
def test_update_rom_user_props_with_data_envelope(
@patch(
"endpoints.roms.fs_rom_handler.remove_directory",
new_callable=AsyncMock,
)
@patch(
"endpoints.roms.fs_rom_handler.remove_file",
new_callable=AsyncMock,
)
@patch(
"endpoints.roms.fs_rom_handler.validate_path",
)
def test_delete_roms_from_fs_flat(
mock_validate_path,
mock_remove_file,
mock_remove_directory,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test that flat (non-directory) ROM files are deleted from filesystem."""
from pathlib import Path
from unittest.mock import MagicMock
mock_path = MagicMock(spec=Path)
mock_path.is_dir.return_value = False
mock_path.parent.is_dir.return_value = False
mock_validate_path.return_value = mock_path
response = client.post(
"/api/roms/delete",
headers={"Authorization": f"Bearer {access_token}"},
json={"roms": [rom.id], "delete_from_fs": [rom.id]},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["successful_items"] == 1
assert body["failed_items"] == 0
mock_remove_file.assert_called_once()
mock_remove_directory.assert_not_called()
@patch(
"endpoints.roms.fs_rom_handler.remove_directory",
new_callable=AsyncMock,
)
@patch(
"endpoints.roms.fs_rom_handler.remove_file",
new_callable=AsyncMock,
)
@patch(
"endpoints.roms.fs_rom_handler.validate_path",
)
def test_delete_roms_from_fs_flat_cleans_empty_parent(
mock_validate_path,
mock_remove_file,
mock_remove_directory,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test that empty parent directories are cleaned up after flat ROM file deletion."""
from pathlib import Path
from unittest.mock import MagicMock
mock_path = MagicMock(spec=Path)
mock_path.is_dir.return_value = False
# Parent is not the base_path (a MagicMock will not equal a real Path), is a dir, and is empty
mock_path.parent.is_dir.return_value = True
mock_path.parent.__iter__ = lambda self: iter([]) # empty directory
mock_validate_path.return_value = mock_path
response = client.post(
"/api/roms/delete",
headers={"Authorization": f"Bearer {access_token}"},
json={"roms": [rom.id], "delete_from_fs": [rom.id]},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["successful_items"] == 1
assert body["failed_items"] == 0
mock_remove_file.assert_called_once()
# remove_directory should be called to clean up the empty parent dir
mock_remove_directory.assert_called_once()
@patch(
"endpoints.roms.fs_rom_handler.remove_directory",
new_callable=AsyncMock,
)
@patch(
"endpoints.roms.fs_rom_handler.validate_path",
)
def test_delete_roms_from_fs_nested(
mock_validate_path,
mock_remove_directory,
client: TestClient,
access_token: str,
platform: Platform,
):
"""Test that nested (directory) ROMs are deleted using remove_directory."""
from pathlib import Path
from unittest.mock import MagicMock
from handler.database import db_rom_handler
from models.rom import Rom
nested_rom = Rom(
platform_id=platform.id,
name="Nested Game",
slug="nested-game",
fs_name="Nested Game",
fs_name_no_tags="Nested Game",
fs_name_no_ext="Nested Game",
fs_extension="",
fs_path=f"{platform.slug}/roms",
)
nested_rom = db_rom_handler.add_rom(nested_rom)
mock_path = MagicMock(spec=Path)
mock_path.is_dir.return_value = True
mock_validate_path.return_value = mock_path
response = client.post(
"/api/roms/delete",
headers={"Authorization": f"Bearer {access_token}"},
json={"roms": [nested_rom.id], "delete_from_fs": [nested_rom.id]},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["successful_items"] == 1
assert body["failed_items"] == 0
mock_remove_directory.assert_called_once()
def test_update_rom_user_props_flat_payload(
client: TestClient, access_token: str, rom: Rom
):
response = client.put(
f"/api/roms/{rom.id}/props",
headers={"Authorization": f"Bearer {access_token}"},
json={"data": {"backlogged": True, "rating": 7}},
json={"backlogged": True, "rating": 7},
)
assert response.status_code == status.HTTP_200_OK
@@ -137,7 +274,7 @@ def test_update_rom_user_props_partial_update(
setup_response = client.put(
f"/api/roms/{rom.id}/props",
headers={"Authorization": f"Bearer {access_token}"},
json={"data": {"backlogged": True, "rating": 5, "hidden": True}},
json={"backlogged": True, "rating": 5, "hidden": True},
)
assert setup_response.status_code == status.HTTP_200_OK
@@ -145,7 +282,7 @@ def test_update_rom_user_props_partial_update(
response = client.put(
f"/api/roms/{rom.id}/props",
headers={"Authorization": f"Bearer {access_token}"},
json={"data": {"rating": 9}},
json={"rating": 9},
)
assert response.status_code == status.HTTP_200_OK
@@ -159,17 +296,17 @@ def test_update_rom_user_props_last_played_flags(
client: TestClient, access_token: str, rom: Rom
):
mark_played_response = client.put(
f"/api/roms/{rom.id}/props",
f"/api/roms/{rom.id}/props?update_last_played=true",
headers={"Authorization": f"Bearer {access_token}"},
json={"data": {}, "update_last_played": True},
json={},
)
assert mark_played_response.status_code == status.HTTP_200_OK
assert mark_played_response.json()["last_played"] is not None
clear_played_response = client.put(
f"/api/roms/{rom.id}/props",
f"/api/roms/{rom.id}/props?remove_last_played=true",
headers={"Authorization": f"Bearer {access_token}"},
json={"data": {}, "remove_last_played": True},
json={},
)
assert clear_played_response.status_code == status.HTTP_200_OK
assert clear_played_response.json()["last_played"] is None
+1 -3
View File
@@ -1,10 +1,8 @@
from pathlib import Path
from unittest.mock import Mock
import pytest
import socketio
from config import LIBRARY_BASE_PATH
from endpoints.sockets.scan import ScanStats, _should_scan_rom
from handler.filesystem.roms_handler import FSRomsHandler
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
@@ -263,7 +261,7 @@ class TestGetPico8CoverUrl:
fs_name="mygame.p8.png",
fs_path="pico/roms",
)
expected = f"file://{Path(LIBRARY_BASE_PATH).resolve() / 'pico/roms' / 'mygame.p8.png'}"
expected = "file://pico/roms/mygame.p8.png"
assert url == expected
def test_returns_none_for_non_pico8_platform(self, handler: FSRomsHandler):
+8 -8
View File
@@ -15,16 +15,16 @@ def test_config(client):
assert response.status_code == status.HTTP_200_OK
config = response.json()
assert config.get("EXCLUDED_PLATFORMS") == DEFAULT_EXCLUDED_DIRS
assert config.get("EXCLUDED_SINGLE_EXT") == [
assert config.get("EXCLUDED_PLATFORMS") == sorted(DEFAULT_EXCLUDED_DIRS)
assert config.get("EXCLUDED_SINGLE_EXT") == sorted(
e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS
]
assert config.get("EXCLUDED_SINGLE_FILES") == DEFAULT_EXCLUDED_FILES
assert config.get("EXCLUDED_MULTI_FILES") == DEFAULT_EXCLUDED_DIRS
assert config.get("EXCLUDED_MULTI_PARTS_EXT") == [
)
assert config.get("EXCLUDED_SINGLE_FILES") == sorted(DEFAULT_EXCLUDED_FILES)
assert config.get("EXCLUDED_MULTI_FILES") == sorted(DEFAULT_EXCLUDED_DIRS)
assert config.get("EXCLUDED_MULTI_PARTS_EXT") == sorted(
e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS
]
assert config.get("EXCLUDED_MULTI_PARTS_FILES") == DEFAULT_EXCLUDED_FILES
)
assert config.get("EXCLUDED_MULTI_PARTS_FILES") == sorted(DEFAULT_EXCLUDED_FILES)
assert config.get("PLATFORMS_BINDING") == {}
assert not config.get("SKIP_HASH_CALCULATION")
+48
View File
@@ -2,6 +2,7 @@ from unittest.mock import Mock, patch
import pytest
from fastapi import status
from rq.exceptions import NoSuchJobError
from tasks.tasks import Task, TaskType
@@ -322,6 +323,53 @@ class TestRunSingleTask:
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestGetTasksStatus:
"""Test suite for the get_tasks_status endpoint"""
@patch("endpoints.tasks.Worker.all", return_value=[])
@patch("endpoints.tasks.low_prio_queue")
@patch("endpoints.tasks.default_queue")
@patch("endpoints.tasks.high_prio_queue")
@patch("endpoints.tasks.Job.fetch")
def test_get_tasks_status_skips_expired_jobs(
self,
mock_job_fetch,
mock_high_queue,
mock_default_queue,
mock_low_queue,
mock_worker_all,
client,
access_token,
):
"""Test that get_tasks_status skips jobs that have expired from Redis"""
mock_low_queue.get_jobs.return_value = []
mock_default_queue.get_jobs.return_value = []
mock_high_queue.get_jobs.return_value = []
mock_finished_registry = Mock()
mock_finished_registry.get_job_ids.return_value = ["expired-job-id"]
mock_failed_registry = Mock()
mock_failed_registry.get_job_ids.return_value = []
mock_job_fetch.side_effect = NoSuchJobError(
"No such job: rq:job:expired-job-id"
)
with patch(
"endpoints.tasks.FinishedJobRegistry", return_value=mock_finished_registry
):
with patch(
"endpoints.tasks.FailedJobRegistry", return_value=mock_failed_registry
):
response = client.get(
"/api/tasks/status",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
class TestGetTaskById:
"""Test suite for the get_task_by_id endpoint"""
+7 -12
View File
@@ -225,11 +225,9 @@ def test_sibling_roms_empty_fs_name_no_tags_not_matched(platform: Platform):
assert rom1.id not in sibling_ids2
def test_sibling_roms_nonempty_fs_name_no_tags_matched(platform: Platform):
"""ROMs with matching non-empty fs_name_no_tags SHOULD be matched as siblings.
For example, "Sonic Jam (USA).iso" and "Sonic Jam (Japan).iso" both have
fs_name_no_tags = "Sonic Jam" and should be considered siblings.
def test_sibling_roms_fs_name_no_tags_not_matched(platform: Platform):
"""ROMs with matching fs_name_no_tags but no shared metadata ID should NOT
be matched as siblings. Sibling matching is based only on metadata IDs.
"""
rom1 = db_rom_handler.add_rom(
Rom(
@@ -261,19 +259,16 @@ def test_sibling_roms_nonempty_fs_name_no_tags_matched(platform: Platform):
assert loaded_rom1 is not None
assert loaded_rom2 is not None
# ROMs with same non-empty fs_name_no_tags should be siblings
# ROMs with same fs_name_no_tags but no metadata IDs should NOT be siblings
sibling_ids1 = {s.id for s in loaded_rom1.sibling_roms}
sibling_ids2 = {s.id for s in loaded_rom2.sibling_roms}
assert rom2.id in sibling_ids1
assert rom1.id in sibling_ids2
assert rom2.id not in sibling_ids1
assert rom1.id not in sibling_ids2
def test_group_by_meta_id_with_empty_fs_name_no_tags(platform: Platform):
"""ROMs with empty fs_name_no_tags should each get their own group when using
"""ROMs with no metadata IDs should each get their own group when using
group_by_meta_id, not be grouped into a single catch-all group.
Without the fix, all unmatched ROMs with empty fs_name_no_tags would be
grouped under "fs-<platform_id>-" and only 1 would be shown.
"""
rom_names = ["(Japan) Game A", "(Japan) Game B", "(Japan) Game C"]
for name in rom_names:
+46
View File
@@ -0,0 +1,46 @@
from config import has_proxy_env
from utils.context import (
create_aiohttp_session,
create_httpx_async_client,
create_httpx_client,
)
class TestProxyAwareHttpClients:
def test_has_proxy_env_detects_uppercase_proxy_vars(self, monkeypatch):
monkeypatch.setenv("HTTP_PROXY", "http://proxy.internal:8080")
assert has_proxy_env() is True
def test_has_proxy_env_returns_false_without_proxy_vars(self, monkeypatch):
for var in (
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
):
monkeypatch.delenv(var, raising=False)
assert has_proxy_env() is False
async def test_create_aiohttp_session_uses_config_proxy_env(self, monkeypatch):
monkeypatch.setattr("utils.context.has_proxy_env", lambda: True)
session = create_aiohttp_session()
try:
assert session.trust_env is True
finally:
await session.close()
async def test_create_httpx_clients_use_config_proxy_env(self, monkeypatch):
monkeypatch.setattr("utils.context.has_proxy_env", lambda: True)
async_client = create_httpx_async_client()
client = create_httpx_client()
try:
assert async_client._trust_env is True
assert client._trust_env is True
finally:
await async_client.aclose()
client.close()
+50 -16
View File
@@ -258,22 +258,6 @@ class TestValidateUrlForHttpRequest:
in exc_info.value.message
)
def test_invalid_cloud_metadata_service_ips(self):
"""Test that cloud metadata service IPs fail validation."""
# AWS/Azure metadata service: 169.254.169.254
with pytest.raises(ValidationError) as exc_info:
validate_url_for_http_request("http://169.254.169.254", "test_url")
assert (
"cloud metadata service addresses are not allowed" in exc_info.value.message
)
# Link-local addresses (169.254.0.0/16)
with pytest.raises(ValidationError) as exc_info:
validate_url_for_http_request("http://169.254.1.1", "test_url")
assert (
"cloud metadata service addresses are not allowed" in exc_info.value.message
)
def test_invalid_loopback_addresses(self):
"""Test that loopback addresses fail validation."""
# 127.x.x.x range
@@ -342,6 +326,56 @@ class TestValidateUrlForHttpRequest:
validate_url_for_http_request("http://server.localhost", "test_url")
assert "internal domain names are not allowed" in exc_info.value.message
def test_invalid_non_standard_ip_representations(self):
"""Test that non-standard IP representations are blocked (SSRF bypass vectors)."""
# Hexadecimal integer for 127.0.0.1
with pytest.raises(ValidationError) as exc_info:
validate_url_for_http_request("http://0x7f000001", "test_url")
assert (
"private, internal, and reserved IP addresses are not allowed"
in exc_info.value.message
)
# Decimal integer for 127.0.0.1
with pytest.raises(ValidationError) as exc_info:
validate_url_for_http_request("http://2130706433", "test_url")
assert (
"private, internal, and reserved IP addresses are not allowed"
in exc_info.value.message
)
# Shorthand dotted for 127.0.0.1
with pytest.raises(ValidationError) as exc_info:
validate_url_for_http_request("http://127.1", "test_url")
assert (
"private, internal, and reserved IP addresses are not allowed"
in exc_info.value.message
)
# Hexadecimal integer for 10.0.0.1
with pytest.raises(ValidationError) as exc_info:
validate_url_for_http_request("http://0x0a000001", "test_url")
assert (
"private, internal, and reserved IP addresses are not allowed"
in exc_info.value.message
)
# Decimal integer for 192.168.1.1
with pytest.raises(ValidationError) as exc_info:
validate_url_for_http_request("http://3232235777", "test_url")
assert (
"private, internal, and reserved IP addresses are not allowed"
in exc_info.value.message
)
# Hexadecimal integer for 169.254.169.254 (cloud metadata)
with pytest.raises(ValidationError) as exc_info:
validate_url_for_http_request("http://0xa9fea9fe", "test_url")
assert (
"private, internal, and reserved IP addresses are not allowed"
in exc_info.value.message
)
def test_invalid_missing_hostname(self):
"""Test that URLs without hostnames fail validation."""
with pytest.raises(ValidationError) as exc_info:
+16 -2
View File
@@ -7,12 +7,26 @@ import aiohttp
import httpx
from fastapi import Request, Response
from config import has_proxy_env
_T = TypeVar("_T")
ctx_aiohttp_session: ContextVar[aiohttp.ClientSession] = ContextVar("aiohttp_session")
ctx_httpx_client: ContextVar[httpx.AsyncClient] = ContextVar("httpx_client")
def create_aiohttp_session() -> aiohttp.ClientSession:
return aiohttp.ClientSession(trust_env=has_proxy_env())
def create_httpx_async_client() -> httpx.AsyncClient:
return httpx.AsyncClient(trust_env=has_proxy_env())
def create_httpx_client() -> httpx.Client:
return httpx.Client(trust_env=has_proxy_env())
@asynccontextmanager
async def set_context_var(var: ContextVar[_T], value: _T) -> AsyncGenerator[Token[_T]]:
"""Temporarily set a context variables."""
@@ -25,8 +39,8 @@ async def set_context_var(var: ContextVar[_T], value: _T) -> AsyncGenerator[Toke
async def initialize_context() -> AsyncGenerator[None]:
"""Initialize context variables."""
async with (
aiohttp.ClientSession() as aiohttp_session,
httpx.AsyncClient() as httpx_client,
create_aiohttp_session() as aiohttp_session,
create_httpx_async_client() as httpx_client,
set_context_var(ctx_aiohttp_session, aiohttp_session),
set_context_var(ctx_httpx_client, httpx_client),
):
+1
View File
@@ -45,6 +45,7 @@ class ZipResponse(Response):
{
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}; filename=\"{filename}\"",
"X-Archive-Files": "zip",
"X-Archive-Charset": "UTF-8",
}
)
+3 -1
View File
@@ -9,6 +9,8 @@ from pathlib import Path
import httpx
from utils.context import create_httpx_client
# Precompiled regexes for better performance
APP_JS_REGEX = re.compile(
r'src=["\'](?P<path>\/_next\/static\/chunks\/pages\/_app[^"\']+\.js)["\']'
@@ -22,7 +24,7 @@ ENDPOINT_TOKEN_REGEX = re.compile(
def fetch_hltb_app_script(base_url: str = "https://howlongtobeat.com") -> str | None:
"""Fetch the HLTB app script from the site."""
try:
with httpx.Client() as client:
with create_httpx_client() as client:
# 1) Fetch homepage HTML
homepage_url = f"{base_url}/"
resp = client.get(homepage_url, timeout=15)
+23 -9
View File
@@ -1,5 +1,6 @@
import ipaddress
import re
import socket
from urllib.parse import urlparse
from logger.logger import log
@@ -183,14 +184,7 @@ def validate_url_for_http_request(url: str, field_name: str = "URL") -> None:
try:
ip = ipaddress.ip_address(hostname)
# Block cloud metadata service IPs (AWS, GCP, Azure, etc.)
# AWS/Azure metadata service: 169.254.169.254
if isinstance(ip, ipaddress.IPv4Address) and str(ip).startswith("169.254."):
msg = f"Invalid {field_name}: cloud metadata service addresses are not allowed"
log.error(f"SSRF prevention: {msg} - IP '{ip}'")
raise ValidationError(msg, field_name)
# Block private/internal IP addresses
# Block private/internal/link-local IP addresses
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
msg = f"Invalid {field_name}: private, internal, and reserved IP addresses are not allowed"
log.error(f"SSRF prevention: {msg} - IP '{ip}'")
@@ -203,7 +197,27 @@ def validate_url_for_http_request(url: str, field_name: str = "URL") -> None:
raise ValidationError(msg, field_name)
except ValueError as e:
# Not a direct IP address, which is fine - it's a domain name
# ipaddress.ip_address() only handles standard notation. HTTP clients
# also accept hex integers (0x7f000001), decimal integers (2130706433),
# shorthand dotted (127.1), and octal (0177.0.0.1). Use socket.inet_aton()
# which handles these non-standard IPv4 representations.
try:
packed = socket.inet_aton(hostname)
ip = ipaddress.IPv4Address(packed)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
msg = f"Invalid {field_name}: private, internal, and reserved IP addresses are not allowed"
log.error(f"SSRF prevention: {msg} - IP '{ip}'")
raise ValidationError(msg, field_name)
if ip.is_multicast:
msg = f"Invalid {field_name}: multicast addresses are not allowed"
log.error(f"SSRF prevention: {msg} - IP '{ip}'")
raise ValidationError(msg, field_name)
except OSError:
pass # Not an IP address at all - fall through to domain name checks
# Additional checks for suspicious domain patterns
hostname_lower = hostname.lower()
+2 -2
View File
@@ -21,8 +21,8 @@ ARG NODE_VERSION=24.13
ARG NODE_ALPINE_SHA256=4f696fbf39f383c1e486030ba6b289a5d9af541642fc78ab197e584a113b9c03
ARG NGINX_VERSION=1.29.5
ARG NGINX_SHA256=1d13701a5f9f3fb01aaa88cef2344d65b6b5bf6b7d9fa4cf0dca557a8d7702ba
ARG UV_VERSION=0.8.24
ARG UV_SHA256=779f3d612539b4696a1b228724cd79b6e8b8604075a9ac7d15378bccf4053373
ARG UV_VERSION=0.11.2
ARG UV_SHA256=db7642df9c7e6214d4d7df81cfc3e8327768dd15565b1eb414bb83004f64d463
FROM python:${PYTHON_VERSION}-alpine${ALPINE_VERSION}@sha256:${PYTHON_ALPINE_SHA256} AS python-alias
+2
View File
@@ -0,0 +1,2 @@
min-release-age=7
ignore-scripts=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+98 -50
View File
@@ -15,7 +15,7 @@
"cronstrue": "^2.57.0",
"date-fns": "^4.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"md-editor-v3": "^5.8.4",
"mitt": "^3.0.1",
"pinia": "^3.0.1",
@@ -108,7 +108,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -3627,6 +3626,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
@@ -3825,7 +3888,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -4205,9 +4267,9 @@
"license": "MIT"
},
"node_modules/@vue/language-core/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4357,7 +4419,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4438,9 +4499,9 @@
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4670,9 +4731,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4715,7 +4776,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5520,7 +5580,6 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -5810,9 +5869,9 @@
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5877,9 +5936,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -6156,7 +6215,6 @@
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -6201,9 +6259,9 @@
"license": "ISC"
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7310,9 +7368,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
@@ -7361,7 +7419,6 @@
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"peer": true,
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
@@ -7810,9 +7867,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"devOptional": true,
"license": "MIT",
"engines": {
@@ -7993,9 +8050,9 @@
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8165,7 +8222,6 @@
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -8517,9 +8573,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
@@ -9009,7 +9065,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9226,7 +9281,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -9374,7 +9428,6 @@
"integrity": "sha512-Q4SC/4TqbNvaZIFb9YsfBqkGlYHbJJJ6uU3CnRBZqLUF3s5eCMVZAaV4GkTbehIH/bhSj42lMXztOwc71u6rVw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vuetify/loader-shared": "^2.1.2",
"debug": "^4.3.3",
@@ -9401,7 +9454,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
@@ -9424,7 +9476,6 @@
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0 || ^9.0.0",
@@ -9522,7 +9573,6 @@
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.12.2.tgz",
"integrity": "sha512-cVQa4+5iQpDs00ToMUnWRHlMdv1d5tEH2wcZIthqSCmBipQAG4rQKE55zFwZFYlPyiDhUVY1RcAFtXCuHNcCww==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/johnleider"
@@ -9855,7 +9905,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -9908,9 +9957,9 @@
}
},
"node_modules/workbox-build/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9939,7 +9988,6 @@
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
+1 -1
View File
@@ -34,7 +34,7 @@
"cronstrue": "^2.57.0",
"date-fns": "^4.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"md-editor-v3": "^5.8.4",
"mitt": "^3.0.1",
"pinia": "^3.0.1",
@@ -40,7 +40,9 @@ async function loadScripts() {
scriptsLoaded = true;
return true;
} catch (error) {
throw new Error(`Failed to load patcher scripts: ${error.message}`);
throw new Error(`Failed to load patcher scripts: ${error.message}`, {
cause: error,
});
}
}
+2 -2
View File
@@ -31,7 +31,6 @@ export type { Body_update_save_api_saves__id__put } from './models/Body_update_s
export type { Body_update_smart_collection_api_collections_smart__id__put } from './models/Body_update_smart_collection_api_collections_smart__id__put';
export type { Body_update_state_api_states__id__put } from './models/Body_update_state_api_states__id__put';
export type { BulkOperationResponse } from './models/BulkOperationResponse';
export type { CleanupStats } from './models/CleanupStats';
export type { CleanupTaskMeta } from './models/CleanupTaskMeta';
export type { CleanupTaskStatusResponse } from './models/CleanupTaskStatusResponse';
export type { ClientSaveState } from './models/ClientSaveState';
@@ -75,10 +74,12 @@ export type { LaunchboxImage } from './models/LaunchboxImage';
export type { ManualMetadata } from './models/ManualMetadata';
export type { MetadataCoverageItem } from './models/MetadataCoverageItem';
export type { MetadataSourcesDict } from './models/MetadataSourcesDict';
export type { MissingRomsCleanupStats } from './models/MissingRomsCleanupStats';
export type { MobyMetadataPlatform } from './models/MobyMetadataPlatform';
export type { NetplayICEServer } from './models/NetplayICEServer';
export type { OIDCDict } from './models/OIDCDict';
export type { OIDCLogoutResponse } from './models/OIDCLogoutResponse';
export type { OrphanedResourcesCleanupStats } from './models/OrphanedResourcesCleanupStats';
export type { PlatformBindingPayload } from './models/PlatformBindingPayload';
export type { PlatformSchema } from './models/PlatformSchema';
export type { RAGameRomAchievement } from './models/RAGameRomAchievement';
@@ -102,7 +103,6 @@ export type { RomSSMetadata } from './models/RomSSMetadata';
export type { RomUserData } from './models/RomUserData';
export type { RomUserSchema } from './models/RomUserSchema';
export type { RomUserStatus } from './models/RomUserStatus';
export type { RomUserUpdatePayload } from './models/RomUserUpdatePayload';
export type { RoomsResponse } from './models/RoomsResponse';
export type { SaveSchema } from './models/SaveSchema';
export type { SaveSummarySchema } from './models/SaveSummarySchema';
+3 -2
View File
@@ -2,8 +2,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { CleanupStats } from './CleanupStats';
import type { MissingRomsCleanupStats } from './MissingRomsCleanupStats';
import type { OrphanedResourcesCleanupStats } from './OrphanedResourcesCleanupStats';
export type CleanupTaskMeta = {
cleanup_stats: (CleanupStats | null);
cleanup_stats: (OrphanedResourcesCleanupStats | MissingRomsCleanupStats | null);
};
+3
View File
@@ -13,6 +13,9 @@ export type ConfigResponse = {
EXCLUDED_MULTI_FILES: Array<string>;
EXCLUDED_MULTI_PARTS_EXT: Array<string>;
EXCLUDED_MULTI_PARTS_FILES: Array<string>;
DEFAULT_EXCLUDED_DIRS: Array<string>;
DEFAULT_EXCLUDED_FILES: Array<string>;
DEFAULT_EXCLUDED_EXTENSIONS: Array<string>;
PLATFORMS_BINDING: Record<string, string>;
PLATFORMS_VERSIONS: Record<string, string>;
SKIP_HASH_CALCULATION: boolean;
@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type MissingRomsCleanupStats = {
platform_id: (number | null);
roms_found: number;
roms_deleted: number;
errors: number;
};
@@ -2,7 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CleanupStats = {
export type OrphanedResourcesCleanupStats = {
platforms_in_db: number;
roms_in_db: number;
platforms_in_fs: number;
@@ -1,20 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { RomUserData } from './RomUserData';
export type RomUserUpdatePayload = {
/**
* Partial rom user data to update. Only provided fields will be updated.
*/
data?: RomUserData;
/**
* Set last played timestamp to now.
*/
update_last_played?: boolean;
/**
* Clear the last played timestamp.
*/
remove_last_played?: boolean;
};
+8 -8
View File
@@ -5,13 +5,13 @@
import type { MetadataCoverageItem } from './MetadataCoverageItem';
import type { RegionBreakdownItem } from './RegionBreakdownItem';
export type StatsReturn = {
PLATFORMS: number;
ROMS: number;
SAVES: number;
STATES: number;
SCREENSHOTS: number;
TOTAL_FILESIZE_BYTES: number;
METADATA_COVERAGE: Record<string, Array<MetadataCoverageItem>>;
REGION_BREAKDOWN: Record<string, Array<RegionBreakdownItem>>;
PLATFORMS?: number;
ROMS?: number;
SAVES?: number;
STATES?: number;
SCREENSHOTS?: number;
TOTAL_FILESIZE_BYTES?: number;
METADATA_COVERAGE?: Record<string, Array<MetadataCoverageItem>>;
REGION_BREAKDOWN?: Record<string, Array<RegionBreakdownItem>>;
};
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import type { CleanupStats, CleanupTaskStatusResponse } from "@/__generated__";
import type {
OrphanedResourcesCleanupStats,
CleanupTaskStatusResponse,
} from "@/__generated__";
const { t } = useI18n();
const props = defineProps<{
task: CleanupTaskStatusResponse;
cleanupStats: CleanupStats;
cleanupStats: OrphanedResourcesCleanupStats;
}>();
const cleanupProgress = computed(() => {
@@ -3,7 +3,7 @@ import { computed } from "vue";
import type {
ScanStats,
ConversionStats,
CleanupStats,
OrphanedResourcesCleanupStats,
UpdateStats,
ScanTaskStatusResponse,
ConversionTaskStatusResponse,
@@ -32,10 +32,13 @@ const conversionStats = computed((): ConversionStats | null => {
return props.task.meta?.conversion_stats || null;
});
const cleanupStats = computed((): CleanupStats | null => {
const cleanupStats = computed((): OrphanedResourcesCleanupStats | null => {
if (props.task.task_type !== "cleanup") return null;
// @ts-ignore
return props.task.meta?.cleanup_stats || null;
const stats = props.task.meta?.cleanup_stats;
// Only show CleanupTaskProgress for orphaned resources cleanup (has platforms_in_db)
if (!stats || stats.platforms_in_db === undefined) return null;
return stats;
});
const updateStats = computed((): UpdateStats | null => {
@@ -29,61 +29,73 @@ const HEADERS = [
{ title: "", align: "end", key: "actions", sortable: false },
] as const;
const exclusions = computed<Row[]>(() => {
const defs = [
{
set: config.value.EXCLUDED_PLATFORMS || [],
title: t("common.platform"),
icon: "mdi-gamepad-variant-outline",
type: "EXCLUDED_PLATFORMS",
description: t("settings.exclusions-platforms-desc"),
},
{
set: config.value.EXCLUDED_SINGLE_FILES || [],
title: t("settings.excluded-single-rom-files"),
icon: "mdi-file-remove-outline",
type: "EXCLUDED_SINGLE_FILES",
description: t("settings.exclusions-single-files-desc"),
},
{
set: config.value.EXCLUDED_SINGLE_EXT || [],
title: t("settings.excluded-single-rom-extensions"),
icon: "mdi-file-code-outline",
type: "EXCLUDED_SINGLE_EXT",
description: t("settings.exclusions-single-ext-desc"),
},
{
set: config.value.EXCLUDED_MULTI_FILES || [],
title: t("settings.excluded-multi-rom-files"),
icon: "mdi-file-multiple-outline",
type: "EXCLUDED_MULTI_FILES",
description: t("settings.exclusions-multi-files-desc"),
},
{
set: config.value.EXCLUDED_MULTI_PARTS_FILES || [],
title: t("settings.excluded-multi-rom-parts-files"),
icon: "mdi-folder-multiple-outline",
type: "EXCLUDED_MULTI_PARTS_FILES",
description: t("settings.exclusions-multi-parts-files-desc"),
},
{
set: config.value.EXCLUDED_MULTI_PARTS_EXT || [],
title: t("settings.excluded-multi-rom-parts-extensions"),
icon: "mdi-file-cog-outline",
type: "EXCLUDED_MULTI_PARTS_EXT",
description: t("settings.exclusions-multi-parts-ext-desc"),
},
];
const DEFAULT_LIST_MAP: Record<
string,
| "DEFAULT_EXCLUDED_DIRS"
| "DEFAULT_EXCLUDED_FILES"
| "DEFAULT_EXCLUDED_EXTENSIONS"
> = {
EXCLUDED_PLATFORMS: "DEFAULT_EXCLUDED_DIRS",
EXCLUDED_SINGLE_FILES: "DEFAULT_EXCLUDED_FILES",
EXCLUDED_SINGLE_EXT: "DEFAULT_EXCLUDED_EXTENSIONS",
EXCLUDED_MULTI_FILES: "DEFAULT_EXCLUDED_DIRS",
EXCLUDED_MULTI_PARTS_FILES: "DEFAULT_EXCLUDED_FILES",
EXCLUDED_MULTI_PARTS_EXT: "DEFAULT_EXCLUDED_EXTENSIONS",
};
const EXCLUSION_DEFS = [
{
key: "EXCLUDED_PLATFORMS" as const,
title: () => t("common.platform"),
icon: "mdi-gamepad-variant-outline",
},
{
key: "EXCLUDED_SINGLE_FILES" as const,
title: () => t("settings.excluded-single-rom-files"),
icon: "mdi-file-remove-outline",
},
{
key: "EXCLUDED_SINGLE_EXT" as const,
title: () => t("settings.excluded-single-rom-extensions"),
icon: "mdi-file-code-outline",
},
{
key: "EXCLUDED_MULTI_FILES" as const,
title: () => t("settings.excluded-multi-rom-files"),
icon: "mdi-file-multiple-outline",
},
{
key: "EXCLUDED_MULTI_PARTS_FILES" as const,
title: () => t("settings.excluded-multi-rom-parts-files"),
icon: "mdi-folder-multiple-outline",
},
{
key: "EXCLUDED_MULTI_PARTS_EXT" as const,
title: () => t("settings.excluded-multi-rom-parts-extensions"),
icon: "mdi-file-cog-outline",
},
];
function isDefault(type: string, value: string): boolean {
const defaultKey = DEFAULT_LIST_MAP[type];
if (!defaultKey) return false;
const defaults = config.value[defaultKey] || [];
return defaults.includes(value);
}
const exclusions = computed<Row[]>(() => {
const result: Row[] = [];
for (const def of defs) {
for (const v of def.set) {
result.push({
type: def.type,
title: def.title,
icon: def.icon,
value: v,
});
for (const def of EXCLUSION_DEFS) {
const set = config.value[def.key] || [];
for (const v of set) {
if (!isDefault(def.key, v)) {
result.push({
type: def.key,
title: def.title(),
icon: def.icon,
value: v,
});
}
}
}
return result.sort(
@@ -91,6 +103,24 @@ const exclusions = computed<Row[]>(() => {
);
});
const defaultExclusions = computed<Row[]>(() => {
const seen = new Map<string, Row>();
for (const def of EXCLUSION_DEFS) {
const set = config.value[def.key] || [];
for (const v of set) {
if (isDefault(def.key, v) && !seen.has(v)) {
seen.set(v, {
type: def.key,
title: def.title(),
icon: def.icon,
value: v,
});
}
}
}
return [...seen.values()].sort((a, b) => a.value.localeCompare(b.value));
});
function removeExclusion(exclusionValue: string, exclusionType: string) {
if (configStore.isExclusionType(exclusionType)) {
configApi.deleteExclusion({
@@ -214,5 +244,29 @@ function removeExclusion(exclusionValue: string, exclusionType: string) {
</template>
</v-data-table-virtual>
</template>
<div v-if="defaultExclusions.length > 0" class="mt-6">
<div class="text-subtitle-2 text-romm-gray mb-2">
{{ t("settings.exclusions-defaults") }}
</div>
<v-row dense>
<v-col
v-for="item in defaultExclusions"
:key="item.value"
cols="12"
sm="6"
lg="3"
>
<div class="d-flex align-center">
<v-icon :icon="item.icon" size="20" class="mr-2 opacity-50" />
<div>
<div class="text-body-2 opacity-70">{{ item.value }}</div>
<div class="text-caption opacity-50">{{ item.title }}</div>
</div>
</div>
</v-col>
</v-row>
</div>
<CreateExclusionDialog />
</template>
@@ -10,7 +10,7 @@ withDefaults(defineProps<{ text: string; icon: string }>(), {
});
</script>
<template>
<v-item v-slot="{ isSelected, toggle }">
<v-item v-slot="{ isSelected, toggle }" :value="text">
<v-card
:color="isSelected ? 'primary' : 'romm-gray'"
class="d-flex align-center"
@@ -34,6 +34,11 @@ const isNDSRom = computed(() => {
return isNintendoDSRom(props.rom);
});
const isAprilFools = computed(() => {
const today = new Date();
return today.getMonth() === 3 && today.getDate() === 1;
});
const isEmulationSupported = computed(() => {
return (
isEJSEmulationSupported(
@@ -70,7 +75,7 @@ watch(menuOpen, (val) => {
@click.prevent="romApi.downloadRom({ rom })"
/>
</v-col>
<v-col v-if="isEmulationSupported" class="d-flex">
<v-col v-if="isEmulationSupported || isAprilFools" class="d-flex">
<PlayBtn
:rom="rom"
icon-embedded
@@ -24,6 +24,11 @@ const { config } = storeToRefs(configStore);
const { value: heartbeat } = storeToRefs(heartbeatStore);
const emitter = inject<Emitter<Events>>("emitter");
const isAprilFools = computed(() => {
const today = new Date();
return today.getMonth() === 3 && today.getDate() === 1;
});
const isEmulationSupported = computed(() => {
return (
isEJSEmulationSupported(
@@ -67,12 +72,14 @@ async function goToPlayer(rom: SimpleRom) {
name: ROUTES.RUFFLE,
params: { rom: rom.id },
});
} else if (isAprilFools.value) {
await router.push({ name: ROUTES.APRIL_FOOLS });
}
}
</script>
<template>
<template v-if="isEmulationSupported">
<template v-if="isEmulationSupported || isAprilFools">
<v-btn
v-if="iconEmbedded"
v-bind="attrs"
+4 -6
View File
@@ -700,9 +700,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div
class="play-root fixed inset-0 bg-black text-white z-[70] overflow-hidden"
>
<div class="play-root fixed inset-0 bg-black text-white z-70 overflow-hidden">
<div id="game" class="w-full h-full" />
<div
v-if="bezelSrc"
@@ -741,7 +739,7 @@ onBeforeUnmount(() => {
<div class="text-red-300 font-medium">
{{ t("console.emulator-failed") }}
</div>
<div class="mt-1 text-[11px] max-w-xs leading-snug break-words">
<div class="mt-1 text-[11px] max-w-xs leading-snug wrap-break-words">
{{ loaderError }}
</div>
</template>
@@ -771,7 +769,7 @@ onBeforeUnmount(() => {
borderColor: 'var(--console-modal-border)',
boxShadow: 'var(--console-modal-shadow)',
}"
class="relative w-full max-w-[560px] mx-auto rounded-2xl pa-10 md:p-9 flex flex-col gap-6 focus:outline-none border"
class="relative w-full max-w-140 mx-auto rounded-2xl pa-10 md:p-9 flex flex-col gap-6 focus:outline-none border"
>
<div class="flex items-center justify-between">
<h2
@@ -799,7 +797,7 @@ onBeforeUnmount(() => {
? 'opacity-40 cursor-not-allowed'
: '',
focusedExitIndex === i
? 'shadow-[0_0_0_2px_var(--console-modal-tile-selected-border),_0_0_18px_-4px_var(--console-modal-tile-selected-border)]'
? 'shadow-[0_0_0_2px_var(--console-modal-tile-selected-border),0_0_18px_-4px_var(--console-modal-tile-selected-border)]'
: '',
]"
:style="
@@ -0,0 +1,21 @@
{
"add-collection": "Добави колекция",
"create-collection": "Създай колекция",
"create-smart-collection": "Създай умна колекция",
"current-filters": "Текущи филтри",
"danger-zone": "Опасна зона",
"delete-collection": "Изтрий колекция",
"description": "Описание",
"edit-collection": "Редактирай колекция",
"name": "Име",
"owner": "Собственик",
"private": "Частна",
"private-desc": "Частна (видима само за мен)",
"public": "Публична",
"public-desc": "Публична (видима за всички)",
"removing-collection-1": "Премахване на колекция",
"removing-collection-2": "от RomM. Потвърди?",
"removing-smart-collection-1": "Премахване на умна колекция",
"removing-smart-collection-2": "от RomM. Потвърди?",
"search-collection": "Търси колекция"
}
+63
View File
@@ -0,0 +1,63 @@
{
"about": "Относно",
"add": "Добави",
"administration": "Администрация",
"and": "и",
"apply": "Приложи",
"ascii-only": "Само ASCII символи",
"cancel": "Откажи",
"collection": "Колекция",
"collections": "Колекции",
"confirm": "Потвърди",
"confirm-deletion": "Потвърди изтриването",
"core": "Основни",
"create": "Създай",
"delete": "Изтрий",
"dropzone-description": "Пусни ROM файловете тук или кликни за да избереш файлове",
"dropzone-drag-over": "Пусни за качване",
"dropzone-title": "Пусни файловете тук",
"edit": "Редактирай",
"exclude-on-delete": "Не добавяй повторно след изтриване",
"filter": "Филтър",
"firmware": "Фърмуер",
"games-n": "{n} игра | {n} игри",
"invalid-email": "Невалиден имейл",
"invalid-name": "Невалидно име",
"last-updated": "Последна промяна",
"library-management": "Управление на библиотека",
"logout": "Изход",
"name": "Име",
"password-length": "Паролата трябва да е между 6 и 255 символа",
"patcher": "Пачър",
"platform": "Платформа",
"platforms": "Платформи",
"platforms-n": "{n} платформа | {n} платформи",
"profile": "Профил",
"random": "Случайно",
"removing-from-filesystem": "Премахване от файловата система",
"required": "Задължително",
"save": "Запази",
"saves": "Записи",
"saves-n": "{n} запис | {n} записа",
"screenshots-n": "{n} Екранна снимка | {n} Екранни снимки",
"search": "Търси",
"server-stats": "Статистики на сървъра",
"size-on-disk": "Размер на диска",
"slug": "Слъг",
"smart-collection": "Умна колекция",
"smart-collections": "Умни колекции",
"state": "Състояние",
"states": "Състояния",
"states-n": "{n} състояние | {n} състояния",
"sync": "Синхронизирай",
"type": "Тип",
"update": "Обнови",
"upload": "Качи",
"upload-files-selected": "{count} избран файл | {count} избрани файла",
"user-interface": "Потребителски интерфейс",
"username-chars": "Потребителското име може да съдържа само букви, цифри, долни черти и тирета",
"username-length": "Потребителското име трябва да е между 3 и 255 символа",
"virtual-collection": "Автогенерирана колекция",
"virtual-collections": "Автогенерирани колекции",
"warning": "ВНИМАНИЕ:"
}
+43
View File
@@ -0,0 +1,43 @@
{
"collections": "Колекции",
"console": "Конзола",
"console-settings": "Настройки на конзолата",
"default": "По подразбиране",
"detail-file": "Файл",
"detail-size": "Размер на файла",
"disabled": "Изключено",
"emulator-cdn": "Зареждане на емулатор (CDN)…",
"emulator-failed": "Неуспешно зареждане на емулатор",
"emulator-loading": "Зареждане на емулатор…",
"enabled": "Включено",
"exit-console-mode": "Излез от режим конзола",
"exit-game": "Натисни Start + Select (или Backspace) за изход",
"fullscreen": "Цял екран",
"game-detail": "Детайли",
"game-exit": "Излез от играта",
"game-exit-cancel": "Откажи",
"game-exit-cancel-desc": "Върни се към играта",
"game-exit-nosave": "Излез без запазване",
"game-exit-nosave-desc": "Излез незабавно, прогресът от последния запис ще бъде изгубен",
"game-exit-save": "Запази и излез",
"game-exit-save-desc": "Запази текущото състояние и излез",
"game-play": "Играй",
"game-saving": "ЗАПАЗВАНЕ…",
"games-n": "{n} игра | {n} игри",
"loading-platforms": "Зареждане на платформи…",
"nav-back": "Назад",
"nav-delete": "Изтрий",
"nav-favorite": "Любими",
"nav-menu": "Меню",
"nav-navigation": "Навигация",
"nav-select": "Избери",
"platforms": "Платформи",
"recently-played": "Последно играни",
"save-states": "ЗАПИСАНИ СЪСТОЯНИЯ",
"screenshots": "Екранни снимки",
"settings": "Настройки",
"smart-collections": "Умни колекции",
"sound-effects": "Звукови ефекти",
"theme": "Тема",
"virtual-collections": "Автоматично генерирани колекции"
}
@@ -0,0 +1,11 @@
{
"404-subtitle": "Страницата, която търсиш, не съществува",
"404-title": "Страницата не е намерена",
"firmware-text": "Търсеният фърмуер/BIOS не съществува",
"firmware-title": "Няма фърмуер/BIOS за показване",
"home-headline": "Няма игри в библиотеката ти",
"home-text": "Готов ли си да играеш?",
"home-title": "Пусни бързо сканиране за автоматично откриване на игри",
"manual-match": "Търси игри във всички източници на метаданни",
"search-for-games": "Търси игри за всички платформи"
}
+4
View File
@@ -0,0 +1,4 @@
{
"all-loaded": "Всички игри са заредени",
"load-more": "Зареди още"
}
+4
View File
@@ -0,0 +1,4 @@
{
"continue-playing": "Продължи играта",
"recently-added": "Наскоро добавени"
}
+16
View File
@@ -0,0 +1,16 @@
{
"back-to-login": "Обратно към входа",
"confirm-new-password": "Потвърди новата парола",
"forgot-password": "Забравена парола?",
"login": "Вход",
"login-oidc": "Вход с {oidc}",
"new-password": "Нова парола",
"or": "или",
"password": "Парола",
"register": "Регистрация",
"reset-password": "Възстановяване на парола",
"reset-sent": "Линкът за възстановяване е изпратен. Свържи се с администратора.",
"send-reset-link": "Изпрати линк за възстановяване",
"setup-wizard": "Съветник за настройка",
"username": "Потребителско име"
}
+34
View File
@@ -0,0 +1,34 @@
{
"apply-download": "Приложи и изтегли",
"apply-download-upload": "Приложи, изтегли и качи",
"apply-upload": "Приложи и качи",
"choose-patch": "Избери пач",
"choose-rom": "Избери ROM",
"download-locally": "Изтегли пачнатия ROM",
"drag-drop-patch": "Пусни пач файл тук или кликни за избор.",
"drag-drop-rom": "Пусни ROM файл тук или кликни за избор.",
"drop-patch-here": "Пусни Patch-а тук",
"drop-rom-here": "Пусни ROM-а тук",
"error-no-action": "Моля избери поне едно действие: изтегляне или качване.",
"error-no-patch": "Моля избери пач файл.",
"error-no-platform": "Моля избери платформа за качване.",
"error-no-rom": "Моля избери ROM файл.",
"error-upload-failed": "Неуспешно качване на ROM: {error}",
"output-filename": "Изходно име на файл (незадължително)",
"patch-file": "Пач файл",
"powered-by": "Задвижвано от patcherjs",
"replace": "Замени",
"rom-file": "ROM файл",
"status-downloading": "Изтегляне на пачнатия ROM...",
"status-preparing": "Подготовка на файловете...",
"status-uploading": "Качване в RomM...",
"subtitle": "Избери основен ROM и пач файл, след което приложи за да изтеглиш пачнатия ROM.",
"success-downloaded": "изтеглен",
"success-message": "Пачнатият ROM беше успешно {actions}!",
"success-uploaded": "качен",
"supported-formats": "Поддържани формати за пач",
"title": "ROM Пачър",
"upload-errors": " (с някои грешки)",
"upload-success": "Пачнатият ROM беше качен успешно{errors}. Стартиране на сканиране...",
"upload-to-romm": "Качи в RomM"
}
+61
View File
@@ -0,0 +1,61 @@
{
"active-multi-select": "Активен мулти избор",
"age-rating": "Възрастова група",
"category": "Категория",
"change-view": "Смени изгледа",
"collection": "Колекция",
"company": "Компания",
"cover-style": "Стил на корицата",
"danger-zone": "Опасна зона",
"delete-platform": "Изтрий платформата",
"export": "Експортирай",
"family": "Фамилия",
"filter-gallery": "Филтрирай галерията",
"firmware-deleted-successfully": "Фърмуерът е изтрит успешно! | {count} фърмуера бяха изтрити успешно!",
"firmware-remove-warning": "Ще премахнеш {count} фърмуер от файловата си система. Това действие е необратимо! | Ще премахнеш {count} фърмуера от файловата си система. Това действие е необратимо!",
"firmware-select-to-remove": "Избери фърмуер файловете които искаш да премахнеш от файловата система, в противен случай ще бъдат изтрити само от базата данни на RomM.",
"firmware-uploaded-successfully": "{count} файл качен успешно. | {count} файла качени успешно.",
"firmware-uploading": "Качване на {count} фърмуер файл в {platform}... | Качване на {count} фърмуер файла в {platform}...",
"franchise": "Франчайз",
"generation": "Поколение",
"genre": "Жанр",
"language": "Език",
"match-all-logic": "Отговаря на всички избрани стойности (И)",
"match-any-logic": "Отговаря на която и да е избрана стойност (ИЛИ)",
"match-none-logic": "Не отговаря на нито една избрана стойност (БЕЗ)",
"missing-from-filesystem": "Липсваща платформа от файловата система",
"no-firmware-found": "Няма намерен фърмуер",
"old-horizontal-cases": "Стари хоризонтални кутии",
"old-squared-cases": "Стари квадратни кутии",
"player-count": "Брой играчи",
"region": "Регион",
"removing-firmware": "Премахване на {n} фърмуер файл от RomM | Премахване на {n} фърмуер файла от RomM",
"removing-platform-1": "Премахване на платформа",
"removing-platform-2": "] от RomM. Потвърди?",
"reset-filters": "Нулирай филтрите",
"search-platform": "Търси платформа",
"settings": "Настройки",
"show-duplicates": "Покажи дубликати",
"show-duplicates-only": "Покажи само дублирани ROM-ове",
"show-favorites": "Покажи любими",
"show-favorites-only": "Покажи само любими ROM-ове",
"show-firmwares": "Покажи фърмуери/BIOS",
"show-matched": "Покажи съвпадащите",
"show-missing": "Покажи липсващите",
"show-missing-only": "Покажи само липсващи ROM-ове",
"show-not-duplicates-only": "Покажи само недублирани ROM-ове",
"show-not-favorites-only": "Покажи само нелюбими ROM-ове",
"show-not-missing-only": "Покажи само налични ROM-ове",
"show-not-playables-only": "Покажи само неизпълними ROM-ове",
"show-not-ra-only": "Покажи само ROM-ове без RetroAchievements",
"show-not-verified-only": "Покажи само непотвърдени ROM-ове",
"show-playables": "Покажи изпълнимите",
"show-playables-only": "Покажи само изпълними ROM-ове",
"show-ra": "Покажи RetroAchievements",
"show-ra-only": "Покажи само ROM-ове с RetroAchievements",
"show-unmatched": "Покажи несъвпадащите",
"show-verified": "Покажи потвърдените",
"show-verified-only": "Покажи само потвърдени ROM-ове",
"status": "Статус",
"upload-roms": "Качи ROM-ове"
}
+24
View File
@@ -0,0 +1,24 @@
{
"back-to-gallery": "Към галерията",
"back-to-game-details": "Към детайлите на играта",
"background-color": "Цвят на фона",
"change-save": "Смени записа",
"change-state": "Смени бързия запис",
"clear-cache": "Изчисти кеша на EmulatorJS",
"clear-cache-description": "Записите и бързите записи съхранени на сървъра няма да бъдат засегнати.",
"clear-cache-title": "Сигурен ли си, че искаш да изчистиш кеша на EmulatorJS?",
"clear-cache-warning": "Това ще премахне всички записи и бързи записи съхранени в браузъра.",
"deselect-save": "Отмени избрания запис",
"deselect-state": "Отмени избрания бърз запис",
"full-screen": "Цял екран",
"no-save-selected": "Няма избран запис",
"no-saves-available": "Няма налични записи",
"no-state-selected": "Няма избран бърз запис",
"no-states-available": "Няма налични бързи записи",
"play": "Играй",
"quit": "Излез",
"save-and-quit": "Запази и излез",
"select-background-color": "Избери цвят на фона",
"select-save": "Избери запис",
"select-state": "Избери бърз запис"
}
+106
View File
@@ -0,0 +1,106 @@
{
"add-new-note": "Добави нова бележка",
"add-note": "Добави бележка",
"add-to-collection": "Добави в колекция",
"add-to-favorites": "Добави в любими",
"adding-to-collection": "Добавяне на {n} ROM-а в колекция",
"additional-content": "Допълнително съдържание",
"age-rating": "Възрастова оценка",
"all-styles": "Всички стилове",
"backlogged": "В списъка",
"by": "от",
"cant-copy-link": "Линкът не може да бъде копиран, копирай го ръчно",
"collections": "Колекции",
"community-notes": "Бележки на общността",
"companies": "Компании",
"completion": "Завършеност",
"completionist": "Перфекционист",
"confirm-delete-note": "Сигурен ли си, че искаш да изтриеш бележката \"{title}\"?",
"copy-link": "Копирай линк за сваляне",
"default": "Задай по подразбиране",
"delete": "Изтрий",
"delete-filesystem-warning": "Ще премахнеш {n} ROM от файловата си система. Това действие е необратимо! | Ще премахнеш {n} ROM-а от файловата си система. Това действие е необратимо!",
"delete-manual-button": "Изтрий",
"delete-manual-confirm-body": "Файлът с ръководството ще бъде окончателно премахнат от файловата система.",
"delete-manual-confirm-title": "Сигурен ли си, че искаш да изтриеш ръководството?",
"delete-select-instruction": "Избери игрите които искаш да премахнеш от файловата система, иначе ще бъдат изтрити само от базата данни на RomM.",
"deleted-from-database": "{count} ROM изтрит от базата данни | {count} ROM-а изтрити от базата данни",
"deleted-from-filesystem": "{count} ROM изтрит от файловата система | {count} ROM-а изтрити от файловата система",
"details": "Детайли",
"difficulty": "Трудност",
"download": "Свали",
"file": "Файл",
"filename": "Име на файл",
"filename-required": "Неуспешно запазване: името на файла е задължително",
"files": "Файлове",
"folder-name": "Име на папка",
"franchises": "Франчайзи",
"genres": "Жанрове",
"hidden": "Скрито",
"how-long-to-beat": "How Long to Beat",
"info": "Информация",
"languages": "Езици",
"main-plus-extra": "Основна + Допълнителна",
"main-story": "Основна история",
"make-private": "Направи частно",
"make-public": "Направи публично",
"manual": "Ръководство",
"manual-match": "Ръчно разпознаване",
"manual-remove-failed": "Грешка при премахване на ръководството: {error}",
"manual-removed": "Ръководството е премахнато успешно",
"manuals-upload-failed": "Неуспешно качване на ръководства: {error}",
"manuals-upload-skipped": "Всички ръководства са пропуснати, няма какво да се качи.",
"manuals-upload-success": "{count} ръководство качено успешно ({failed} пропуснати/неуспешни). | {count} ръководства качени успешно ({failed} пропуснати/неуспешни).",
"metadata": "Метаданни",
"metadata-ids": "Метаданни ID-та",
"my-notes": "Моите бележки",
"no-metadata-source": "Няма активиран източник на метаданни",
"no-notes": "Няма намерени бележки",
"no-notes-desc": "Натисни бутона Добави бележка за да създадеш първата си бележка",
"no-saves-found": "Няма намерени записи",
"no-states-found": "Няма намерени бързи записи",
"note-content": "Съдържание на бележката",
"note-title": "Заглавие на бележката",
"note-title-exists": "Вече съществува бележка с това заглавие",
"now-playing": "Играна в момента",
"personal": "Лично",
"player-count": "Играчи",
"private": "Частно",
"public": "Публично",
"public-notes": "Публични бележки",
"rating": "Оценка",
"refresh-metadata": "Обнови метаданните",
"regions": "Региони",
"related-content": "Свързано съдържание",
"remove-from-collection": "Премахни от колекция",
"remove-from-favorites": "Премахни от любими",
"remove-from-playing": "Премахни от играни",
"removing-from-collection": "Премахване на {n} ROM-а от колекция",
"removing-title": "Премахване на {n} игра от RomM | Премахване на {n} игри от RomM",
"rename-file-details": "Файлът ще бъде преименуван от {from} на {to}. Таговете на файла няма да бъдат засегнати.",
"rename-file-title": "Преименувай файла според заглавието от {source}",
"results-found": "Намерени резултати",
"save-data": "Данни от записи",
"select-all": "Избери всички",
"select-cover-image": "Избери изображение за корица",
"set-as-default": "Задай ROM-а като основен",
"show-earned-only": "Покажи само спечелените",
"size": "Размер",
"status": "Статус",
"status-backlogged": "В списъка",
"status-completed-100": "Завършена на 100%",
"status-finished": "Завършена",
"status-hidden": "Скрита",
"status-incomplete": "Незавършена",
"status-never-playing": "Неиграна никога",
"status-now-playing": "Играна в момента",
"status-retired": "Изоставена",
"summary": "Резюме",
"switch-version": "Смени версията",
"tags": "Тагове",
"unmatch": "Премахни разпознаването",
"unmatch-success": "Разпознаването на ROM-а е премахнато успешно",
"unselect-all": "Премахни избора на всички",
"update-success": "ROM-ът е обновен успешно!",
"updated": "Обновено"
}
+47
View File
@@ -0,0 +1,47 @@
{
"abort": "Прекъсни",
"api-key-invalid": "Невалиден API ключ!",
"api-key-missing": "Липсващ или невалиден API ключ",
"api-key-missing-or-disabled": "Липсващ API ключ или деактивиран източник",
"api-key-missing-short": "Липсва API ключ!",
"api-key-set": "API ключът е зададен",
"api-key-valid": "API ключът е зададен и валиден",
"calculate-hashes": "Изчисли хешове",
"complete-rescan": "Пълно повторно сканиране",
"complete-rescan-desc": "Пълно повторно сканиране на избраните платформи (най-бавно)",
"connection-failed": "Неуспешна връзка",
"connection-in-progress": "Свързване...",
"connection-successful": "Успешна връзка",
"disabled-by-admin": "Деактивирано от администратора",
"firmware-found": "Намерен фърмуер: {n} файл | Намерен фърмуер: {n} файла",
"firmware-scanned-n": "Фърмуер: {n} сканирани",
"firmware-scanned-with-details": "Фърмуер: {n_scanned_firmware} сканирани, от които {n_new_firmware} нови",
"hash-calculation-disabled": "Изчисляването на хешове е деактивирано",
"hasheous-requires-hashes": "Hasheous изисква активирано изчисляване на хешове",
"hashes": "Преизчисли хешове",
"hashes-desc": "Преизчисляване хешовете за избраните платформи",
"hashes-disabled-tooltip": "Изчисляването на хешове е деактивирано.<br><br>Хешовете (MD5, SHA1, CRC32) са уникални отпечатъци които идентифицират ROM файловете прецизно.<br><br>Без тях Hasheous и RetroAchievements не могат да разпознаят игри в своите бази данни, но сканирането ще бъде по-бързо.",
"hashes-enabled-tooltip": "Изчисляването на хешове е активирано.<br><br>Хешовете (MD5, SHA1, CRC32) ще бъдат изчислени за създаване на уникални отпечатъци за всеки ROM файл.<br><br>Това позволява на Hasheous и RetroAchievements да разпознаят точно игрите в своите бази данни.",
"manage-library": "Управление на библиотека",
"metadata-sources": "Източници на метаданни",
"new-platforms": "Нови платформи",
"new-platforms-desc": "Сканирай само нови платформи (най-бързо)",
"no-new-roms": "Няма намерени нови или променени ROM-ове",
"not-identified": "Неидентифициран",
"platforms-scanned-n": "Платформи: {n} сканирани",
"platforms-scanned-with-details": "Платформи: {n_scanned_platforms} сканирани от {n_total_platforms}, от които {n_new_platforms} нови и {n_identified_platforms} идентифицирани",
"quick-scan": "Бързо сканиране",
"quick-scan-desc": "Сканирай само нови игри",
"retroachievements-requires-hashes": "RetroAchievements изисква активирано изчисляване на хешове",
"roms-scanned-n": "ROM-ове: {n} сканирани",
"roms-scanned-with-details": "ROM-ове: {n_scanned_roms} сканирани от {n_total_roms}, от които {n_new_roms} нови и {n_identified_roms} идентифицирани",
"scan": "Сканирай",
"scan-options": "Опции за сканиране",
"scan-types-info": "<strong>Нови платформи:</strong> Търси само платформи които все още не са в RomM.<br><br><strong>Бързо сканиране:</strong> Сканира игри които все още не са в библиотеката (най-бързо).<br><br><strong>Несъвпадащи игри:</strong> Опитва се да разпознае игри които не са разпознати от избраните източници на метаданни.<br>Например, ако изберете IGDB и ScreenScraper ще сканирате игри, които не са разпознати от IGDB или ScreenScraper.<br><br><strong>Обнови метаданни:</strong> Обновява метаданните на игри разпознати от избраните източници, използвайки външно ID (напр. IGDB ID).<br>Например, ако изберете IGDB и ScreenScraper ще се обновят метаданните на разпознатите игри, използвайки igdb_id и/или ssfr_id.<br><br><strong>Преизчисли хешове:</strong> Преизчислява хешовете на всички файлове в избраните платформи.<br><br><strong>Пълно повторно сканиране:</strong> Повторно сканира и разпознава всички игри в избраните платформи (най-бавно).<br>Това ще изтрие всички съществуващи разпознавания включително външните ID-та и ще опита да ги разпознае отново като при първоначално сканиране. Записите, бързите записи и бележките ще бъдат запазени.",
"scan-types-more-info": "Повече информация",
"select-one-source": "Моля избери поне един източник на метаданни за да обогатиш библиотеката си с корици и метаданни",
"unmatched-games": "Неразпознати игри",
"unmatched-games-desc": "Сканирай игри с липсващи метаданни",
"update-metadata": "Обнови метаданни",
"update-metadata-desc": "Обнови метаданните на разпознатите игри"
}
+172
View File
@@ -0,0 +1,172 @@
{
"add-exclusion-for": "Добави ново изключение за",
"add-folder-alias": "Добави псевдоним на папка",
"add-folder-mapping": "Добави свързване на папка",
"add-mapping-type": "Избери тип свързване:",
"add-platform-variant": "Добави вариант на платформа",
"auto-detected": "Автоматично открит",
"backlogged": "В списъка",
"boxart-box3d": "3D кутия",
"boxart-cover": "2D кутия",
"boxart-desc": "Избери стил на кориците",
"boxart-miximage": "Смесено изображение",
"boxart-physical": "Физически носител",
"boxart-style": "Кутия",
"canceled": "Отменено",
"client-api-tokens": "API токени",
"client-token-confirm-delete": "Сигурен ли си, че искаш да отмениш този токен? Всички устройства които го използват ще загубят достъп.",
"client-token-confirm-regenerate": "Това ще анулира текущия токен. Всички устройства използващи стария токен ще трябва да се сдвоят отново.",
"client-token-copied": "Токенът е копиран в клипборда",
"client-token-created": "Токенът е създаден успешно",
"client-token-deleted": "Токенът е отменен успешно",
"client-token-delivery-hint": "Този токен няма да бъде показан отново.",
"client-token-expiry-30d": "30 дни",
"client-token-expiry-90d": "90 дни",
"client-token-expiry-1y": "1 година",
"client-token-expiry-never": "Никога",
"client-token-name": "Име на токена",
"client-token-pair-claimed": "Устройството е сдвоено успешно!",
"client-token-pair-expired": "Кодът за сдвояване е изтекъл",
"client-token-regenerated": "Токенът е регенериран успешно",
"client-token-scopes": "Разрешения",
"client-token-select-expiry": "Изтичане",
"cleanup": "Почистване",
"cleanup-all": "Почисти всичко",
"cleanup-all-confirm": "Това ще изтрие окончателно всички липсващи ROM-ове{platform} от базата данни и техните директории. Това действие е необратимо.",
"completed": "Завършено",
"config-file-not-mounted-desc": "Файлът config.yml не е монтиран. Промените в конфигурацията няма да се запазят след рестартиране на приложението.",
"config-file-not-mounted-title": "Конфигурационният файл не е монтиран!",
"config-file-not-writable-desc": "Файлът config.yml не е записваем. Промените в конфигурацията няма да се запазят след рестартиране на приложението.",
"config-file-not-writable-title": "Конфигурационният файл не е записваем!",
"config-tab": "Конфигурация",
"confirm-delete-mapping": "Потвърди?",
"continue-playing-as-grid": "Продължи игра като решетка",
"continue-playing-as-grid-desc": "Показвай кориците за продължаване на игра като решетка на началната страница",
"conversion": "Конвертиране",
"couldnt-fetch-missing-roms": "Неуспешно зареждане на липсващи ROM-ове: {error}",
"deferred": "Отложено",
"deleting-mapping": "Изтриване",
"disable-animations": "Изключи анимациите",
"disable-animations-desc": "Изключи всички анимации в галерията (въртящ се CD, зареждане на касета, видео при посочване)",
"edit-user": "Редактирай потребител",
"email": "Имейл",
"enable-3d-effect": "Активирай 3D ефект",
"enable-3d-effect-desc": "Активирай 3D ефект за кориците с игри, платформи и колекции",
"enable-experimental-cache": "Активирай експериментален кеш",
"enable-experimental-cache-desc": "Активирай експериментално кеширане на заявки за подобряване на производителността (може да причини проблеми)",
"excluded": "Изключено",
"excluded-multi-rom-files": "Файлове с множество ROM-ове",
"excluded-multi-rom-parts-extensions": "Разширения на части от ROM-ове с множество файлове",
"excluded-multi-rom-parts-files": "Файлове с части от множество ROM-ове",
"excluded-single-rom-extensions": "Разширения на единични ROM-ове",
"excluded-single-rom-files": "Файлове с единични ROM-ове",
"exclusion-placeholder": "напр. *.tmp или test_file.rom",
"exclusion-value": "Стойност на изключението",
"exclusions-item": "елемент",
"exclusions-items": "елемента",
"exclusions-multi-files-desc": "Имена на файлове за изключване при сканиране на ROM-ове с множество файлове",
"exclusions-multi-parts-ext-desc": "Файлови разширения за изключване при сканиране на многочастни ROM-ове",
"exclusions-multi-parts-files-desc": "Имена на файлове за изключване при сканиране на многочастни ROM-ове",
"exclusions-defaults": "Изключения по подразбиране",
"exclusions-none": "Няма конфигурирани потребителски изключения",
"exclusions-platforms-desc": "Платформи за изключване от сканирането",
"exclusions-single-ext-desc": "Файлови разширения за изключване при сканиране на единични ROM-ове",
"exclusions-single-files-desc": "Имена на файлове за изключване при сканиране на единични ROM-ове",
"exclusions-tooltip": "Конфигурирай кои файлове, разширения и платформи да се изключват при сканиране на библиотеката. Елементите добавени тук ще бъдат пропускани при сканиране.",
"failed": "Неуспешно",
"folder-alias": "Псевдоним на папка",
"folder-alias-description": "Свързва персонализирано име на папка с официална платформа в RomM. Например 'Nintendo-64' може да се свърже с 'n64'.",
"folder-mappings": "Свързвания на папки",
"folder-mappings-mutually-exclusive": "Забележка: папката не може да бъде едновременно псевдоним и вариант.",
"folder-mappings-tooltip-aliases": "Използвай това за да свържеш папка в библиотеката си с официална платформа в RomM. Например ако игрите ти за Nintendo 64 са в папка 'Nintendo-64', можеш да я свържеш с платформата 'n64'. Това става основният идентификатор на платформата в RomM.",
"folder-mappings-tooltip-variants": "Използвай това ако имаш отделна папка за вариант на платформа и искаш да използва метаданните на основната платформа. Например свързването на папка 'fbneo' като вариант на 'arcade' ще накара RomM да търси Arcade метаданни при сканиране на игри от 'fbneo'.",
"folder-name": "Име на папка",
"folder-name-header": "Име на папка",
"gallery": "Галерия",
"group-roms": "Групирай ROM-ове",
"group-roms-desc": "Групирай версиите на един и същи ROM заедно в галерията",
"home": "Начало",
"interface": "Интерфейс",
"invite-link": "Линк за покана",
"language": "Език",
"main-platform": "Основна платформа",
"manual": "Ръчно",
"missing-games-none": "Няма намерени липсващи ROM-ове",
"missing-games-tab": "Липсващи игри",
"missing-platform-from-fs": "Липсваща платформа от файловата система",
"no-missing-roms-to-delete": "Няма липсващи ROM-ове за изтриване",
"no-tasks-in-history": "Няма задачи в историята",
"parent-platform": "Родителска платформа",
"password": "Парола",
"password-placeholder": "Остави празно за да запазиш текущата парола",
"passwords-must-match": "Паролите трябва да съвпадат",
"platform-mapping-created": "Платформената връзка е създадена",
"platform-mapping-deleted": "Платформената връзка е изтрита",
"platform-mapping-updated": "Платформената връзка е обновена",
"platform-variant": "Вариант на платформа",
"platform-variant-description": "Свързва папка с метаданните на родителска платформа. Например свърже 'n64dd' като вариант на 'n64' за наследяване на N64 метаданни.",
"platform-version": "Версия на платформа",
"platforms-bindings": "Псевдоними на папки",
"platforms-drawer": "Меню с платформи",
"platforms-drawer-group-by": "Групирай по",
"platforms-versions": "Варианти на платформи",
"progress": "Прогрес",
"queued": "На опашка",
"recently-added-as-grid": "Наскоро добавени ROM-ове като решетка",
"recently-added-as-grid-desc": "Показвай наскоро добавените ROM-ове като решетка на началната страница",
"removed": "Премахнато",
"repeat-password": "Повтори паролата",
"repeat-password-required": "Повторението на паролата е задължително",
"role": "Роля",
"romm-platform": "RomM платформа",
"romm-platform-header": "RomM платформа",
"running": "Изпълнява се",
"scan": "Сканиране",
"scheduled": "Планирано",
"select-exclusion-type": "Избери какво да се изключи",
"show-actionbar": "Покажи лентата с действия",
"show-actionbar-desc": "Винаги показвай лентата с действия върху кориците на игрите",
"show-collections": "Покажи колекции",
"show-collections-as-grid": "Колекции като решетка",
"show-collections-as-grid-desc": "Показвай колекциите като решетка на началната страница",
"show-collections-desc": "Показвай секцията с колекции на началната страница",
"show-continue-playing": "Покажи продължаване на игра",
"show-continue-playing-desc": "Показвай секцията за продължаване на игра на началната страница",
"show-game-titles": "Покажи заглавията на игрите",
"show-game-titles-desc": "Винаги показвай заглавията на игрите върху кориците",
"show-languages": "Покажи езици",
"show-languages-desc": "Показвай флаговете на езиците в галерията",
"show-platforms": "Покажи платформи",
"show-platforms-as-grid": "Платформи като решетка",
"show-platforms-as-grid-desc": "Показвай платформите като решетка на началната страница",
"show-platforms-desc": "Показвай секцията с платформи на началната страница",
"show-recently-added": "Покажи наскоро добавените ROM-ове",
"show-recently-added-desc": "Показвай секцията с наскоро добавени ROM-ове на началната страница",
"show-regions": "Покажи региони",
"show-regions-desc": "Показвай флаговете на регионите в галерията",
"show-siblings": "Покажи версии (siblings)",
"show-siblings-desc": "Показвай броя на версиите (siblings) в галерията когато опцията \"Групирай ROM-ове\" е активирана",
"show-stats": "Покажи статистики",
"show-stats-desc": "Показвай обобщени статистики на началната страница",
"show-status": "Покажи статус",
"show-status-desc": "Показвай икони за статус в галерията (в списъка, играна, завършена и др.)",
"show-virtual-collections": "Покажи автоматично генерирани колекции",
"show-virtual-collections-desc": "Показва се на началната страница и в страничната лента с колекции.",
"stopped": "Спряно",
"task-history": "История на задачите",
"tasks": "Задачи",
"theme": "Тема",
"theme-auto": "Автоматична",
"theme-dark": "Тъмна",
"theme-light": "Светла",
"type-header": "Тип",
"unable-to-create-platform-mapping": "Грешка при създаване на платформена връзка: {detail}",
"unable-to-delete-platform-mapping": "Грешка при изтриване на платформена връзка: {detail}",
"unable-to-get-supported-platforms": "Грешка при зареждане на поддържани платформи: {detail}",
"unable-to-update-platform-mapping": "Грешка при обновяване на платформена връзка: {detail}",
"update": "Обнови",
"username": "Потребителско име",
"variant-folder": "Папка с варианти",
"virtual-collection-type": "Тип автоматично генерирана колекция (метод за групиране на ROM-ове)",
"watcher": "Наблюдател"
}
+28
View File
@@ -0,0 +1,28 @@
{
"admin-user-step": "Създай администраторски потребител",
"cancel": "Откажи",
"check-metadata-step": "Провери източници на метаданни",
"confirm-create-platforms": "RomM ще създаде Структура {structure} ({pattern}) с {count} платформ{plural}. Продължи?",
"confirm-no-platforms": "Не е открита структура на папки и не са избрани платформи. Ще трябва да създадеш структурата на папките ръчно. Продължи?",
"confirm-no-structure": "Не е открита структура на папки. RomM ще създаде Структура A (roms/{platform}) с {count} платформ{plural}. Продължи?",
"continue": "Продължи",
"deselect-all": "Отмени всички",
"detected-platforms": "Открити платформи",
"finish": "Завърши",
"folder-structure": "Структура на папките",
"game": "игра",
"games": "игри",
"library-structure-step": "Настрой структурата на библиотеката",
"metadata-missing": "Липсващ или невалиден API ключ",
"next": "Напред",
"no-structure-detected": "Не е открита структура - ще бъде създадена Структура A",
"platform": "платформа",
"platforms": "платформи",
"previous": "Назад",
"select-all": "Избери всички",
"select-platforms": "Избери платформи за създаване",
"selected": "избрани",
"structure-a-detected": "Открита е Структура A",
"structure-b-detected": "Открита е Структура B",
"supported-platforms": "Поддържани платформи"
}
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "Názvy souborů k vyloučení ze skenování vícesouborových ROM",
"exclusions-multi-parts-ext-desc": "Přípony souborů k vyloučení ze skenování vícedílných ROM",
"exclusions-multi-parts-files-desc": "Názvy souborů k vyloučení ze skenování vícedílných ROM",
"exclusions-none": "Nejsou nakonfigurovány žádné výjimky",
"exclusions-defaults": "Výchozí výjimky",
"exclusions-none": "Nejsou nakonfigurovány žádné vlastní výjimky",
"exclusions-platforms-desc": "Platformy k vyloučení ze skenování",
"exclusions-single-ext-desc": "Přípony souborů k vyloučení ze skenování jednoduchých ROM",
"exclusions-single-files-desc": "Názvy souborů k vyloučení ze skenování jednoduchých ROM",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "Dateinamen vom Scannen mehrteiliger ROMs ausschließen",
"exclusions-multi-parts-ext-desc": "Dateierweiterungen vom Scannen mehrteiliger ROM-Parts ausschließen",
"exclusions-multi-parts-files-desc": "Dateinamen vom Scannen mehrteiliger ROM-Parts ausschließen",
"exclusions-none": "Keine Ausschlüsse konfiguriert",
"exclusions-defaults": "Standardausschlüsse",
"exclusions-none": "Keine benutzerdefinierten Ausschlüsse konfiguriert",
"exclusions-platforms-desc": "Plattformen vom Scannen ausschließen",
"exclusions-single-ext-desc": "Dateierweiterungen vom Scannen einzelner ROMs ausschließen",
"exclusions-single-files-desc": "Dateinamen vom Scannen einzelner ROMs ausschließen",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "File names to exclude from multi-file ROM scanning",
"exclusions-multi-parts-ext-desc": "File extensions to exclude from multi-part ROM scanning",
"exclusions-multi-parts-files-desc": "File names to exclude from multi-part ROM scanning",
"exclusions-none": "No exclusions configured",
"exclusions-defaults": "Default exclusions",
"exclusions-none": "No custom exclusions configured",
"exclusions-platforms-desc": "Platforms to exclude from scanning",
"exclusions-single-ext-desc": "File extensions to exclude from single ROM scanning",
"exclusions-single-files-desc": "File names to exclude from single ROM scanning",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "File names to exclude from multi-file ROM scanning",
"exclusions-multi-parts-ext-desc": "File extensions to exclude from multi-part ROM scanning",
"exclusions-multi-parts-files-desc": "File names to exclude from multi-part ROM scanning",
"exclusions-none": "No exclusions configured",
"exclusions-defaults": "Default exclusions",
"exclusions-none": "No custom exclusions configured",
"exclusions-platforms-desc": "Platforms to exclude from scanning",
"exclusions-single-ext-desc": "File extensions to exclude from single ROM scanning",
"exclusions-single-files-desc": "File names to exclude from single ROM scanning",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "Nombres de archivo a excluir del escaneo de ROM multi-archivo",
"exclusions-multi-parts-ext-desc": "Extensiones de archivo a excluir del escaneo de ROM multi-parte",
"exclusions-multi-parts-files-desc": "Nombres de archivo a excluir del escaneo de ROM multi-parte",
"exclusions-none": "No hay exclusiones configuradas",
"exclusions-defaults": "Exclusiones predeterminadas",
"exclusions-none": "No hay exclusiones personalizadas configuradas",
"exclusions-platforms-desc": "Plataformas a excluir del escaneo",
"exclusions-single-ext-desc": "Extensiones de archivo a excluir del escaneo de ROM individuales",
"exclusions-single-files-desc": "Nombres de archivo a excluir del escaneo de ROM individuales",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "Noms de fichiers à exclure de l'analyse des ROM multi-fichiers",
"exclusions-multi-parts-ext-desc": "Extensions de fichiers à exclure de l'analyse des ROM multi-parties",
"exclusions-multi-parts-files-desc": "Noms de fichiers à exclure de l'analyse des ROM multi-parties",
"exclusions-none": "Aucune exclusion configurée",
"exclusions-defaults": "Exclusions par défaut",
"exclusions-none": "Aucune exclusion personnalisée configurée",
"exclusions-platforms-desc": "Plateformes à exclure de l'analyse",
"exclusions-single-ext-desc": "Extensions de fichiers à exclure de l'analyse des ROM simples",
"exclusions-single-files-desc": "Noms de fichiers à exclure de l'analyse des ROM simples",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "Fájlnevek kizárása a többfájlos ROM szkennelésből",
"exclusions-multi-parts-ext-desc": "Fájlkiterjesztések kizárása a többrészes ROM szkennelésből",
"exclusions-multi-parts-files-desc": "Fájlnevek kizárása a többrészes ROM szkennelésből",
"exclusions-none": "Nincs konfigurált kizárás",
"exclusions-defaults": "Alapértelmezett kizárások",
"exclusions-none": "Nincs konfigurált egyéni kizárás",
"exclusions-platforms-desc": "Platformok kizárása a szkennelésből",
"exclusions-single-ext-desc": "Fájlkiterjesztések kizárása az egyszeres ROM szkennelésből",
"exclusions-single-files-desc": "Fájlnevek kizárása az egyszeres ROM szkennelésből",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "Nomi di file da escludere dalla scansione ROM multi-file",
"exclusions-multi-parts-ext-desc": "Estensioni di file da escludere dalla scansione ROM multi-parte",
"exclusions-multi-parts-files-desc": "Nomi di file da escludere dalla scansione ROM multi-parte",
"exclusions-none": "Nessuna esclusione configurata",
"exclusions-defaults": "Esclusioni predefinite",
"exclusions-none": "Nessuna esclusione personalizzata configurata",
"exclusions-platforms-desc": "Piattaforme da escludere dalla scansione",
"exclusions-single-ext-desc": "Estensioni di file da escludere dalla scansione ROM singole",
"exclusions-single-files-desc": "Nomi di file da escludere dalla scansione ROM singole",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "マルチファイルROMスキャンから除外するファイル名",
"exclusions-multi-parts-ext-desc": "マルチパートROMスキャンから除外するファイル拡張子",
"exclusions-multi-parts-files-desc": "マルチパートROMスキャンから除外するファイル名",
"exclusions-none": "除外設定がありません",
"exclusions-defaults": "デフォルトの除外設定",
"exclusions-none": "カスタム除外設定がありません",
"exclusions-platforms-desc": "スキャンから除外するプラットフォーム",
"exclusions-single-ext-desc": "単一ROMスキャンから除外するファイル拡張子",
"exclusions-single-files-desc": "単一ROMスキャンから除外するファイル名",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "다중 파일 ROM 스캔에서 제외할 파일 이름",
"exclusions-multi-parts-ext-desc": "다중 파트 ROM 스캔에서 제외할 파일 확장자",
"exclusions-multi-parts-files-desc": "다중 파트 ROM 스캔에서 제외할 파일 이름",
"exclusions-none": "구성된 제외 항목 없음",
"exclusions-defaults": "기본 제외 항목",
"exclusions-none": "구성된 사용자 지정 제외 항목 없음",
"exclusions-platforms-desc": "스캔에서 제외할 플랫폼",
"exclusions-single-ext-desc": "단일 ROM 스캔에서 제외할 파일 확장자",
"exclusions-single-files-desc": "단일 ROM 스캔에서 제외할 파일 이름",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "Nazwy plików do wykluczenia ze skanowania wieloplikowych ROM",
"exclusions-multi-parts-ext-desc": "Rozszerzenia plików do wykluczenia ze skanowania wieloczęściowych ROM",
"exclusions-multi-parts-files-desc": "Nazwy plików do wykluczenia ze skanowania wieloczęściowych ROM",
"exclusions-none": "Nie skonfigurowano wykluczeń",
"exclusions-defaults": "Domyślne wykluczenia",
"exclusions-none": "Nie skonfigurowano własnych wykluczeń",
"exclusions-platforms-desc": "Platformy do wykluczenia ze skanowania",
"exclusions-single-ext-desc": "Rozszerzenia plików do wykluczenia ze skanowania pojedynczych ROM",
"exclusions-single-files-desc": "Nazwy plików do wykluczenia ze skanowania pojedynczych ROM",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "Nomes de arquivo a excluir da varredura de ROM multi-arquivo",
"exclusions-multi-parts-ext-desc": "Extensões de arquivo a excluir da varredura de ROM multi-parte",
"exclusions-multi-parts-files-desc": "Nomes de arquivo a excluir da varredura de ROM multi-parte",
"exclusions-none": "Nenhuma exclusão configurada",
"exclusions-defaults": "Exclusões padrão",
"exclusions-none": "Nenhuma exclusão personalizada configurada",
"exclusions-platforms-desc": "Plataformas a excluir da varredura",
"exclusions-single-ext-desc": "Extensões de arquivo a excluir da varredura de ROM individuais",
"exclusions-single-files-desc": "Nomes de arquivo a excluir da varredura de ROM individuais",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "Nume de fișiere de exclus din scanarea ROM-urilor multi-fișier",
"exclusions-multi-parts-ext-desc": "Extensii de fișiere de exclus din scanarea ROM-urilor multi-parte",
"exclusions-multi-parts-files-desc": "Nume de fișiere de exclus din scanarea ROM-urilor multi-parte",
"exclusions-none": "Nicio excludere configurată",
"exclusions-defaults": "Excluderi implicite",
"exclusions-none": "Nicio excludere personalizată configurată",
"exclusions-platforms-desc": "Platforme de exclus din scanare",
"exclusions-single-ext-desc": "Extensii de fișiere de exclus din scanarea ROM-urilor simple",
"exclusions-single-files-desc": "Nume de fișiere de exclus din scanarea ROM-urilor simple",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "Имена файлов для исключения из сканирования многофайловых ROM",
"exclusions-multi-parts-ext-desc": "Расширения файлов для исключения из сканирования многочастевых ROM",
"exclusions-multi-parts-files-desc": "Имена файлов для исключения из сканирования многочастевых ROM",
"exclusions-none": "Исключения не настроены",
"exclusions-defaults": "Исключения по умолчанию",
"exclusions-none": "Пользовательские исключения не настроены",
"exclusions-platforms-desc": "Платформы для исключения из сканирования",
"exclusions-single-ext-desc": "Расширения файлов для исключения из сканирования одиночных ROM",
"exclusions-single-files-desc": "Имена файлов для исключения из сканирования одиночных ROM",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "要从多文件 ROM 扫描中排除的文件名",
"exclusions-multi-parts-ext-desc": "要从多部分 ROM 扫描中排除的文件扩展名",
"exclusions-multi-parts-files-desc": "要从多部分 ROM 扫描中排除的文件名",
"exclusions-none": "未配置排除项",
"exclusions-defaults": "默认排除项",
"exclusions-none": "未配置自定义排除项",
"exclusions-platforms-desc": "要从扫描中排除的平台",
"exclusions-single-ext-desc": "要从单个 ROM 扫描中排除的文件扩展名",
"exclusions-single-files-desc": "要从单个 ROM 扫描中排除的文件名",
+2 -1
View File
@@ -67,7 +67,8 @@
"exclusions-multi-files-desc": "要從多檔案 ROM 掃描中排除的檔案名稱",
"exclusions-multi-parts-ext-desc": "要從多部分 ROM 掃描中排除的副檔名",
"exclusions-multi-parts-files-desc": "要從多部分 ROM 掃描中排除的檔案名稱",
"exclusions-none": "未配置排除項",
"exclusions-defaults": "預設排除項",
"exclusions-none": "未配置自訂排除項",
"exclusions-platforms-desc": "要從掃描中排除的平台",
"exclusions-single-ext-desc": "要從單個 ROM 掃描中排除的副檔名",
"exclusions-single-files-desc": "要從單個 ROM 掃描中排除的檔案名稱",
+7 -1
View File
@@ -37,6 +37,7 @@ export const ROUTES = {
ADMINISTRATION: "administration",
SERVER_STATS: "server-stats",
PAIR: "pair",
APRIL_FOOLS: "april-fools",
NOT_FOUND: "404",
CONSOLE_HOME: "console-home",
CONSOLE_PLATFORM: "console-platform",
@@ -183,6 +184,11 @@ const routes = [
name: ROUTES.RUFFLE,
component: () => import("@/views/Player/RuffleRS/Base.vue"),
},
{
path: "april-fools",
name: ROUTES.APRIL_FOOLS,
component: () => import("@/views/Player/AprilFools.vue"),
},
{
path: "scan",
name: ROUTES.SCAN,
@@ -381,7 +387,7 @@ router.beforeEach(async (to, _from, next) => {
return next({
name: ROUTES.LOGIN,
query: {
next: to.query.next ?? (to.path !== "/login" ? to.path : "/"),
next: to.query.next ?? to.fullPath,
},
});
}
+4 -8
View File
@@ -3,8 +3,7 @@ import { useLocalStorage } from "@vueuse/core";
import { createVuetify } from "vuetify";
import { VDateInput } from "vuetify/labs/VDateInput";
import "vuetify/styles";
import { themes, dark, light, autoThemeKey } from "@/styles/themes";
import { isKeyof } from "@/types";
import { dark, light } from "@/styles/themes";
const mediaMatch = window.matchMedia("(prefers-color-scheme: dark)");
mediaMatch.addEventListener("change", (event) => {
@@ -12,13 +11,10 @@ mediaMatch.addEventListener("change", (event) => {
});
function getTheme() {
const storedTheme = useLocalStorage("settings.theme", autoThemeKey);
const storedTheme = useLocalStorage("settings.theme", "auto");
if (
storedTheme.value !== autoThemeKey &&
isKeyof(storedTheme.value, themes)
) {
return themes[storedTheme.value];
if (storedTheme.value === "dark" || storedTheme.value === "light") {
return storedTheme.value;
}
return mediaMatch.matches ? "dark" : "light";
+4 -2
View File
@@ -70,12 +70,14 @@ api.interceptors.response.use(
await refetchCSRFToken();
const pathname = window.location.pathname;
const params = new URLSearchParams(window.location.search);
const search = window.location.search;
const params = new URLSearchParams(search);
const fullPath = pathname + search;
router.push({
name: ROUTES.LOGIN,
query: {
next: params.get("next") ?? (pathname !== "/login" ? pathname : "/"),
next: params.get("next") ?? (pathname !== "/login" ? fullPath : "/"),
},
});
}
+13 -8
View File
@@ -5,7 +5,7 @@ import type {
BulkOperationResponse,
DetailedRomSchema,
ManualMetadata,
RomUserUpdatePayload,
RomUserData,
RomUserSchema,
SearchRomSchema,
SimpleRomSchema,
@@ -545,16 +545,21 @@ async function updateUserRomProps({
removeLastPlayed = false,
}: {
romId: number;
data: Partial<RomUserSchema>;
data: Partial<RomUserData>;
updateLastPlayed?: boolean;
removeLastPlayed?: boolean;
}) {
const payload: RomUserUpdatePayload = {
data: data,
update_last_played: updateLastPlayed,
remove_last_played: removeLastPlayed,
};
return api.put<RomUserSchema>(`/roms/${romId}/props`, payload);
const params = new URLSearchParams();
if (updateLastPlayed) {
params.set("update_last_played", "true");
} else if (removeLastPlayed) {
params.set("remove_last_played", "true");
}
const query = params.toString();
return api.put<RomUserSchema>(
`/roms/${romId}/props${query ? `?${query}` : ""}`,
data,
);
}
async function deleteRoms({
+3
View File
@@ -20,6 +20,9 @@ const defaultConfig = {
EXCLUDED_MULTI_FILES: [],
EXCLUDED_MULTI_PARTS_EXT: [],
EXCLUDED_MULTI_PARTS_FILES: [],
DEFAULT_EXCLUDED_DIRS: [],
DEFAULT_EXCLUDED_FILES: [],
DEFAULT_EXCLUDED_EXTENSIONS: [],
PLATFORMS_BINDING: {},
PLATFORMS_VERSIONS: {},
SKIP_HASH_CALCULATION: false,
+1
View File
@@ -20,6 +20,7 @@ const defaultLanguageState = {
{ value: "pl_PL", name: "Polski" },
{ value: "cs_CZ", name: "Česky" },
{ value: "hu_HU", name: "Magyar" },
{ value: "bg_BG", name: "Български" },
].sort((a, b) => a.name.localeCompare(b.name)),
};
+65
View File
@@ -0,0 +1,65 @@
<template>
<v-row class="align-center justify-center h-100 af-backdrop" no-gutters>
<v-col cols="auto" class="text-center">
<div class="af-reveal">
<img class="af-img" src="/assets/default/af.gif" alt="April Fools!" />
</div>
<v-btn
class="mt-4 af-btn"
block
variant="outlined"
size="large"
prepend-icon="mdi-arrow-left"
@click="$router.back()"
>
Go back
</v-btn>
</v-col>
</v-row>
</template>
<style scoped>
.af-backdrop {
animation: fade-in 0.6s ease-out;
}
.af-reveal {
overflow: hidden;
animation: clip-reveal 0.8s cubic-bezier(0.4, 0, 0.2, 1) 0.3s both;
}
.af-img {
animation: zoom-settle 0.8s cubic-bezier(0.4, 0, 0.2, 1) 0.3s both;
}
.af-btn {
animation: fade-in 0.4s ease-out 1s both;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes clip-reveal {
from {
clip-path: circle(0% at 50% 50%);
}
to {
clip-path: circle(150% at 50% 50%);
}
}
@keyframes zoom-settle {
from {
transform: scale(1.3);
}
to {
transform: scale(1);
}
}
</style>
@@ -249,7 +249,12 @@ onMounted(async () => {
)
: undefined;
selectedFirmware.value = biosFromStorage ?? biosFromConfig ?? null;
// Auto-select firmware if only one option is available
const biosFromSingleOption =
firmwareOptions.value.length === 1 ? firmwareOptions.value[0] : undefined;
selectedFirmware.value =
biosFromStorage ?? biosFromConfig ?? biosFromSingleOption ?? null;
});
onBeforeUnmount(async () => {

Some files were not shown because too many files have changed in this diff Show More