mirror of
https://github.com/rommapp/romm.git
synced 2026-04-23 06:54:40 +00:00
Merge branch 'master' into save-sync
This commit is contained in:
@@ -3,6 +3,9 @@ name: Copilot Setup Steps
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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", {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class RegionBreakdownItem(TypedDict):
|
||||
count: int
|
||||
|
||||
|
||||
class StatsReturn(TypedDict):
|
||||
class StatsReturn(TypedDict, total=False):
|
||||
PLATFORMS: int
|
||||
ROMS: int
|
||||
SAVES: int
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
min-release-age=7
|
||||
ignore-scripts=true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Generated
+98
-50
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+1
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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": "Търси колекция"
|
||||
}
|
||||
@@ -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": "ВНИМАНИЕ:"
|
||||
}
|
||||
@@ -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": "Търси игри за всички платформи"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"all-loaded": "Всички игри са заредени",
|
||||
"load-more": "Зареди още"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"continue-playing": "Продължи играта",
|
||||
"recently-added": "Наскоро добавени"
|
||||
}
|
||||
@@ -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": "Потребителско име"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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-ове"
|
||||
}
|
||||
@@ -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": "Избери бърз запис"
|
||||
}
|
||||
@@ -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": "Обновено"
|
||||
}
|
||||
@@ -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": "Обнови метаданните на разпознатите игри"
|
||||
}
|
||||
@@ -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": "Наблюдател"
|
||||
}
|
||||
@@ -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": "Поддържани платформи"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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スキャンから除外するファイル名",
|
||||
|
||||
@@ -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 스캔에서 제외할 파일 이름",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 扫描中排除的文件名",
|
||||
|
||||
@@ -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 掃描中排除的檔案名稱",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 : "/"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user