diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index cae81edc3..c2e761e13 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -1470,6 +1470,42 @@ async def delete_roms( } +@protected_route(router.post, "/cleanup-missing", [Scope.ROMS_WRITE]) +async def cleanup_missing_roms( + request: Request, + platform_id: Annotated[ + int | None, + Body( + description="Optional platform ID to restrict cleanup to a single platform.", + embed=True, + ), + ] = None, +) -> dict: + """Enqueue a background task to delete all ROMs flagged as missing from the filesystem. + + Optionally restrict the cleanup to a single platform by providing a platform_id. + """ + from config import TASK_RESULT_TTL, TASK_TIMEOUT + from handler.redis_handler import low_prio_queue + from tasks.manual.cleanup_missing_roms import cleanup_missing_roms_task + + job = low_prio_queue.enqueue( + cleanup_missing_roms_task.run, + kwargs={"platform_id": platform_id}, + job_timeout=TASK_TIMEOUT, + result_ttl=TASK_RESULT_TTL, + meta={ + "task_name": cleanup_missing_roms_task.title, + "task_type": cleanup_missing_roms_task.task_type.value, + }, + ) + + return { + "task_id": job.get_id(), + "status": str(job.get_status()), + } + + @protected_route( router.put, "/{id}/props", diff --git a/backend/endpoints/tasks.py b/backend/endpoints/tasks.py index 9362d37f5..f20264b9e 100644 --- a/backend/endpoints/tasks.py +++ b/backend/endpoints/tasks.py @@ -32,6 +32,7 @@ from handler.redis_handler import ( low_prio_queue, redis_client, ) +from tasks.manual.cleanup_missing_roms import cleanup_missing_roms_task from tasks.manual.cleanup_orphaned_resources import cleanup_orphaned_resources_task from tasks.scheduled.convert_images_to_webp import convert_images_to_webp_task from tasks.scheduled.scan_library import scan_library_task @@ -98,6 +99,13 @@ manual_tasks: list[ManualTask] = [ "task": cleanup_orphaned_resources_task, } ), + ManualTask( + { + "name": "cleanup_missing_roms", + "type": TaskType.CLEANUP, + "task": cleanup_missing_roms_task, + } + ), ] diff --git a/backend/tasks/manual/cleanup_missing_roms.py b/backend/tasks/manual/cleanup_missing_roms.py new file mode 100644 index 000000000..a89432594 --- /dev/null +++ b/backend/tasks/manual/cleanup_missing_roms.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass +from typing import Any + +from handler.database import db_rom_handler +from handler.filesystem import fs_resource_handler +from logger.logger import log +from tasks.tasks import Task, TaskType, update_job_meta +from utils.context import initialize_context + + +@dataclass +class CleanupMissingRomsStats: + """Statistics for missing ROMs cleanup operations.""" + + platform_id: int | None = None + roms_found: int = 0 + roms_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, + "roms_found": self.roms_found, + "roms_deleted": self.roms_deleted, + "errors": self.errors, + } + + +class CleanupMissingRomsTask(Task): + def __init__(self): + super().__init__( + title="Cleanup missing ROMs", + description="Delete all ROMs 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 ROMs that are flagged as missing from the filesystem.""" + log.info(f"Starting {self.title} task...") + + stats = CleanupMissingRomsStats(platform_id=platform_id) + + filter_kwargs: dict[str, Any] = {"missing": True} + if platform_id is not None: + filter_kwargs["platform_ids"] = [platform_id] + + missing_roms = db_rom_handler.get_roms_scalar(**filter_kwargs) + + stats.update(roms_found=len(missing_roms)) + log.info( + f"Found {len(missing_roms)} missing ROM(s) to clean up" + + (f" for platform ID {platform_id}" if platform_id else "") + ) + + for rom in missing_roms: + try: + log.info( + f"Deleting missing ROM '{rom.name or rom.fs_name}' [ID: {rom.id}] from database" + ) + db_rom_handler.delete_rom(rom.id) + + try: + await fs_resource_handler.remove_directory(rom.fs_resources_path) + except FileNotFoundError: + log.warning( + f"Couldn't find resources to delete for '{rom.name or rom.fs_name}'" + ) + + stats.update(roms_deleted=stats.roms_deleted + 1) + except Exception as e: + log.error(f"Failed to delete missing ROM {rom.id}: {e}") + stats.update(errors=stats.errors + 1) + + log.info( + f"Cleanup of missing ROMs completed: {stats.roms_deleted} deleted, {stats.errors} error(s)" + ) + return stats.to_dict() + + +cleanup_missing_roms_task = CleanupMissingRomsTask() diff --git a/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue b/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue index cb1be5196..900493026 100644 --- a/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue +++ b/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue @@ -14,6 +14,7 @@ import storeGalleryFilter from "@/stores/galleryFilter"; import storeGalleryView from "@/stores/galleryView"; import storePlatforms from "@/stores/platforms"; import storeRoms from "@/stores/roms"; +import romApi from "@/services/api/rom"; import type { Events } from "@/types/emitter"; const { t } = useI18n(); @@ -26,6 +27,7 @@ const { selectedPlatform } = storeToRefs(galleryFilterStore); const platformsStore = storePlatforms(); const emitter = inject>("emitter"); const loading = ref(false); +const cleaningUp = ref(false); let timeout: ReturnType = setTimeout(() => {}, 400); const allPlatforms = computed(() => @@ -87,39 +89,31 @@ async function fetchRoms() { }); } -function cleanupAll() { - romsStore.setLimit(10000); - galleryFilterStore.setFilterMissing(true); - romsStore - .fetchRoms() - .then(() => { - emitter?.emit("showLoadingDialog", { - loading: false, - scrim: false, - }); - if (filteredRoms.value.length > 0) { - emitter?.emit("showDeleteRomDialog", filteredRoms.value); - } else { - emitter?.emit("snackbarShow", { - msg: t("settings.no-missing-roms-to-delete"), - icon: "mdi-close-circle", - color: "red", - timeout: 4000, - }); - } - }) - .catch((error) => { - console.error("Error fetching missing games:", error); - emitter?.emit("snackbarShow", { - msg: t("settings.couldnt-fetch-missing-roms", { error }), - icon: "mdi-close-circle", - color: "red", - timeout: 4000, - }); - }) - .finally(() => { - galleryFilterStore.setFilterMissing(false); +async function cleanupAll() { + if (cleaningUp.value) return; + + cleaningUp.value = true; + try { + await romApi.cleanupMissingRoms({ + platformId: selectedPlatform.value?.id, }); + emitter?.emit("snackbarShow", { + msg: t("settings.cleanup-all-queued"), + icon: "mdi-check-circle", + color: "green", + timeout: 5000, + }); + } catch (error) { + console.error("Error queuing cleanup task:", error); + emitter?.emit("snackbarShow", { + msg: t("settings.cleanup-all-error", { error }), + icon: "mdi-close-circle", + color: "red", + timeout: 4000, + }); + } finally { + cleaningUp.value = false; + } } function resetMissingRoms() { @@ -231,6 +225,7 @@ onUnmounted(() => { size="large" class="text-romm-red bg-toplayer" variant="flat" + :loading="cleaningUp" @click="cleanupAll" > {{ t("settings.cleanup-all") }} diff --git a/frontend/src/locales/en_US/settings.json b/frontend/src/locales/en_US/settings.json index 08c1d5b0e..ad4127db2 100644 --- a/frontend/src/locales/en_US/settings.json +++ b/frontend/src/locales/en_US/settings.json @@ -15,6 +15,8 @@ "canceled": "Canceled", "cleanup": "Cleanup", "cleanup-all": "Clean up all", + "cleanup-all-error": "Couldn't queue cleanup task: {error}", + "cleanup-all-queued": "Cleanup task queued. Missing ROMs will be deleted in the background.", "completed": "Completed", "config-file-not-mounted-desc": "The config.yml file has not been mounted. Any changes made to the configuration will not persist after the application restarts.", "config-file-not-mounted-title": "Configuration file not mounted!", diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index 626268b40..0f642699b 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -571,6 +571,18 @@ async function deleteRoms({ return api.post("/roms/delete", payload); } +async function cleanupMissingRoms({ + platformId, +}: { + platformId?: number | null; +} = {}) { + const payload = platformId ? { platform_id: platformId } : {}; + return api.post<{ task_id: string; status: string }>( + "/roms/cleanup-missing", + payload, + ); +} + // Multi-note management functions async function createRomNote({ romId, @@ -653,6 +665,7 @@ export default { removeManual, updateUserRomProps, deleteRoms, + cleanupMissingRoms, createRomNote, updateRomNote, deleteRomNote,