diff --git a/backend/endpoints/firmware.py b/backend/endpoints/firmware.py index 7bc9bd6a77..2712044d8d 100644 --- a/backend/endpoints/firmware.py +++ b/backend/endpoints/firmware.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import Body, File, HTTPException, Request, UploadFile, status +from fastapi import Body, File, HTTPException, Query, Request, UploadFile, status from fastapi.responses import FileResponse from config import DISABLE_DOWNLOAD_ENDPOINT_AUTH @@ -124,6 +124,10 @@ async def add_firmware( def get_platform_firmware( request: Request, platform_id: int | None = None, + missing: Annotated[ + bool | None, + Query(description="Whether the firmware is missing from the filesystem."), + ] = None, ) -> list[FirmwareSchema]: """Get firmware endpoint @@ -138,6 +142,7 @@ def get_platform_firmware( FirmwareSchema.model_validate(f) for f in db_firmware_handler.list_firmware( platform_id=platform_id, + missing=missing, hidden_platform_ids=perms.hidden_platform_ids, ) ] diff --git a/backend/endpoints/responses/firmware.py b/backend/endpoints/responses/firmware.py index 0c0711c9ee..728a580b6a 100644 --- a/backend/endpoints/responses/firmware.py +++ b/backend/endpoints/responses/firmware.py @@ -9,6 +9,7 @@ class FirmwareSchema(BaseModel): model_config = ConfigDict(from_attributes=True) id: int + platform_id: int file_name: str file_name_no_tags: str diff --git a/backend/endpoints/responses/platform.py b/backend/endpoints/responses/platform.py index 39a6656574..44cd42326a 100644 --- a/backend/endpoints/responses/platform.py +++ b/backend/endpoints/responses/platform.py @@ -46,10 +46,12 @@ class PlatformSchema(BaseModel): def display_name(self) -> str: return self.custom_name or self.name + # Missing entries stay in `firmware` so they can be cleaned up, but they + # aren't usable BIOS, so they don't count. @computed_field # type: ignore @property def firmware_count(self) -> int: - return len(self.firmware) + return len([f for f in self.firmware if not f.missing_from_fs]) @field_validator("firmware") def sort_files(cls, v: list[FirmwareSchema]) -> list[FirmwareSchema]: diff --git a/backend/endpoints/tasks.py b/backend/endpoints/tasks.py index d007dc3a9e..038c5785a6 100644 --- a/backend/endpoints/tasks.py +++ b/backend/endpoints/tasks.py @@ -33,6 +33,7 @@ low_prio_queue, redis_client, ) +from tasks.manual.cleanup_missing_firmware import cleanup_missing_firmware_task from tasks.manual.cleanup_missing_roms import cleanup_missing_roms_task from tasks.manual.recompute_save_content_hashes import ( recompute_save_content_hashes_task, @@ -119,6 +120,13 @@ class ManualTask(ScheduledTask): "task": cleanup_missing_roms_task, } ), + ManualTask( + { + "name": "cleanup_missing_firmware", + "type": TaskType.CLEANUP, + "task": cleanup_missing_firmware_task, + } + ), ManualTask( { "name": "sync_folder_scan", diff --git a/backend/handler/database/firmware_handler.py b/backend/handler/database/firmware_handler.py index 8ce8a53de0..20fa3f35fb 100644 --- a/backend/handler/database/firmware_handler.py +++ b/backend/handler/database/firmware_handler.py @@ -32,6 +32,7 @@ def list_firmware( self, *, platform_id: int | None = None, + missing: bool | None = None, only_fields: Sequence[QueryableAttribute] | None = None, hidden_platform_ids: Sequence[int] | None = None, session: Session = None, # type: ignore @@ -41,6 +42,9 @@ def list_firmware( if platform_id: query = query.filter_by(platform_id=platform_id) + if missing is not None: + query = query.filter(Firmware.missing_from_fs == missing) + # Firmware inherits its platform's visibility: hide firmware whose # platform an admin has hidden from the caller. if hidden_platform_ids: diff --git a/backend/tasks/manual/cleanup_missing_firmware.py b/backend/tasks/manual/cleanup_missing_firmware.py new file mode 100644 index 0000000000..1760112cf1 --- /dev/null +++ b/backend/tasks/manual/cleanup_missing_firmware.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass + +from handler.database import db_firmware_handler +from logger.logger import log +from tasks.tasks import Task, TaskType, update_job_meta +from utils.context import initialize_context + + +@dataclass +class CleanupMissingFirmwareStats: + """Statistics for missing firmware cleanup operations.""" + + platform_id: int | None = None + firmware_found: int = 0 + firmware_deleted: int = 0 + errors: int = 0 + + def update(self, **kwargs) -> None: + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + update_job_meta({"cleanup_stats": self.to_dict()}) + + def to_dict(self) -> dict: + return { + "platform_id": self.platform_id, + "firmware_found": self.firmware_found, + "firmware_deleted": self.firmware_deleted, + "errors": self.errors, + } + + +class CleanupMissingFirmwareTask(Task): + def __init__(self): + super().__init__( + title="Cleanup missing firmware", + description="Delete all firmware flagged as missing from the filesystem from the database", + task_type=TaskType.CLEANUP, + enabled=True, + manual_run=True, + cron_string=None, + ) + + @initialize_context() + async def run(self, platform_id: int | None = None) -> dict: + """Clean up firmware that is flagged as missing from the filesystem.""" + log.info(f"Starting {self.title} task...") + + stats = CleanupMissingFirmwareStats(platform_id=platform_id) + + missing_firmware = db_firmware_handler.list_firmware( + platform_id=platform_id, missing=True + ) + + stats.update(firmware_found=len(missing_firmware)) + log.info( + f"Found {len(missing_firmware)} missing firmware file(s) to clean up" + + (f" for platform ID {platform_id}" if platform_id else "") + ) + + # The row is stale because the file is already gone, so there is + # nothing to remove from disk here. + for firmware in missing_firmware: + try: + log.info( + f"Deleting missing firmware '{firmware.file_name}' [ID: {firmware.id}] from database" + ) + db_firmware_handler.delete_firmware(firmware.id) + except Exception as e: + log.error(f"Failed to delete missing firmware {firmware.id}: {e}") + stats.update(errors=stats.errors + 1) + continue + + stats.update(firmware_deleted=stats.firmware_deleted + 1) + + log.info( + f"Cleanup of missing firmware completed: {stats.firmware_deleted} deleted, {stats.errors} error(s)" + ) + return stats.to_dict() + + +cleanup_missing_firmware_task = CleanupMissingFirmwareTask() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ced230b688..1a092d4138 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -14,6 +14,7 @@ from handler.auth import auth_handler from handler.auth.base_handler import ALGORITHM, oct_key from handler.database import ( + db_firmware_handler, db_permission_handler, db_platform_handler, db_rom_handler, @@ -26,6 +27,7 @@ from models.client_token import ClientToken from models.device import Device from models.device_save_sync import DeviceSaveSync +from models.firmware import Firmware from models.platform import Platform from models.play_session import PlaySession from models.rom import Rom, RomFile @@ -100,6 +102,7 @@ def clear_database(): s.query(Screenshot).delete(synchronize_session="evaluate") s.query(RomFile).delete(synchronize_session="evaluate") s.query(Rom).delete(synchronize_session="evaluate") + s.query(Firmware).delete(synchronize_session="evaluate") s.query(Platform).delete(synchronize_session="evaluate") s.query(User).delete(synchronize_session="evaluate") @@ -124,6 +127,35 @@ def platform(): return db_platform_handler.add_platform(platform) +def _build_firmware(platform: Platform, file_name: str, missing: bool) -> Firmware: + return Firmware( + platform_id=platform.id, + file_name=file_name, + file_path=f"{platform.fs_slug}/bios", + file_size_bytes=1024, + crc_hash="crc", + md5_hash="md5", + sha1_hash="sha1", + missing_from_fs=missing, + ) + + +@pytest.fixture +def firmware(platform: Platform): + """Firmware whose file is still on disk.""" + return db_firmware_handler.add_firmware( + _build_firmware(platform, "present.bin", missing=False) + ) + + +@pytest.fixture +def missing_firmware(platform: Platform): + """Firmware flagged by a scan as gone from the filesystem.""" + return db_firmware_handler.add_firmware( + _build_firmware(platform, "gone.bin", missing=True) + ) + + @pytest.fixture def rom(admin_user: User, platform: Platform): rom = Rom( diff --git a/backend/tests/endpoints/test_firmware.py b/backend/tests/endpoints/test_firmware.py new file mode 100644 index 0000000000..b117ba1185 --- /dev/null +++ b/backend/tests/endpoints/test_firmware.py @@ -0,0 +1,78 @@ +"""Tests for `GET /api/firmware`. + +Issue #4075: the endpoint returned every row with no way to select on +`missing_from_fs`, so the player's BIOS list offered (and auto-selected) +firmware whose file was gone, and nothing could list missing firmware +library-wide. +""" + +from fastapi import status + + +def test_get_firmware_requires_auth(client, firmware): + response = client.get("/api/firmware") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_get_firmware_returns_everything_by_default( + client, access_token, firmware, missing_firmware +): + response = client.get( + "/api/firmware", headers={"Authorization": f"Bearer {access_token}"} + ) + assert response.status_code == status.HTTP_200_OK + + names = [f["file_name"] for f in response.json()] + assert sorted(names) == ["gone.bin", "present.bin"] + + +def test_get_firmware_missing_true_returns_only_missing( + client, access_token, firmware, missing_firmware +): + response = client.get( + "/api/firmware", + params={"missing": "true"}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert [f["file_name"] for f in body] == ["gone.bin"] + assert body[0]["missing_from_fs"] is True + + +def test_get_firmware_missing_false_excludes_missing( + client, access_token, firmware, missing_firmware +): + response = client.get( + "/api/firmware", + params={"missing": "false"}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert [f["file_name"] for f in body] == ["present.bin"] + assert body[0]["missing_from_fs"] is False + + +def test_get_firmware_exposes_its_platform(client, access_token, platform, firmware): + """The library-wide missing view groups by platform, so the row has to + carry one without a second round trip per entry.""" + response = client.get( + "/api/firmware", headers={"Authorization": f"Bearer {access_token}"} + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()[0]["platform_id"] == platform.id + + +def test_get_firmware_missing_filter_stacks_with_platform_id( + client, access_token, platform, firmware, missing_firmware +): + response = client.get( + "/api/firmware", + params={"platform_id": platform.id, "missing": "true"}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert [f["file_name"] for f in response.json()] == ["gone.bin"] diff --git a/backend/tests/endpoints/test_platform.py b/backend/tests/endpoints/test_platform.py index e590cb6457..12a858c44a 100644 --- a/backend/tests/endpoints/test_platform.py +++ b/backend/tests/endpoints/test_platform.py @@ -176,3 +176,21 @@ def test_update_platform_description_requires_write_scope(client, platform): json={"description": "Nope"}, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_firmware_count_excludes_missing_firmware( + client, access_token, platform, firmware, missing_firmware +): + """A platform whose only BIOS file was deleted from disk shouldn't still + advertise it (issue #4075).""" + response = client.get( + f"/api/platforms/{platform.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert body["firmware_count"] == 1 + # The rows themselves still ship so the Firmware tab can strike them + # through and offer a cleanup. + assert len(body["firmware"]) == 2 diff --git a/backend/tests/endpoints/test_tasks.py b/backend/tests/endpoints/test_tasks.py index b81e6f52fe..aebf205d2e 100644 --- a/backend/tests/endpoints/test_tasks.py +++ b/backend/tests/endpoints/test_tasks.py @@ -183,6 +183,19 @@ def test_list_tasks_empty(self, client, access_token): assert data["watcher"][0]["enabled"] is False assert "10 minute delay" in data["watcher"][0]["description"] + def test_missing_firmware_cleanup_is_registered(self, client, access_token): + """Unpatched registry: the Missing tab runs this task by name, so a + missing registration is a 404 at the point of use (issue #4075).""" + response = client.get( + "/api/tasks", headers={"Authorization": f"Bearer {access_token}"} + ) + + assert response.status_code == status.HTTP_200_OK + manual = {t["name"]: t for t in response.json()["manual"]} + assert "cleanup_missing_firmware" in manual + assert manual["cleanup_missing_firmware"]["manual_run"] is True + assert manual["cleanup_missing_firmware"]["type"] == TaskType.CLEANUP.value + def test_list_tasks_unauthorized(self, client): """Test that unauthorized requests are rejected""" response = client.get("/api/tasks") diff --git a/backend/tests/handler/database/test_firmware_handler.py b/backend/tests/handler/database/test_firmware_handler.py new file mode 100644 index 0000000000..6cf4e048e2 --- /dev/null +++ b/backend/tests/handler/database/test_firmware_handler.py @@ -0,0 +1,74 @@ +"""Tests for the firmware list filters. + +A scan flags firmware whose file vanished with `missing_from_fs`, but until +issue #4075 nothing could select on that flag, so every consumer (the player's +BIOS list, the platform firmware count) had to take the whole set. +""" + +from handler.database import db_firmware_handler, db_platform_handler +from models.firmware import Firmware +from models.platform import Platform + + +def _add(platform: Platform, file_name: str, missing: bool) -> Firmware: + return db_firmware_handler.add_firmware( + Firmware( + platform_id=platform.id, + file_name=file_name, + file_path=f"{platform.fs_slug}/bios", + file_size_bytes=1, + crc_hash="crc", + md5_hash="md5", + sha1_hash="sha1", + missing_from_fs=missing, + ) + ) + + +def _other_platform() -> Platform: + return db_platform_handler.add_platform( + Platform(name="other", slug="other_slug", fs_slug="other_slug") + ) + + +class TestListFirmwareMissingFilter: + def test_lists_everything_without_the_filter(self, platform): + _add(platform, "present.bin", missing=False) + _add(platform, "gone.bin", missing=True) + + names = [f.file_name for f in db_firmware_handler.list_firmware()] + assert names == ["gone.bin", "present.bin"] + + def test_missing_true_returns_only_flagged_firmware(self, platform): + _add(platform, "present.bin", missing=False) + _add(platform, "gone.bin", missing=True) + + firmware = db_firmware_handler.list_firmware(missing=True) + assert [f.file_name for f in firmware] == ["gone.bin"] + + def test_missing_false_excludes_flagged_firmware(self, platform): + _add(platform, "present.bin", missing=False) + _add(platform, "gone.bin", missing=True) + + firmware = db_firmware_handler.list_firmware(missing=False) + assert [f.file_name for f in firmware] == ["present.bin"] + + def test_combines_with_the_platform_filter(self, platform): + other = _other_platform() + _add(platform, "gone.bin", missing=True) + _add(other, "other-gone.bin", missing=True) + + firmware = db_firmware_handler.list_firmware( + platform_id=platform.id, missing=True + ) + assert [f.file_name for f in firmware] == ["gone.bin"] + + def test_combines_with_the_hidden_platform_filter(self, platform): + other = _other_platform() + _add(platform, "gone.bin", missing=True) + _add(other, "other-gone.bin", missing=True) + + firmware = db_firmware_handler.list_firmware( + missing=True, hidden_platform_ids=[other.id] + ) + assert [f.file_name for f in firmware] == ["gone.bin"] diff --git a/backend/tests/tasks/test_cleanup_missing_firmware.py b/backend/tests/tasks/test_cleanup_missing_firmware.py new file mode 100644 index 0000000000..1ba5392e7d --- /dev/null +++ b/backend/tests/tasks/test_cleanup_missing_firmware.py @@ -0,0 +1,102 @@ +"""Tests for CleanupMissingFirmwareTask. + +The ROM side has had a bulk cleanup for missing rows since forever; firmware +flagged by `mark_missing_firmware` had no counterpart, so stale BIOS entries +could only be removed one platform tab at a time (issue #4075). +""" + +import pytest + +from handler.database import db_firmware_handler, db_platform_handler +from models.firmware import Firmware +from models.platform import Platform +from tasks.manual.cleanup_missing_firmware import ( + CleanupMissingFirmwareTask, + cleanup_missing_firmware_task, +) + + +def _add(platform: Platform, file_name: str, missing: bool) -> Firmware: + return db_firmware_handler.add_firmware( + Firmware( + platform_id=platform.id, + file_name=file_name, + file_path=f"{platform.fs_slug}/bios", + file_size_bytes=1, + crc_hash="crc", + md5_hash="md5", + sha1_hash="sha1", + missing_from_fs=missing, + ) + ) + + +@pytest.fixture +def other_platform() -> Platform: + return db_platform_handler.add_platform( + Platform(name="other", slug="other_slug", fs_slug="other_slug") + ) + + +class TestCleanupMissingFirmwareTask: + @pytest.fixture + def task(self) -> CleanupMissingFirmwareTask: + return CleanupMissingFirmwareTask() + + def test_module_singleton_exists(self): + assert isinstance(cleanup_missing_firmware_task, CleanupMissingFirmwareTask) + + def test_configuration(self, task): + assert task.enabled is True + assert task.manual_run is True + assert task.can_run_manually is True + assert task.cron_string is None + + async def test_deletes_only_missing_firmware(self, task, platform): + present = _add(platform, "present.bin", missing=False) + gone = _add(platform, "gone.bin", missing=True) + + stats = await task.run() + + assert stats["firmware_found"] == 1 + assert stats["firmware_deleted"] == 1 + assert stats["errors"] == 0 + assert db_firmware_handler.get_firmware(gone.id) is None + assert db_firmware_handler.get_firmware(present.id) is not None + + async def test_scopes_to_a_single_platform(self, task, platform, other_platform): + mine = _add(platform, "gone.bin", missing=True) + theirs = _add(other_platform, "other-gone.bin", missing=True) + + stats = await task.run(platform_id=platform.id) + + assert stats["platform_id"] == platform.id + assert stats["firmware_deleted"] == 1 + assert db_firmware_handler.get_firmware(mine.id) is None + assert db_firmware_handler.get_firmware(theirs.id) is not None + + async def test_counts_delete_failures(self, task, platform, mocker): + _add(platform, "gone-a.bin", missing=True) + _add(platform, "gone-b.bin", missing=True) + mocker.patch( + "tasks.manual.cleanup_missing_firmware.db_firmware_handler.delete_firmware", + side_effect=RuntimeError("boom"), + ) + + stats = await task.run() + + assert stats["firmware_found"] == 2 + assert stats["firmware_deleted"] == 0 + assert stats["errors"] == 2 + + async def test_no_missing_firmware_is_a_no_op(self, task, platform): + _add(platform, "present.bin", missing=False) + + stats = await task.run() + + assert stats == { + "platform_id": None, + "firmware_found": 0, + "firmware_deleted": 0, + "errors": 0, + } diff --git a/frontend/src/__generated__/models/FirmwareSchema.ts b/frontend/src/__generated__/models/FirmwareSchema.ts index 4b8b701c4b..d794d70535 100644 --- a/frontend/src/__generated__/models/FirmwareSchema.ts +++ b/frontend/src/__generated__/models/FirmwareSchema.ts @@ -4,6 +4,7 @@ /* eslint-disable */ export type FirmwareSchema = { id: number; + platform_id: number; file_name: string; file_name_no_tags: string; file_name_no_ext: string; diff --git a/frontend/src/locales/bg_BG/settings.json b/frontend/src/locales/bg_BG/settings.json index b7fd0bfd74..a52b7aed91 100644 --- a/frontend/src/locales/bg_BG/settings.json +++ b/frontend/src/locales/bg_BG/settings.json @@ -30,6 +30,8 @@ "cleanup": "Почистване", "cleanup-all": "Почисти всичко", "cleanup-all-confirm": "Това ще изтрие окончателно всички липсващи ROM-ове{platform} от базата данни и техните директории. Това действие е необратимо.", + "cleanup-firmware-confirm": "Това ще изтрие окончателно всеки запис за фърмуер{platform}, чийто файл липсва от файловата система. Нищо няма да бъде премахнато от диска.", + "cleanup-firmware-queued": "Задачата за почистване е на опашка, липсващият фърмуер ще бъде изтрит скоро", "cleanup-queued": "Задачата за почистване е на опашка — липсващите ROM-ове ще бъдат изтрити скоро", "client-api-tokens": "API токени", "client-token-confirm-delete": "Сигурен ли си, че искаш да отмениш този токен? Всички устройства които го използват ще загубят достъп.", @@ -78,6 +80,7 @@ "copy-link": "Копирай линка", "copy-token": "Копирай токена", "copy-token-title": "Копиране на токен", + "couldnt-fetch-missing-firmware": "Неуспешно зареждане на липсващ фърмуер: {error}", "couldnt-fetch-missing-roms": "Неуспешно зареждане на липсващи ROM-ове: {error}", "couldnt-queue-cleanup": "Задачата за почистване не можа да бъде поставена на опашка: {error}", "create-new-api-token": "Създай нов API токен", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Време за завършване", "metadata-subtitle-cover-art": "Корици", "metadata-website": "Уебсайт", + "missing-firmware-actions": "Действия за липсващ фърмуер", + "missing-firmware-none": "Няма намерен липсващ фърмуер", + "missing-firmware-tab": "Липсващ фърмуер", "missing-games-actions": "Действия за липсващи игри", "missing-games-none": "Няма намерени липсващи ROM-ове", "missing-games-tab": "Липсващи игри", diff --git a/frontend/src/locales/cs_CZ/settings.json b/frontend/src/locales/cs_CZ/settings.json index a494853038..7b331dae00 100644 --- a/frontend/src/locales/cs_CZ/settings.json +++ b/frontend/src/locales/cs_CZ/settings.json @@ -30,6 +30,8 @@ "cleanup": "Vyčištění", "cleanup-all": "Vyčistit vše", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "Tímto se z databáze trvale odstraní každý záznam firmwaru{platform}, jehož soubor v souborovém systému chybí. Na disku se nic nesmaže.", + "cleanup-firmware-queued": "Úloha vyčištění zařazena do fronty, chybějící firmware bude brzy smazán", "cleanup-queued": "Úloha vyčištění zařazena do fronty — chybějící ROMy budou brzy smazány", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Kopírovat odkaz", "copy-token": "Kopírovat token", "copy-token-title": "Kopírovat token", + "couldnt-fetch-missing-firmware": "Nelze načíst chybějící firmware: {error}", "couldnt-fetch-missing-roms": "Nelze načíst chybějící ROM: {error}", "couldnt-queue-cleanup": "Nepodařilo se zařadit úlohu vyčištění: {error}", "create-new-api-token": "Vytvořit nový API token", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Časy dokončení", "metadata-subtitle-cover-art": "Obaly", "metadata-website": "Web", + "missing-firmware-actions": "Akce pro chybějící firmware", + "missing-firmware-none": "Nebyl nalezen žádný chybějící firmware", + "missing-firmware-tab": "Chybějící firmware", "missing-games-actions": "Akce pro chybějící hry", "missing-games-none": "Nebyly nalezeny žádné chybějící ROM", "missing-games-tab": "Chybějící hry", diff --git a/frontend/src/locales/de_DE/settings.json b/frontend/src/locales/de_DE/settings.json index 21172fa166..2b5cfac237 100644 --- a/frontend/src/locales/de_DE/settings.json +++ b/frontend/src/locales/de_DE/settings.json @@ -30,6 +30,8 @@ "cleanup": "Bereinigung", "cleanup-all": "Alles bereinigen", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "Dadurch wird jeder Firmware-Eintrag{platform}, dessen Datei im Dateisystem fehlt, dauerhaft aus der Datenbank gelöscht. Auf der Festplatte wird nichts entfernt.", + "cleanup-firmware-queued": "Bereinigungsaufgabe in Warteschlange, fehlende Firmware wird in Kürze gelöscht", "cleanup-queued": "Bereinigungsaufgabe in Warteschlange — fehlende ROMs werden in Kürze gelöscht", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Link kopieren", "copy-token": "Token kopieren", "copy-token-title": "Token kopieren", + "couldnt-fetch-missing-firmware": "Fehlende Firmware konnte nicht abgerufen werden: {error}", "couldnt-fetch-missing-roms": "Fehlende ROMs konnten nicht abgerufen werden: {error}", "couldnt-queue-cleanup": "Bereinigungsaufgabe konnte nicht in die Warteschlange gestellt werden: {error}", "create-new-api-token": "Neues API-Token erstellen", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Abschlusszeiten", "metadata-subtitle-cover-art": "Coverbild", "metadata-website": "Website", + "missing-firmware-actions": "Aktionen für fehlende Firmware", + "missing-firmware-none": "Keine fehlende Firmware gefunden", + "missing-firmware-tab": "Fehlende Firmware", "missing-games-actions": "Aktionen für fehlende Spiele", "missing-games-none": "Keine fehlenden ROMs gefunden", "missing-games-tab": "Fehlende Spiele", diff --git a/frontend/src/locales/en_GB/settings.json b/frontend/src/locales/en_GB/settings.json index 6ca0347cd4..faa381805f 100644 --- a/frontend/src/locales/en_GB/settings.json +++ b/frontend/src/locales/en_GB/settings.json @@ -30,6 +30,8 @@ "cleanup": "Cleanup", "cleanup-all": "Clean up all", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "This will permanently delete every firmware entry{platform} whose file is missing from the filesystem. Nothing is removed from disk.", + "cleanup-firmware-queued": "Cleanup task queued, missing firmware will be deleted shortly", "cleanup-queued": "Cleanup task queued — missing ROMs will be deleted shortly", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Copy link", "copy-token": "Copy token", "copy-token-title": "Copy token", + "couldnt-fetch-missing-firmware": "Couldn't fetch missing firmware: {error}", "couldnt-fetch-missing-roms": "Couldn't fetch missing ROMs: {error}", "couldnt-queue-cleanup": "Couldn't queue cleanup task: {error}", "create-new-api-token": "Create new API token", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Completion times", "metadata-subtitle-cover-art": "Cover art", "metadata-website": "Website", + "missing-firmware-actions": "Missing firmware actions", + "missing-firmware-none": "No missing firmware found", + "missing-firmware-tab": "Missing firmware", "missing-games-actions": "Missing games actions", "missing-games-none": "No missing ROMs found", "missing-games-tab": "Missing games", diff --git a/frontend/src/locales/en_US/settings.json b/frontend/src/locales/en_US/settings.json index fcf25c4e83..49a87123c9 100644 --- a/frontend/src/locales/en_US/settings.json +++ b/frontend/src/locales/en_US/settings.json @@ -30,6 +30,8 @@ "cleanup": "Cleanup", "cleanup-all": "Clean up all", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "This will permanently delete every firmware entry{platform} whose file is missing from the filesystem. Nothing is removed from disk.", + "cleanup-firmware-queued": "Cleanup task queued, missing firmware will be deleted shortly", "cleanup-queued": "Cleanup task queued — missing ROMs will be deleted shortly", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Copy link", "copy-token": "Copy token", "copy-token-title": "Copy token", + "couldnt-fetch-missing-firmware": "Couldn't fetch missing firmware: {error}", "couldnt-fetch-missing-roms": "Couldn't fetch missing ROMs: {error}", "couldnt-queue-cleanup": "Couldn't queue cleanup task: {error}", "create-new-api-token": "Create new API token", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Completion times", "metadata-subtitle-cover-art": "Cover art", "metadata-website": "Website", + "missing-firmware-actions": "Missing firmware actions", + "missing-firmware-none": "No missing firmware found", + "missing-firmware-tab": "Missing firmware", "missing-games-actions": "Missing games actions", "missing-games-none": "No missing ROMs found", "missing-games-tab": "Missing games", diff --git a/frontend/src/locales/es_ES/settings.json b/frontend/src/locales/es_ES/settings.json index ebb59dd5f0..2e37ddb0e0 100644 --- a/frontend/src/locales/es_ES/settings.json +++ b/frontend/src/locales/es_ES/settings.json @@ -30,6 +30,8 @@ "cleanup": "Limpieza", "cleanup-all": "Limpiar todo", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "Esto eliminará permanentemente de la base de datos cada entrada de firmware{platform} cuyo archivo falte en el sistema de archivos. No se elimina nada del disco.", + "cleanup-firmware-queued": "Tarea de limpieza en cola, el firmware ausente se eliminará en breve", "cleanup-queued": "Tarea de limpieza en cola — las ROMs ausentes se eliminarán en breve", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Copiar enlace", "copy-token": "Copiar token", "copy-token-title": "Copiar token al portapapeles", + "couldnt-fetch-missing-firmware": "No se pudo obtener el firmware ausente: {error}", "couldnt-fetch-missing-roms": "No se pudieron obtener las ROM faltantes: {error}", "couldnt-queue-cleanup": "No se pudo encolar la tarea de limpieza: {error}", "create-new-api-token": "Crear nuevo token API", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Tiempos de completado", "metadata-subtitle-cover-art": "Portadas", "metadata-website": "Sitio web", + "missing-firmware-actions": "Acciones para firmware ausente", + "missing-firmware-none": "No se encontró firmware ausente", + "missing-firmware-tab": "Firmware ausente", "missing-games-actions": "Acciones para juegos faltantes", "missing-games-none": "No se encontraron ROM faltantes", "missing-games-tab": "Juegos faltantes", diff --git a/frontend/src/locales/fr_FR/settings.json b/frontend/src/locales/fr_FR/settings.json index edf6b2c7cb..aa0737e16e 100644 --- a/frontend/src/locales/fr_FR/settings.json +++ b/frontend/src/locales/fr_FR/settings.json @@ -30,6 +30,8 @@ "cleanup": "Nettoyage", "cleanup-all": "Tout nettoyer", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "Cette action supprimera définitivement de la base de données chaque entrée de firmware{platform} dont le fichier est absent du système de fichiers. Rien n'est supprimé du disque.", + "cleanup-firmware-queued": "Tâche de nettoyage mise en file d'attente, les firmwares manquants seront supprimés sous peu", "cleanup-queued": "Tâche de nettoyage mise en file d'attente — les ROMs manquantes seront supprimées sous peu", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Copier le lien", "copy-token": "Copier le token", "copy-token-title": "Copier le token", + "couldnt-fetch-missing-firmware": "Impossible de récupérer les firmwares manquants : {error}", "couldnt-fetch-missing-roms": "Impossible de récupérer les ROM manquantes : {error}", "couldnt-queue-cleanup": "Impossible de mettre la tâche de nettoyage en file d'attente : {error}", "create-new-api-token": "Créer un nouveau token d'API", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Temps de complétion", "metadata-subtitle-cover-art": "Jaquettes", "metadata-website": "Site web", + "missing-firmware-actions": "Actions sur les firmwares manquants", + "missing-firmware-none": "Aucun firmware manquant trouvé", + "missing-firmware-tab": "Firmwares manquants", "missing-games-actions": "Actions sur les jeux manquants", "missing-games-none": "Aucune ROM manquante trouvée", "missing-games-tab": "Jeux manquants", diff --git a/frontend/src/locales/hu_HU/settings.json b/frontend/src/locales/hu_HU/settings.json index de79852501..6515c18ad9 100644 --- a/frontend/src/locales/hu_HU/settings.json +++ b/frontend/src/locales/hu_HU/settings.json @@ -30,6 +30,8 @@ "cleanup": "Takarítás", "cleanup-all": "Mindent tisztítson meg", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "Ez véglegesen törli az adatbázisból minden olyan firmware-bejegyzést{platform}, amelynek a fájlja hiányzik a fájlrendszerből. A lemezről semmi sem törlődik.", + "cleanup-firmware-queued": "Takarítási feladat sorba állítva, a hiányzó firmware hamarosan törlődik", "cleanup-queued": "Takarítási feladat sorba állítva — a hiányzó ROM-ok hamarosan törlődnek", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Link másolása", "copy-token": "Token másolása", "copy-token-title": "Token másolása", + "couldnt-fetch-missing-firmware": "A hiányzó firmware lekérése sikertelen: {error}", "couldnt-fetch-missing-roms": "A hiányzó ROM-ok lekérése sikertelen: {error}", "couldnt-queue-cleanup": "A takarítási feladat sorba állítása sikertelen: {error}", "create-new-api-token": "Új API token létrehozása", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Befejezési idők", "metadata-subtitle-cover-art": "Borítóképek", "metadata-website": "Weboldal", + "missing-firmware-actions": "Hiányzó firmware műveletei", + "missing-firmware-none": "Nem található hiányzó firmware", + "missing-firmware-tab": "Hiányzó firmware", "missing-games-actions": "Hiányzó játékok műveletei", "missing-games-none": "Nem találhatók hiányzó ROM-ok", "missing-games-tab": "Hiányzó játékok", diff --git a/frontend/src/locales/it_IT/settings.json b/frontend/src/locales/it_IT/settings.json index ca2941b552..9e49706393 100644 --- a/frontend/src/locales/it_IT/settings.json +++ b/frontend/src/locales/it_IT/settings.json @@ -30,6 +30,8 @@ "cleanup": "Pulizia", "cleanup-all": "Pulisci tutto", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "Questa azione eliminerà definitivamente dal database ogni voce di firmware{platform} il cui file manca dal filesystem. Nulla viene rimosso dal disco.", + "cleanup-firmware-queued": "Attività di pulizia accodata, il firmware mancante verrà eliminato a breve", "cleanup-queued": "Attività di pulizia accodata — le ROM mancanti verranno eliminate a breve", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Copia link", "copy-token": "Copia token", "copy-token-title": "Copia token", + "couldnt-fetch-missing-firmware": "Impossibile recuperare il firmware mancante: {error}", "couldnt-fetch-missing-roms": "Impossibile recuperare le ROM mancanti: {error}", "couldnt-queue-cleanup": "Impossibile accodare l'attività di pulizia: {error}", "create-new-api-token": "Crea nuovo token API", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Tempi di completamento", "metadata-subtitle-cover-art": "Copertine", "metadata-website": "Sito web", + "missing-firmware-actions": "Azioni per firmware mancante", + "missing-firmware-none": "Nessun firmware mancante trovato", + "missing-firmware-tab": "Firmware mancante", "missing-games-actions": "Azioni per giochi mancanti", "missing-games-none": "Nessuna ROM mancante trovata", "missing-games-tab": "Giochi mancanti", diff --git a/frontend/src/locales/ja_JP/settings.json b/frontend/src/locales/ja_JP/settings.json index 289588da9d..f6022d32ef 100644 --- a/frontend/src/locales/ja_JP/settings.json +++ b/frontend/src/locales/ja_JP/settings.json @@ -30,6 +30,8 @@ "cleanup": "クリーンアップ", "cleanup-all": "すべてクリーンアップ", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "ファイルシステムにファイルが存在しないファームウェアの登録{platform}をデータベースから完全に削除します。ディスク上のファイルは削除されません。", + "cleanup-firmware-queued": "クリーンアップタスクをキューに追加しました。不足しているファームウェアはまもなく削除されます", "cleanup-queued": "クリーンアップタスクをキューに追加しました — 不足しているROMはまもなく削除されます", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "リンクをコピー", "copy-token": "トークンをコピー", "copy-token-title": "トークンをコピー", + "couldnt-fetch-missing-firmware": "不足しているファームウェアを取得できませんでした: {error}", "couldnt-fetch-missing-roms": "不足しているROMを取得できませんでした: {error}", "couldnt-queue-cleanup": "クリーンアップタスクをキューに追加できませんでした: {error}", "create-new-api-token": "新しいAPIトークンを作成", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "クリア時間", "metadata-subtitle-cover-art": "カバーアート", "metadata-website": "ウェブサイト", + "missing-firmware-actions": "不足ファームウェアの操作", + "missing-firmware-none": "不足しているファームウェアは見つかりませんでした", + "missing-firmware-tab": "不足しているファームウェア", "missing-games-actions": "不足ゲームの操作", "missing-games-none": "不足しているROMは見つかりませんでした", "missing-games-tab": "不足しているゲーム", diff --git a/frontend/src/locales/ko_KR/settings.json b/frontend/src/locales/ko_KR/settings.json index e956611f80..349e0e1ffa 100644 --- a/frontend/src/locales/ko_KR/settings.json +++ b/frontend/src/locales/ko_KR/settings.json @@ -30,6 +30,8 @@ "cleanup": "정리", "cleanup-all": "모두 정리", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "파일 시스템에서 파일이 사라진 모든 펌웨어 항목{platform}을 데이터베이스에서 영구적으로 삭제합니다. 디스크에서는 아무것도 삭제되지 않습니다.", + "cleanup-firmware-queued": "정리 작업이 대기열에 추가되었습니다. 누락된 펌웨어는 곧 삭제됩니다", "cleanup-queued": "정리 작업이 대기열에 추가되었습니다 — 누락된 ROM은 곧 삭제됩니다", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "링크 복사", "copy-token": "토큰 복사", "copy-token-title": "토큰 복사", + "couldnt-fetch-missing-firmware": "누락된 펌웨어를 가져오지 못했습니다: {error}", "couldnt-fetch-missing-roms": "누락된 ROM을 가져오지 못했습니다: {error}", "couldnt-queue-cleanup": "정리 작업을 대기열에 추가할 수 없습니다: {error}", "create-new-api-token": "새 API 토큰 생성", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "완료 시간", "metadata-subtitle-cover-art": "커버 이미지", "metadata-website": "웹사이트", + "missing-firmware-actions": "누락된 펌웨어 작업", + "missing-firmware-none": "누락된 펌웨어를 찾을 수 없습니다", + "missing-firmware-tab": "누락된 펌웨어", "missing-games-actions": "누락된 게임 작업", "missing-games-none": "누락된 ROM을 찾을 수 없습니다", "missing-games-tab": "누락된 게임", diff --git a/frontend/src/locales/pl_PL/settings.json b/frontend/src/locales/pl_PL/settings.json index ae4f42803d..3685dda0f5 100644 --- a/frontend/src/locales/pl_PL/settings.json +++ b/frontend/src/locales/pl_PL/settings.json @@ -30,6 +30,8 @@ "cleanup": "Czyszczenie", "cleanup-all": "Wyczyść wszystko", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "Spowoduje to trwałe usunięcie z bazy danych każdego wpisu oprogramowania{platform}, którego plik nie istnieje w systemie plików. Nic nie zostanie usunięte z dysku.", + "cleanup-firmware-queued": "Zadanie czyszczenia w kolejce, brakujące oprogramowanie zostanie wkrótce usunięte", "cleanup-queued": "Zadanie czyszczenia w kolejce — brakujące ROM-y zostaną wkrótce usunięte", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Kopiuj link", "copy-token": "Kopiuj token", "copy-token-title": "Kopiuj token", + "couldnt-fetch-missing-firmware": "Nie udało się pobrać brakującego oprogramowania: {error}", "couldnt-fetch-missing-roms": "Nie udało się pobrać brakujących ROM-ów: {error}", "couldnt-queue-cleanup": "Nie udało się dodać zadania czyszczenia do kolejki: {error}", "create-new-api-token": "Utwórz nowy token API", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Czasy ukończenia", "metadata-subtitle-cover-art": "Okładki", "metadata-website": "Strona internetowa", + "missing-firmware-actions": "Akcje brakującego oprogramowania", + "missing-firmware-none": "Nie znaleziono brakującego oprogramowania", + "missing-firmware-tab": "Brakujące oprogramowanie", "missing-games-actions": "Akcje brakujących gier", "missing-games-none": "Nie znaleziono brakujących ROM-ów", "missing-games-tab": "Brakujące gry", diff --git a/frontend/src/locales/pt_BR/settings.json b/frontend/src/locales/pt_BR/settings.json index ce7cc132a8..5b9a2cdea0 100644 --- a/frontend/src/locales/pt_BR/settings.json +++ b/frontend/src/locales/pt_BR/settings.json @@ -30,6 +30,8 @@ "cleanup": "Limpeza", "cleanup-all": "Limpar tudo", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "Isso excluirá permanentemente do banco de dados cada entrada de firmware{platform} cujo arquivo esteja ausente no sistema de arquivos. Nada é removido do disco.", + "cleanup-firmware-queued": "Tarefa de limpeza na fila, o firmware ausente será excluído em breve", "cleanup-queued": "Tarefa de limpeza na fila — as ROMs ausentes serão excluídas em breve", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Copiar link", "copy-token": "Copiar token", "copy-token-title": "Copiar token", + "couldnt-fetch-missing-firmware": "Não foi possível obter o firmware ausente: {error}", "couldnt-fetch-missing-roms": "Não foi possível obter as ROMs ausentes: {error}", "couldnt-queue-cleanup": "Não foi possível enfileirar a tarefa de limpeza: {error}", "create-new-api-token": "Criar novo token de API", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Tempos de conclusão", "metadata-subtitle-cover-art": "Imagem da capa", "metadata-website": "Site", + "missing-firmware-actions": "Ações de firmware ausente", + "missing-firmware-none": "Nenhum firmware ausente encontrado", + "missing-firmware-tab": "Firmware ausente", "missing-games-actions": "Ações de jogos ausentes", "missing-games-none": "Nenhuma ROM ausente encontrada", "missing-games-tab": "Jogos ausentes", diff --git a/frontend/src/locales/ro_RO/settings.json b/frontend/src/locales/ro_RO/settings.json index ccf6a02c6f..386412fd6f 100644 --- a/frontend/src/locales/ro_RO/settings.json +++ b/frontend/src/locales/ro_RO/settings.json @@ -30,6 +30,8 @@ "cleanup": "Curățare", "cleanup-all": "Curăță tot", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "Aceasta va șterge definitiv din baza de date fiecare intrare de firmware{platform} al cărei fișier lipsește din sistemul de fișiere. Nu se șterge nimic de pe disc.", + "cleanup-firmware-queued": "Sarcina de curățare a fost pusă în coadă, firmware-ul lipsă va fi șters în scurt timp", "cleanup-queued": "Sarcina de curățare a fost pusă în coadă — ROM-urile lipsă vor fi șterse în scurt timp", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Copiază linkul", "copy-token": "Copiază token-ul", "copy-token-title": "Copiază token-ul", + "couldnt-fetch-missing-firmware": "Nu s-a putut prelua firmware-ul lipsă: {error}", "couldnt-fetch-missing-roms": "Nu s-au putut prelua ROM-urile lipsă: {error}", "couldnt-queue-cleanup": "Sarcina de curățare nu a putut fi pusă în coadă: {error}", "create-new-api-token": "Creează un token API nou", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Timpi de finalizare", "metadata-subtitle-cover-art": "Coperți", "metadata-website": "Site web", + "missing-firmware-actions": "Acțiuni firmware lipsă", + "missing-firmware-none": "Nu s-a găsit firmware lipsă", + "missing-firmware-tab": "Firmware lipsă", "missing-games-actions": "Acțiuni jocuri lipsă", "missing-games-none": "Nu s-au găsit ROM-uri lipsă", "missing-games-tab": "Jocuri lipsă", diff --git a/frontend/src/locales/ru_RU/settings.json b/frontend/src/locales/ru_RU/settings.json index 36e141e26a..20f7000ebf 100644 --- a/frontend/src/locales/ru_RU/settings.json +++ b/frontend/src/locales/ru_RU/settings.json @@ -30,6 +30,8 @@ "cleanup": "Очистка", "cleanup-all": "Очистить все", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "Это навсегда удалит из базы данных каждую запись прошивки{platform}, файл которой отсутствует в файловой системе. С диска ничего не удаляется.", + "cleanup-firmware-queued": "Задача очистки поставлена в очередь, отсутствующие прошивки скоро будут удалены", "cleanup-queued": "Задача очистки поставлена в очередь — отсутствующие ROM-ы скоро будут удалены", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "Копировать ссылку", "copy-token": "Копировать токен", "copy-token-title": "Копировать токен", + "couldnt-fetch-missing-firmware": "Не удалось получить отсутствующие прошивки: {error}", "couldnt-fetch-missing-roms": "Не удалось получить отсутствующие ROM: {error}", "couldnt-queue-cleanup": "Не удалось поставить в очередь задачу очистки: {error}", "create-new-api-token": "Создать новый API-токен", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Время прохождения", "metadata-subtitle-cover-art": "Обложки", "metadata-website": "Веб-сайт", + "missing-firmware-actions": "Действия с отсутствующими прошивками", + "missing-firmware-none": "Отсутствующие прошивки не найдены", + "missing-firmware-tab": "Отсутствующие прошивки", "missing-games-actions": "Действия с отсутствующими играми", "missing-games-none": "Отсутствующие ROM не найдены", "missing-games-tab": "Отсутствующие игры", diff --git a/frontend/src/locales/tr_TR/settings.json b/frontend/src/locales/tr_TR/settings.json index 2b25b25826..c2058f6ede 100644 --- a/frontend/src/locales/tr_TR/settings.json +++ b/frontend/src/locales/tr_TR/settings.json @@ -30,6 +30,8 @@ "cleanup": "Temizlik", "cleanup-all": "Tümünü temizle", "cleanup-all-confirm": "Bu işlem, veritabanından tüm eksik ROM'ları{platform} ve kaynak dizinlerini kalıcı olarak siler. Bu işlem geri alınamaz.", + "cleanup-firmware-confirm": "Bu işlem, dosyası dosya sisteminde bulunmayan her firmware kaydını{platform} veritabanından kalıcı olarak siler. Diskten hiçbir şey silinmez.", + "cleanup-firmware-queued": "Temizlik görevi sıraya alındı, eksik firmware kısa süre içinde silinecek", "cleanup-queued": "Temizlik görevi sıraya alındı — eksik ROM'lar kısa süre içinde silinecek", "client-api-tokens": "İstemci API Tokenları", "client-token-confirm-delete": "Bu tokenı iptal etmek istediğinizden emin misiniz? Kullanan tüm cihazlar erişimini kaybedecek.", @@ -78,6 +80,7 @@ "copy-link": "Bağlantıyı kopyala", "copy-token": "Tokenı kopyala", "copy-token-title": "Tokenı kopyala", + "couldnt-fetch-missing-firmware": "Eksik firmware alınamadı: {error}", "couldnt-fetch-missing-roms": "Eksik ROM'lar alınamadı: {error}", "couldnt-queue-cleanup": "Temizlik görevi sıraya alınamadı: {error}", "create-new-api-token": "Yeni API tokenı oluştur", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "Tamamlama süreleri", "metadata-subtitle-cover-art": "Kapak görselleri", "metadata-website": "Web sitesi", + "missing-firmware-actions": "Eksik firmware işlemleri", + "missing-firmware-none": "Eksik firmware bulunamadı", + "missing-firmware-tab": "Eksik firmware", "missing-games-actions": "Eksik oyun işlemleri", "missing-games-none": "Eksik ROM bulunamadı", "missing-games-tab": "Eksik oyunlar", diff --git a/frontend/src/locales/zh_CN/settings.json b/frontend/src/locales/zh_CN/settings.json index fe064c806b..5704741ed8 100644 --- a/frontend/src/locales/zh_CN/settings.json +++ b/frontend/src/locales/zh_CN/settings.json @@ -30,6 +30,8 @@ "cleanup": "清理", "cleanup-all": "清理全部", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "这将从数据库中永久删除文件已从文件系统中缺失的所有固件条目{platform}。磁盘上的文件不会被删除。", + "cleanup-firmware-queued": "清理任务已排队,缺失的固件将很快被删除", "cleanup-queued": "清理任务已排队 — 缺失的 ROM 将很快被删除", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "复制链接", "copy-token": "复制令牌", "copy-token-title": "复制令牌", + "couldnt-fetch-missing-firmware": "无法获取缺失的固件:{error}", "couldnt-fetch-missing-roms": "无法获取缺失的 ROM:{error}", "couldnt-queue-cleanup": "无法将清理任务排队:{error}", "create-new-api-token": "创建新 API 令牌", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "完成时长", "metadata-subtitle-cover-art": "封面图", "metadata-website": "网站", + "missing-firmware-actions": "缺失固件操作", + "missing-firmware-none": "未找到缺失的固件", + "missing-firmware-tab": "缺失固件", "missing-games-actions": "缺失游戏操作", "missing-games-none": "未找到缺失的 ROM", "missing-games-tab": "缺失游戏", diff --git a/frontend/src/locales/zh_TW/settings.json b/frontend/src/locales/zh_TW/settings.json index 028f453775..e9c1c34da5 100644 --- a/frontend/src/locales/zh_TW/settings.json +++ b/frontend/src/locales/zh_TW/settings.json @@ -30,6 +30,8 @@ "cleanup": "清理", "cleanup-all": "清理全部", "cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.", + "cleanup-firmware-confirm": "這將從資料庫中永久刪除檔案已從檔案系統中缺失的所有韌體項目{platform}。磁碟上的檔案不會被刪除。", + "cleanup-firmware-queued": "清理任務已排程,缺失的韌體將在稍後被刪除", "cleanup-queued": "清理任務已排程 — 缺失的 ROM 將在稍後被刪除", "client-api-tokens": "Client API Tokens", "client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.", @@ -78,6 +80,7 @@ "copy-link": "複製連結", "copy-token": "複製權杖", "copy-token-title": "複製權杖", + "couldnt-fetch-missing-firmware": "無法取得缺少的韌體:{error}", "couldnt-fetch-missing-roms": "無法取得缺少的 ROM:{error}", "couldnt-queue-cleanup": "無法排程清理任務:{error}", "create-new-api-token": "建立新的 API 權杖", @@ -234,6 +237,9 @@ "metadata-subtitle-completion": "完成時間", "metadata-subtitle-cover-art": "封面圖片", "metadata-website": "網站", + "missing-firmware-actions": "缺失韌體動作", + "missing-firmware-none": "未找到缺少的韌體", + "missing-firmware-tab": "缺失韌體", "missing-games-actions": "缺失遊戲動作", "missing-games-none": "未找到缺少的 ROM", "missing-games-tab": "缺失遊戲", diff --git a/frontend/src/services/api/firmware.ts b/frontend/src/services/api/firmware.ts index 10746a8430..9c41b4425c 100644 --- a/frontend/src/services/api/firmware.ts +++ b/frontend/src/services/api/firmware.ts @@ -9,12 +9,15 @@ export const firmwareApi = api; async function getFirmware({ platformId = null, + missing = null, }: { platformId?: number | null; + missing?: boolean | null; }) { return firmwareApi.get(`/firmware`, { params: { platform_id: platformId, + missing, }, }); } diff --git a/frontend/src/v2/components/Gallery/FirmwareTab.vue b/frontend/src/v2/components/Gallery/FirmwareTab.vue index 382609f5de..db8b6ac9a0 100644 --- a/frontend/src/v2/components/Gallery/FirmwareTab.vue +++ b/frontend/src/v2/components/Gallery/FirmwareTab.vue @@ -167,12 +167,13 @@ function onDeleted(deletedIds: number[]) { // `firmware_count` is a readonly derived field on PlatformSchema — // patched locally so the InfoPanel stat reacts instantly; a future -// refetch reconciles. +// refetch reconciles. It counts only firmware present on disk, matching +// what the server derives. function syncFirmware(next: FirmwareSchema[]) { const updated: Platform = { ...props.platform, firmware: next, - firmware_count: next.length, + firmware_count: next.filter((f) => !f.missing_from_fs).length, }; platformsStore.update(updated); if (galleryRoms.currentPlatform?.id === updated.id) { diff --git a/frontend/src/v2/components/Settings/MissingFirmwareSection.test.ts b/frontend/src/v2/components/Settings/MissingFirmwareSection.test.ts new file mode 100644 index 0000000000..000c04c491 --- /dev/null +++ b/frontend/src/v2/components/Settings/MissingFirmwareSection.test.ts @@ -0,0 +1,225 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import storePlatforms from "@/stores/platforms"; +import MissingFirmwareSection from "./MissingFirmwareSection.vue"; + +const { getFirmware, runTask, getTaskById, confirm } = vi.hoisted(() => ({ + getFirmware: vi.fn(), + runTask: vi.fn(), + getTaskById: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock("@/services/api/firmware", () => ({ default: { getFirmware } })); +vi.mock("@/services/api/task", () => ({ default: { runTask, getTaskById } })); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/v2/composables/useConfirm", () => ({ + useConfirm: () => confirm, +})); +vi.mock("@/v2/composables/useSnackbar", () => ({ + useSnackbar: () => ({ success: vi.fn(), error: vi.fn() }), +})); + +const PS1 = { + id: 1, + slug: "ps", + name: "PlayStation", + display_name: "PlayStation", +}; +const SATURN = { + id: 2, + slug: "saturn", + name: "Sega Saturn", + display_name: "Sega Saturn", +}; + +function firmware(id: number, platformId: number, fileName: string) { + return { + id, + platform_id: platformId, + file_name: fileName, + file_path: "bios", + file_size_bytes: 512, + missing_from_fs: true, + }; +} + +function seedPlatforms() { + storePlatforms().allPlatforms = [PS1, SATURN] as never; +} + +// The kebab's items live in RMenu's default slot; the auto-stub drops slot +// content, so render it to reach the cleanup action. +const RMenuStub = { + name: "RMenu", + template: '
', +}; + +function mountSection() { + return mount(MissingFirmwareSection, { + global: { + stubs: { + CachedPlatformIcon: true, + RBtn: true, + RIcon: true, + RMenu: RMenuStub, + RMenuItem: true, + RSelect: true, + RTag: true, + }, + }, + }); +} + +async function selectPlatforms( + wrapper: ReturnType, + ids: number[], +) { + wrapper.findComponent({ name: "RSelect" }).vm.$emit("update:modelValue", ids); + await flushPromises(); +} + +describe("MissingFirmwareSection", () => { + beforeEach(() => { + setActivePinia(createPinia()); + seedPlatforms(); + runTask.mockReset(); + runTask.mockResolvedValue({ data: { task_id: "job-1" } }); + getTaskById.mockReset(); + getTaskById.mockResolvedValue({ data: { status: "finished" } }); + confirm.mockReset(); + confirm.mockResolvedValue(true); + getFirmware.mockReset(); + getFirmware.mockResolvedValue({ + data: [firmware(10, PS1.id, "scph5501.bin")], + }); + }); + + it("asks the server for missing firmware only", async () => { + mountSection(); + await flushPromises(); + + expect(getFirmware).toHaveBeenCalledTimes(1); + expect(getFirmware.mock.calls[0][0]).toMatchObject({ missing: true }); + }); + + it("renders one row per missing entry", async () => { + getFirmware.mockResolvedValue({ + data: [ + firmware(10, PS1.id, "scph5501.bin"), + firmware(11, SATURN.id, "saturn_bios.bin"), + ], + }); + + const wrapper = mountSection(); + await flushPromises(); + + const rows = wrapper.findAll("[data-test='missing-firmware-row']"); + expect(rows).toHaveLength(2); + expect(rows[0].text()).toContain("scph5501.bin"); + }); + + it("filters by platform client-side rather than refetching", async () => { + getFirmware.mockResolvedValue({ + data: [ + firmware(10, PS1.id, "scph5501.bin"), + firmware(11, SATURN.id, "saturn_bios.bin"), + ], + }); + + const wrapper = mountSection(); + await flushPromises(); + getFirmware.mockClear(); + + await selectPlatforms(wrapper, [SATURN.id]); + + const rows = wrapper.findAll("[data-test='missing-firmware-row']"); + expect(rows).toHaveLength(1); + expect(rows[0].text()).toContain("saturn_bios.bin"); + expect(getFirmware).not.toHaveBeenCalled(); + }); + + it("scopes the cleanup task to a single selected platform", async () => { + const wrapper = mountSection(); + await flushPromises(); + await selectPlatforms(wrapper, [PS1.id]); + + wrapper.findComponent({ name: "RMenuItem" }).vm.$emit("click"); + await flushPromises(); + + expect(runTask).toHaveBeenCalledWith("cleanup_missing_firmware", { + platform_id: PS1.id, + }); + }); + + it("runs the unscoped cleanup when the filter isn't a single platform", async () => { + const wrapper = mountSection(); + await flushPromises(); + await selectPlatforms(wrapper, [PS1.id, SATURN.id]); + + wrapper.findComponent({ name: "RMenuItem" }).vm.$emit("click"); + await flushPromises(); + + expect(runTask).toHaveBeenCalledWith("cleanup_missing_firmware", {}); + }); + + it("waits for the cleanup task to finish before reloading the list", async () => { + const wrapper = mountSection(); + await flushPromises(); + getFirmware.mockClear(); + getFirmware.mockResolvedValue({ data: [] }); + + wrapper.findComponent({ name: "RMenuItem" }).vm.$emit("click"); + await flushPromises(); + + expect(getTaskById).toHaveBeenCalledWith("job-1"); + expect(getFirmware).toHaveBeenCalledTimes(1); + expect(wrapper.find("[data-test='missing-firmware-empty']").exists()).toBe( + true, + ); + }); + + it("leaves the list alone while the cleanup task is still running", async () => { + getTaskById.mockResolvedValue({ data: { status: "started" } }); + + const wrapper = mountSection(); + await flushPromises(); + getFirmware.mockClear(); + + wrapper.findComponent({ name: "RMenuItem" }).vm.$emit("click"); + await flushPromises(); + + expect(getFirmware).not.toHaveBeenCalled(); + }); + + it("does not queue anything when the confirmation is declined", async () => { + confirm.mockResolvedValue(false); + + const wrapper = mountSection(); + await flushPromises(); + + wrapper.findComponent({ name: "RMenuItem" }).vm.$emit("click"); + await flushPromises(); + + expect(runTask).not.toHaveBeenCalled(); + }); + + it("shows the empty state when nothing is missing", async () => { + getFirmware.mockResolvedValue({ data: [] }); + + const wrapper = mountSection(); + await flushPromises(); + + expect(wrapper.find("[data-test='missing-firmware-empty']").exists()).toBe( + true, + ); + expect(wrapper.findAll("[data-test='missing-firmware-row']")).toHaveLength( + 0, + ); + }); +}); diff --git a/frontend/src/v2/components/Settings/MissingFirmwareSection.vue b/frontend/src/v2/components/Settings/MissingFirmwareSection.vue new file mode 100644 index 0000000000..917b8446f3 --- /dev/null +++ b/frontend/src/v2/components/Settings/MissingFirmwareSection.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/frontend/src/v2/components/Settings/MissingGamesSection.test.ts b/frontend/src/v2/components/Settings/MissingGamesSection.test.ts index c77200dc92..1f35876f80 100644 --- a/frontend/src/v2/components/Settings/MissingGamesSection.test.ts +++ b/frontend/src/v2/components/Settings/MissingGamesSection.test.ts @@ -6,7 +6,12 @@ import MissingGamesSection from "./MissingGamesSection.vue"; const { getRoms } = vi.hoisted(() => ({ getRoms: vi.fn() })); vi.mock("@/services/api/rom", () => ({ default: { getRoms } })); -vi.mock("@/services/api/task", () => ({ default: { runTask: vi.fn() } })); +vi.mock("@/services/api/task", () => ({ + default: { + runTask: vi.fn().mockResolvedValue({ data: { task_id: "job-1" } }), + getTaskById: vi.fn().mockResolvedValue({ data: { status: "finished" } }), + }, +})); vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }), diff --git a/frontend/src/v2/components/Settings/MissingGamesSection.vue b/frontend/src/v2/components/Settings/MissingGamesSection.vue index 512d65c09b..7dc1298f62 100644 --- a/frontend/src/v2/components/Settings/MissingGamesSection.vue +++ b/frontend/src/v2/components/Settings/MissingGamesSection.vue @@ -40,6 +40,7 @@ import { import CachedPlatformIcon from "@/v2/components/shared/CachedPlatformIcon.vue"; import { useConfirm } from "@/v2/composables/useConfirm"; import { useSnackbar } from "@/v2/composables/useSnackbar"; +import { useTaskCompletion } from "@/v2/composables/useTaskCompletion"; import { useWebpSupport } from "@/v2/composables/useWebpSupport"; import storeGalleryRoms, { type SidecarOptions } from "@/v2/stores/galleryRoms"; @@ -66,6 +67,7 @@ const galleryFilter = storeGalleryFilter(); const platformsStore = storePlatforms(); const snackbar = useSnackbar(); const confirm = useConfirm(); +const { awaitTask } = useTaskCompletion(); const { supportsWebp } = useWebpSupport(); const { allPlatforms } = storeToRefs(platformsStore); @@ -251,12 +253,14 @@ async function cleanupAll() { selectedPlatforms.value.length === 1 ? { platform_id: selectedPlatforms.value[0].id } : {}; - await taskApi.runTask("cleanup_missing_roms", body); + const { data } = await taskApi.runTask("cleanup_missing_roms", body); snackbar.success(t("settings.cleanup-queued")); - setTimeout(() => { + // The run endpoint returns once the job is queued, so wait for the worker + // to finish before reloading the table. + if (await awaitTask(data.task_id)) { galleryRoms.invalidateWindows(); - void galleryRoms.fetchInitialMetadata(NO_SIDECARS); - }, 1500); + await galleryRoms.fetchInitialMetadata(NO_SIDECARS); + } } catch (err) { snackbar.error(t("settings.couldnt-queue-cleanup", { error: String(err) })); } finally { diff --git a/frontend/src/v2/composables/useTaskCompletion/index.test.ts b/frontend/src/v2/composables/useTaskCompletion/index.test.ts new file mode 100644 index 0000000000..befb9c1ccc --- /dev/null +++ b/frontend/src/v2/composables/useTaskCompletion/index.test.ts @@ -0,0 +1,139 @@ +import { AxiosError, type AxiosResponse } from "axios"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { effectScope } from "vue"; +import { useTaskCompletion } from "./index"; + +const { getTaskById } = vi.hoisted(() => ({ getTaskById: vi.fn() })); + +vi.mock("@/services/api/task", () => ({ default: { getTaskById } })); + +const status = (s: string) => ({ data: { status: s } }); + +const httpError = (code: number) => + new AxiosError("boom", undefined, undefined, undefined, { + status: code, + } as AxiosResponse); + +// The composable registers an onScopeDispose hook, so it needs an owning scope +// the same way a component setup would give it one. +function inScope() { + const scope = effectScope(); + const composable = scope.run(() => useTaskCompletion())!; + return { ...composable, dispose: () => scope.stop() }; +} + +describe("useTaskCompletion", () => { + beforeEach(() => { + vi.useFakeTimers(); + getTaskById.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolves without waiting when the job already finished", async () => { + getTaskById.mockResolvedValue(status("finished")); + + const { awaitTask } = inScope(); + await expect(awaitTask("job-1")).resolves.toBe(true); + expect(getTaskById).toHaveBeenCalledTimes(1); + }); + + it("keeps polling while the job is queued or running", async () => { + getTaskById + .mockResolvedValueOnce(status("queued")) + .mockResolvedValueOnce(status("started")) + .mockResolvedValueOnce(status("finished")); + + const { awaitTask } = inScope(); + const settled = awaitTask("job-1"); + + await vi.advanceTimersByTimeAsync(5000); + + await expect(settled).resolves.toBe(true); + expect(getTaskById).toHaveBeenCalledTimes(3); + }); + + // The job ran and its result has already aged out of Redis, so there is + // nothing left to wait for. + it("treats an unfetchable job as done", async () => { + getTaskById.mockRejectedValue(httpError(404)); + + const { awaitTask } = inScope(); + await expect(awaitTask("job-1")).resolves.toBe(true); + }); + + it("keeps polling through a transient failure", async () => { + getTaskById + .mockRejectedValueOnce(httpError(503)) + .mockResolvedValueOnce(status("finished")); + + const { awaitTask } = inScope(); + const settled = awaitTask("job-1"); + + await vi.advanceTimersByTimeAsync(5000); + + await expect(settled).resolves.toBe(true); + expect(getTaskById).toHaveBeenCalledTimes(2); + }); + + it("stops retrying a persistently failing lookup at the deadline", async () => { + getTaskById.mockRejectedValue(httpError(503)); + + const { awaitTask } = inScope(); + const settled = awaitTask("job-1"); + + await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + + await expect(settled).resolves.toBe(true); + }); + + it.each(["failed", "stopped", "canceled"])( + "stops waiting on a %s job so the caller still refreshes", + async (terminal) => { + getTaskById.mockResolvedValue(status(terminal)); + + const { awaitTask } = inScope(); + await expect(awaitTask("job-1")).resolves.toBe(true); + }, + ); + + it("tells the caller not to act once the scope is disposed", async () => { + getTaskById.mockResolvedValue(status("started")); + + const { awaitTask, dispose } = inScope(); + const settled = awaitTask("job-1"); + await vi.advanceTimersByTimeAsync(0); + + dispose(); + await vi.advanceTimersByTimeAsync(5000); + + await expect(settled).resolves.toBe(false); + }); + + it("supersedes an earlier wait when a second one starts", async () => { + getTaskById.mockResolvedValue(status("started")); + + const { awaitTask } = inScope(); + const first = awaitTask("job-1"); + await vi.advanceTimersByTimeAsync(0); + + getTaskById.mockResolvedValue(status("finished")); + const second = awaitTask("job-2"); + + await expect(first).resolves.toBe(false); + await expect(second).resolves.toBe(true); + }); + + it("gives up on a job that never reports terminal", async () => { + getTaskById.mockResolvedValue(status("started")); + + const { awaitTask } = inScope(); + const settled = awaitTask("job-1"); + + await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + + await expect(settled).resolves.toBe(true); + }); +}); diff --git a/frontend/src/v2/composables/useTaskCompletion/index.ts b/frontend/src/v2/composables/useTaskCompletion/index.ts new file mode 100644 index 0000000000..54806a78bf --- /dev/null +++ b/frontend/src/v2/composables/useTaskCompletion/index.ts @@ -0,0 +1,111 @@ +// useTaskCompletion — wait for a queued RQ job to finish before reacting to it. +// +// `POST /tasks/run/{name}` returns as soon as the job is enqueued, so anything +// that wants to show the job's effect has to wait for the worker. Guessing at +// a fixed delay loses the race whenever the worker is busy or the job is slow, +// which leaves the caller showing stale data behind a success toast. Polling +// the job's own status instead means the wait is as long as the job actually +// takes. There is no socket event for task completion, and `TasksSection` +// already polls task status, so this follows the same mechanism scoped to one +// job. +// +// Usage: +// const { awaitTask } = useTaskCompletion(); +// const { data } = await taskApi.runTask("cleanup_missing_roms", body); +// if (await awaitTask(data.task_id)) await refresh(); +import axios from "axios"; +import { onScopeDispose } from "vue"; +import type { JobStatus } from "@/__generated__"; +import taskApi from "@/services/api/task"; + +const TERMINAL_STATUSES: readonly JobStatus[] = [ + "finished", + "failed", + "stopped", + "canceled", +]; + +const FIRST_POLL_DELAY_MS = 400; +const POLL_BACKOFF = 1.5; +const MAX_POLL_DELAY_MS = 5000; +// Cleanups on a large library run for a while; stop waiting well after that +// rather than polling forever behind a tab nobody is looking at. +const POLL_TIMEOUT_MS = 5 * 60 * 1000; + +export interface UseTaskCompletion { + /** + * Polls `task_id` until it reaches a terminal state. Resolves true when the + * caller should act on the result, false when the wait was cancelled (the + * component went away, or another task superseded this one). + * + * Resolves true on timeout and on a job that can no longer be fetched, so a + * refresh still happens on a best-effort basis. + */ + awaitTask: (taskId: string) => Promise; + /** Abandons an in-flight wait. Called automatically on scope dispose. */ + cancel: () => void; +} + +export function useTaskCompletion(): UseTaskCompletion { + let timer: ReturnType | null = null; + // Bumped on cancel so a poll already in flight knows it was superseded. + let generation = 0; + // Cancelling clears the pending timer, so it has to settle the outstanding + // promise itself or the caller waits on it forever. + let settle: ((observed: boolean) => void) | null = null; + + function cancel() { + generation += 1; + if (timer) clearTimeout(timer); + timer = null; + settle?.(false); + settle = null; + } + + function awaitTask(taskId: string): Promise { + cancel(); + const mine = generation; + const deadline = Date.now() + POLL_TIMEOUT_MS; + let delay = FIRST_POLL_DELAY_MS; + + return new Promise((resolve) => { + settle = resolve; + + const finish = (observed: boolean) => { + if (mine !== generation) return; + settle = null; + resolve(observed); + }; + + const poll = async () => { + if (mine !== generation) return; + + try { + const { data } = await taskApi.getTaskById(taskId); + if (mine !== generation) return; + if (TERMINAL_STATUSES.includes(data.status)) return finish(true); + } catch (err) { + if (mine !== generation) return; + // A job past its result TTL 404s, which means it ran and is gone. + // Anything else (a timeout, a 5xx) says nothing about the job, so + // keep polling rather than refreshing over a cleanup still in + // progress. The deadline below still bounds the wait. + if (axios.isAxiosError(err) && err.response?.status === 404) { + return finish(true); + } + } + + if (Date.now() >= deadline) return finish(true); + + timer = setTimeout(() => void poll(), delay); + delay = Math.min(delay * POLL_BACKOFF, MAX_POLL_DELAY_MS); + }; + + void poll(); + }); + } + + onScopeDispose(cancel); + + return { awaitTask, cancel }; +} diff --git a/frontend/src/v2/utils/playerFirmware.test.ts b/frontend/src/v2/utils/playerFirmware.test.ts new file mode 100644 index 0000000000..c70ed6d2cf --- /dev/null +++ b/frontend/src/v2/utils/playerFirmware.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { resolveInitialFirmware } from "./playerFirmware"; + +// Only these three fields are read; minimal stubs stand in for FirmwareSchema. +const fw = (id: number, file_name: string, missing_from_fs = false) => ({ + id, + file_name, + missing_from_fs, +}); + +describe("resolveInitialFirmware", () => { + it("prefers the id the user last picked for the platform", () => { + const options = [fw(1, "a.bin"), fw(2, "b.bin")]; + expect( + resolveInitialFirmware({ + options, + storedBiosId: "2", + configBiosFile: "a.bin", + })?.id, + ).toBe(2); + }); + + it("falls back to the core config's bios_file", () => { + const options = [fw(1, "a.bin"), fw(2, "b.bin")]; + expect( + resolveInitialFirmware({ + options, + storedBiosId: null, + configBiosFile: "b.bin", + })?.id, + ).toBe(2); + }); + + it("auto-selects when the platform has exactly one usable entry", () => { + expect( + resolveInitialFirmware({ + options: [fw(7, "only.bin")], + storedBiosId: null, + configBiosFile: undefined, + })?.id, + ).toBe(7); + }); + + // Issue #4075: a single, stale entry used to be auto-selected, and the game + // then failed to boot with nothing pointing at the BIOS. + it("never auto-selects the sole entry when its file is missing", () => { + expect( + resolveInitialFirmware({ + options: [fw(7, "gone.bin", true)], + storedBiosId: null, + configBiosFile: undefined, + }), + ).toBeNull(); + }); + + it("ignores a stored id that now points at missing firmware", () => { + const options = [fw(1, "gone.bin", true), fw(2, "present.bin")]; + // The stored pick is discarded, leaving one usable entry to fall back on. + expect( + resolveInitialFirmware({ + options, + storedBiosId: "1", + configBiosFile: undefined, + })?.id, + ).toBe(2); + }); + + it("ignores a config bios_file that now points at missing firmware", () => { + const options = [fw(1, "gone.bin", true), fw(2, "present.bin")]; + expect( + resolveInitialFirmware({ + options, + storedBiosId: null, + configBiosFile: "gone.bin", + })?.id, + ).toBe(2); + }); + + it("selects nothing when the platform has no firmware at all", () => { + expect( + resolveInitialFirmware({ + options: [], + storedBiosId: null, + configBiosFile: undefined, + }), + ).toBeNull(); + }); + + it("selects nothing when several usable entries make the choice ambiguous", () => { + expect( + resolveInitialFirmware({ + options: [fw(1, "a.bin"), fw(2, "b.bin")], + storedBiosId: null, + configBiosFile: undefined, + }), + ).toBeNull(); + }); +}); diff --git a/frontend/src/v2/utils/playerFirmware.ts b/frontend/src/v2/utils/playerFirmware.ts new file mode 100644 index 0000000000..7f0582373f --- /dev/null +++ b/frontend/src/v2/utils/playerFirmware.ts @@ -0,0 +1,43 @@ +// Choosing which firmware (BIOS) the EmulatorJS player boots with. +// +// A scan flags firmware whose file vanished with `missing_from_fs`, but the +// player used to offer every entry for the platform, and auto-selected the sole +// entry when there was only one. A stale sole entry then made the game fail to +// load with nothing pointing at the BIOS (issue #4075). Missing entries are +// never selectable here, so a platform whose only BIOS is gone boots with none +// rather than with a file the server can't serve. + +// Only these fields are read, so both `FirmwareSchema` and lighter shapes fit. +interface FirmwareLike { + id: number; + file_name: string; + missing_from_fs: boolean; +} + +export function resolveInitialFirmware({ + options, + storedBiosId, + configBiosFile, +}: { + options: readonly T[]; + // The user's last pick for this platform, from localStorage. + storedBiosId: string | null; + // `bios_file` from the selected core's EJS config. Typed as the config's + // own `string | boolean` since most EJS settings are toggles; only a + // string names a file. + configBiosFile: string | boolean | undefined; +}): T | null { + const usable = options.filter((f) => !f.missing_from_fs); + + const fromStorage = storedBiosId + ? usable.find((f) => f.id === parseInt(storedBiosId)) + : undefined; + const fromConfig = + typeof configBiosFile === "string" + ? usable.find((f) => f.file_name === configBiosFile) + : undefined; + // Auto-select only when the choice is unambiguous. + const fromSingleOption = usable.length === 1 ? usable[0] : undefined; + + return fromStorage ?? fromConfig ?? fromSingleOption ?? null; +} diff --git a/frontend/src/v2/views/Player/EmulatorJS.vue b/frontend/src/v2/views/Player/EmulatorJS.vue index 2b0d37ba6d..bcf05e66dc 100644 --- a/frontend/src/v2/views/Player/EmulatorJS.vue +++ b/frontend/src/v2/views/Player/EmulatorJS.vue @@ -58,6 +58,7 @@ import { resolveStoredBezelVisible, } from "@/v2/utils/playerBezel"; import { resolveStoredDisc } from "@/v2/utils/playerDisc"; +import { resolveInitialFirmware } from "@/v2/utils/playerFirmware"; import { installIOSFullscreenShim } from "@/views/Player/EmulatorJS/utils"; // Reuse v1's heavy emulator integration — do NOT rewrite this. Lazy so the @@ -373,8 +374,10 @@ onMounted(async () => { }); rom.value = romResponse.data; + // Firmware whose file is gone can't be served, so it isn't a BIOS choice. const firmwareResponse = await firmwareApi.getFirmware({ platformId: romResponse.data.platform_id, + missing: false, }); firmwareOptions.value = firmwareResponse.data; @@ -443,19 +446,11 @@ onMounted(async () => { `player:${rom.value.platform_slug}:bios_id`, ); - const biosFromStorage = storedBiosID - ? firmwareOptions.value.find((f) => f.id === parseInt(storedBiosID)) - : undefined; - const biosFromConfig = coreOptions["bios_file"] - ? firmwareOptions.value.find( - (f) => f.file_name === coreOptions["bios_file"], - ) - : undefined; - const biosFromSingleOption = - firmwareOptions.value.length === 1 ? firmwareOptions.value[0] : undefined; - - selectedFirmware.value = - biosFromStorage ?? biosFromConfig ?? biosFromSingleOption ?? null; + selectedFirmware.value = resolveInitialFirmware({ + options: firmwareOptions.value, + storedBiosId: storedBiosID, + configBiosFile: coreOptions["bios_file"], + }); // Autofocus the Play CTA so gamepad/keyboard users land on the // primary action without an extra Tab. Mouse / touch keep the diff --git a/frontend/src/v2/views/Settings/LibraryManagement.vue b/frontend/src/v2/views/Settings/LibraryManagement.vue index 44a3813c9e..9845e3d4b2 100644 --- a/frontend/src/v2/views/Settings/LibraryManagement.vue +++ b/frontend/src/v2/views/Settings/LibraryManagement.vue @@ -10,14 +10,15 @@ import { useRoute, useRouter } from "vue-router"; import storeConfig from "@/stores/config"; import ExcludedSection from "@/v2/components/Settings/ExcludedSection.vue"; import FolderMappingsSection from "@/v2/components/Settings/FolderMappingsSection.vue"; +import MissingFirmwareSection from "@/v2/components/Settings/MissingFirmwareSection.vue"; import MissingGamesSection from "@/v2/components/Settings/MissingGamesSection.vue"; const { t } = useI18n(); const route = useRoute(); const router = useRouter(); -type Tab = "mapping" | "excluded" | "missing"; -const validTabs: Tab[] = ["mapping", "excluded", "missing"]; +type Tab = "mapping" | "excluded" | "missing" | "missing-firmware"; +const validTabs: Tab[] = ["mapping", "excluded", "missing", "missing-firmware"]; const tab = ref( (validTabs as string[]).includes(route.query.tab as string) @@ -64,6 +65,11 @@ const tabs = computed(() => [ label: t("settings.missing-games-tab"), icon: "mdi-folder-question-outline", }, + { + id: "missing-firmware", + label: t("settings.missing-firmware-tab"), + icon: "mdi-memory", + }, ]); // Bridge between RTabNav's string modelValue and our Tab union. @@ -111,6 +117,7 @@ const tabModel = computed({ +