From 8cb4d619b49cd86c0115a55ad230b1a8b818ce87 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:25:01 +0000 Subject: [PATCH 1/8] =?UTF-8?q?fix(routes):=20review=20sweep=20=E2=80=94?= =?UTF-8?q?=20library,=20reports,=20tools,=20controllers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Screenshot dedup scans/deletes under IMAGE_PATH, not the bundle STATIC_PATH - Multi-disc scan lists each disc file once (case-insensitive FS double-count) - Default-controller auto-assign tries longest folder pattern first (snes != nes) - /api/controllers: one systems query instead of one per controller - Calculate storage: one batched write instead of a commit per game - Local search clamps negative limit; similar-games escapes LIKE wildcards - AI Fill invalidates the Library filter cache - Case-only ROM renames allowed (samefile check) - Database optimize closes its connection when VACUUM fails - ROM-tools task cleanup iterates a snapshot; browse-folders single scandir Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013s5ztLFVCcdTjjTeVcTnJr --- routes/controllers.py | 31 ++++--- routes/games_ai.py | 5 ++ routes/games_media.py | 4 +- routes/games_search.py | 17 ++-- routes/maintenance.py | 11 ++- routes/reports.py | 50 ++++++----- routes/systems.py | 11 +-- routes/tools.py | 24 ++++-- tests/test_route_review_fixes.py | 141 +++++++++++++++++++++++++++++++ 9 files changed, 238 insertions(+), 56 deletions(-) create mode 100644 tests/test_route_review_fixes.py diff --git a/routes/controllers.py b/routes/controllers.py index 54ac11be..32cf50e1 100644 --- a/routes/controllers.py +++ b/routes/controllers.py @@ -155,19 +155,20 @@ def api_get_all_controllers(): ORDER BY c.manufacturer COLLATE NOCASE, c.name COLLATE NOCASE """) - # For each controller, get all associated systems + # Every controller's systems in one query, not one query per controller. + systems_by_controller = {} + for row in query(""" + SELECT sc.controller_id, s.id, s.name + FROM systems s + JOIN system_controllers sc ON s.id = sc.system_id + ORDER BY s.name COLLATE NOCASE + """): + systems_by_controller.setdefault(row['controller_id'], []).append(row) + result = [] for c in controllers: ctrl = dict(c) - - # Get systems from junction table - systems = query(""" - SELECT s.id, s.name - FROM systems s - JOIN system_controllers sc ON s.id = sc.system_id - WHERE sc.controller_id = ? - ORDER BY s.name COLLATE NOCASE - """, (ctrl['id'],)) + systems = systems_by_controller.get(ctrl['id'], []) ctrl['system_ids'] = [s['id'] for s in systems] ctrl['system_names'] = [s['name'] for s in systems] @@ -558,12 +559,18 @@ def api_assign_default_controllers(): 'xbox360': ['Xbox 360'], } + patterns_longest_first = sorted(controller_mappings, key=len, reverse=True) + for system in systems: folder_lower = system['folder'].lower().replace('-', '').replace('_', '').replace(' ', '') - # Try to find a matching controller + # Try to find a matching controller. Longest pattern first: a bare + # substring scan in dict order typed 'snes' as 'nes', 'gba' as 'gb', + # 'wiiu' as 'wii' and 'xbox360' as 'xbox' (same bug Pass 48.5 fixed in + # systems.update_system_types). matched_controller = None - for folder_pattern, controller_names in controller_mappings.items(): + for folder_pattern in patterns_longest_first: + controller_names = controller_mappings[folder_pattern] if folder_pattern in folder_lower: # Search for any matching controller for ctrl_name in controller_names: diff --git a/routes/games_ai.py b/routes/games_ai.py index f421c1a9..c1fd9e92 100644 --- a/routes/games_ai.py +++ b/routes/games_ai.py @@ -17,6 +17,7 @@ from services.api_helpers import handle_api_errors, success, error from services.auth import editor_required from services.database import query, execute +from services.game_query import invalidate_filter_cache from services.game_metadata_service import cross_map_ratings from services.game_utils import ( generate_sort_title, infer_rating_from_content, @@ -242,6 +243,10 @@ def api_game_ai_fill(game_id): if sets: assignments = ', '.join(f'{col} = ?' for col in sets) execute(f"UPDATE games SET {assignments} WHERE id = ?", (*sets.values(), game_id)) + # AI fill writes genre/developer/publisher/franchise/modes/ratings, + # all of which feed the Library filter dropdowns — same cache + # discipline as the edit paths in routes/games.py. + invalidate_filter_cache() invalidate_analytics_cache() verify = query("SELECT esrb_rating, edition FROM games WHERE id = ?", (game_id,), one=True) diff --git a/routes/games_media.py b/routes/games_media.py index ad4d6741..d9e5cc0d 100644 --- a/routes/games_media.py +++ b/routes/games_media.py @@ -108,7 +108,9 @@ def api_rename_rom(game_id): if problem: return error(_('Cannot rename: %(reason)s') % {'reason': problem}, 400) - if os.path.exists(new_path) and new_path != old_path: + # samefile, not !=: on a case-insensitive filesystem (Windows, + # macOS) a case-only rename finds its own source "existing". + if os.path.exists(new_path) and not os.path.samefile(new_path, old_path): return error(_('A file with that name already exists'), 400) try: diff --git a/routes/games_search.py b/routes/games_search.py index 5b709dd5..63d9ebee 100644 --- a/routes/games_search.py +++ b/routes/games_search.py @@ -82,7 +82,9 @@ def api_search_games(): def api_local_search_games(): """Search local game library by title. Used by lists, compare, etc.""" q = request.args.get('q', '').strip() - limit = min(request.args.get('limit', 20, type=int), 50) + # Floor at 1: SQLite reads a negative LIMIT as "no limit", so + # ?limit=-1 returned the whole library. + limit = min(max(request.args.get('limit', 20, type=int), 1), 50) if not q or len(q) < 2: return success(games=[]) @@ -119,9 +121,9 @@ def api_similar_games(game_id): matches = query(""" SELECT g.id, g.title, g.boxart, s.name AS system_name FROM games g JOIN systems s ON g.system_id = s.id - WHERE g.franchise LIKE ? AND g.id != ? AND g.is_bonus_disc = 0 + WHERE g.franchise LIKE ? ESCAPE '\\' AND g.id != ? AND g.is_bonus_disc = 0 ORDER BY g.title LIMIT 5 - """, [f'%{franchise}%', game_id]) + """, [f'%{escape_like(franchise)}%', game_id]) for m in matches: if m['id'] not in seen_ids: similar.append({'id': m['id'], 'title': m['title'], 'boxart': m['boxart'], 'system_name': m['system_name'], 'reason': f'Same franchise: {franchise}'}) @@ -135,9 +137,10 @@ def api_similar_games(game_id): matches = query(""" SELECT g.id, g.title, g.boxart, s.name AS system_name FROM games g JOIN systems s ON g.system_id = s.id - WHERE g.genre LIKE ? AND g.developer LIKE ? AND g.id != ? AND g.is_bonus_disc = 0 + WHERE g.genre LIKE ? ESCAPE '\\' AND g.developer LIKE ? ESCAPE '\\' + AND g.id != ? AND g.is_bonus_disc = 0 ORDER BY g.title LIMIT 5 - """, [f'%{genres[0]}%', f'%{devs[0]}%', game_id]) + """, [f'%{escape_like(genres[0])}%', f'%{escape_like(devs[0])}%', game_id]) for m in matches: if m['id'] not in seen_ids: similar.append({'id': m['id'], 'title': m['title'], 'boxart': m['boxart'], 'system_name': m['system_name'], 'reason': 'Same genre & developer'}) @@ -154,9 +157,9 @@ def api_similar_games(game_id): matches = query(f""" SELECT g.id, g.title, g.boxart, s.name AS system_name FROM games g JOIN systems s ON g.system_id = s.id - WHERE g.genre LIKE ? AND g.system_id = ? AND g.id NOT IN ({placeholders}) AND g.is_bonus_disc = 0 + WHERE g.genre LIKE ? ESCAPE '\\' AND g.system_id = ? AND g.id NOT IN ({placeholders}) AND g.is_bonus_disc = 0 ORDER BY RANDOM() LIMIT ? - """, [f'%{genres[0]}%', game['system_id']] + seen_ids_list + [8 - len(similar)]) + """, [f'%{escape_like(genres[0])}%', game['system_id']] + seen_ids_list + [8 - len(similar)]) for m in matches: if m['id'] not in seen_ids: similar.append({'id': m['id'], 'title': m['title'], 'boxart': m['boxart'], 'system_name': m['system_name'], 'reason': 'Similar genre on same system'}) diff --git a/routes/maintenance.py b/routes/maintenance.py index d4db172e..e70062e0 100644 --- a/routes/maintenance.py +++ b/routes/maintenance.py @@ -224,10 +224,13 @@ def api_database_optimize(): start_time = time.time() conn = get_db() - conn.execute("VACUUM") - conn.execute("ANALYZE") - conn.execute("PRAGMA optimize") - conn.close() + try: + conn.execute("VACUUM") + conn.execute("ANALYZE") + conn.execute("PRAGMA optimize") + finally: + # VACUUM raises "database is locked" while a job writes; close anyway. + conn.close() duration = time.time() - start_time size_after = os.path.getsize(db_path) diff --git a/routes/reports.py b/routes/reports.py index cba49bb8..da8698b9 100644 --- a/routes/reports.py +++ b/routes/reports.py @@ -10,7 +10,6 @@ import os import shutil import re -import glob import logging # Import configuration @@ -387,6 +386,21 @@ def regions_match(file_region, db_region): # API ROUTES - Multi-Disc Scan # ============================================================================= +def _disc_files_in(folder, extensions): + """Top-level files in `folder` whose extension is one of `extensions`, + matched case-insensitively, each listed once. + + The old per-extension glob of `*.bin` plus `*.BIN` listed every file + twice where the filesystem ignores case (Windows, macOS), so one + "Game (Disc 1).bin" looked like a two-disc set. + """ + wanted = {e.lower() for e in extensions} + with os.scandir(folder) as entries: + return [e.path for e in entries + if not e.name.startswith('.') and e.is_file() + and os.path.splitext(e.name)[1].lower() in wanted] + + @reports_bp.route('/api/reports/multidisc-scan', methods=['POST']) @login_required @handle_api_errors @@ -441,15 +455,7 @@ def api_reports_multidisc_scan(): scanned_systems.append(system) # Find all disc files in this system folder (top level only - not in subdirectories) - disc_files = [] - for ext in disc_extensions: - # Only look at top level files - found = glob.glob(os.path.join(system_path, f'*{ext}')) - disc_files.extend(found) - # Also check case-insensitive - if ext != ext.upper(): - found_upper = glob.glob(os.path.join(system_path, f'*{ext.upper()}')) - disc_files.extend(found_upper) + disc_files = _disc_files_in(system_path, disc_extensions) logger.info(f"Multi-disc scan: Found {len(disc_files)} top-level disc files in {system}") @@ -463,13 +469,7 @@ def api_reports_multidisc_scan(): m3u_exists = os.path.exists(os.path.join(system_path, f"{subfolder}.m3u")) # Count disc files in this subfolder - subfolder_disc_files = [] - for ext in disc_extensions: - found = glob.glob(os.path.join(subfolder_path, f'*{ext}')) - subfolder_disc_files.extend(found) - if ext != ext.upper(): - found_upper = glob.glob(os.path.join(subfolder_path, f'*{ext.upper()}')) - subfolder_disc_files.extend(found_upper) + subfolder_disc_files = _disc_files_in(subfolder_path, disc_extensions) # Check if any files look like multi-disc has_disc_indicator = False @@ -725,7 +725,9 @@ def api_reports_rename_rom(): return error(_('Cannot rename: %(reason)s') % {'reason': problem}, 400) # Check if new name already exists - if os.path.exists(new_path) and new_path != old_path: + # samefile, not !=: on a case-insensitive filesystem (Windows, + # macOS) a case-only rename finds its own source "existing". + if os.path.exists(new_path) and not os.path.samefile(new_path, old_path): return error(_('A file with that name already exists'), 400) # Check if this is an M3U file @@ -747,7 +749,9 @@ def api_reports_rename_rom(): # Check if associated folder exists if os.path.isdir(old_folder): # Check if new folder name already exists - if os.path.exists(new_folder) and new_folder != old_folder: + # samefile, not !=: on a case-insensitive filesystem (Windows, + # macOS) a case-only rename finds its own source "existing". + if os.path.exists(new_folder) and not os.path.samefile(new_folder, old_folder): return error(_('A folder with the new name already exists'), 400) # Rename the folder first @@ -892,7 +896,9 @@ def api_reports_rename_to_scraped(): return error(_('Destination is outside the configured ROM root'), 400) # Check if new name already exists - if os.path.exists(new_path) and new_path != old_path: + # samefile, not !=: on a case-insensitive filesystem (Windows, + # macOS) a case-only rename finds its own source "existing". + if os.path.exists(new_path) and not os.path.samefile(new_path, old_path): return error(_('A file with that name already exists'), 400) # Handle M3U files specially - need to rename folder and update M3U contents @@ -904,7 +910,9 @@ def api_reports_rename_to_scraped(): # Check if associated folder exists if os.path.isdir(old_folder): # Check if new folder name already exists - if os.path.exists(new_folder) and new_folder != old_folder: + # samefile, not !=: on a case-insensitive filesystem (Windows, + # macOS) a case-only rename finds its own source "existing". + if os.path.exists(new_folder) and not os.path.samefile(new_folder, old_folder): return error(_('A folder with the new name already exists'), 400) # Rename the folder first diff --git a/routes/systems.py b/routes/systems.py index 38b574cf..96a2de48 100644 --- a/routes/systems.py +++ b/routes/systems.py @@ -446,16 +446,17 @@ def api_calculate_storage(): """Calculate and update file sizes for all games""" try: games = query("SELECT id, rom_path FROM games WHERE file_size IS NULL OR file_size = 0") - updated = 0 - + # One batched write (one commit) instead of a commit per game. + sizes = [] for game in games: if game['rom_path'] and os.path.exists(game['rom_path']): try: - size = os.path.getsize(game['rom_path']) - execute("UPDATE games SET file_size = ? WHERE id = ?", (size, game['id'])) - updated += 1 + sizes.append((os.path.getsize(game['rom_path']), game['id'])) except Exception as e: logger.warning(f"Could not get size for {game['rom_path']}: {e}") + if sizes: + execute_many("UPDATE games SET file_size = ? WHERE id = ?", sizes) + updated = len(sizes) return success( updated=updated, diff --git a/routes/tools.py b/routes/tools.py index a9e9144a..4bd9fa64 100644 --- a/routes/tools.py +++ b/routes/tools.py @@ -60,7 +60,8 @@ def _cleanup_completed_tasks(): """Remove completed/failed/cancelled tasks older than 30 minutes.""" now = datetime.now() to_remove = [] - for task_id, task in _rom_tool_tasks.items(): + # Snapshot: request threads may add tasks while this iterates. + for task_id, task in list(_rom_tool_tasks.items()): status = task.get('status') if status in ('completed', 'failed', 'cancelled'): end_time = task.get('end_time') @@ -70,7 +71,7 @@ def _cleanup_completed_tasks(): # No end_time recorded — stamp it now so it gets cleaned up next cycle task['end_time'] = now for task_id in to_remove: - del _rom_tool_tasks[task_id] + _rom_tool_tasks.pop(task_id, None) def _wait_if_paused(task): @@ -337,9 +338,16 @@ def api_rom_tools_browse_folders(): for item in sorted(os.listdir(current_path)): item_path = os.path.join(current_path, item) if os.path.isdir(item_path) and not item.startswith('.'): + # One scandir pass (d_type, no per-entry stat on most + # filesystems) instead of two listdir + isfile/isdir walks. + file_count = subfolder_count = 0 try: - file_count = len([f for f in os.listdir(item_path) if os.path.isfile(os.path.join(item_path, f))]) - subfolder_count = len([f for f in os.listdir(item_path) if os.path.isdir(os.path.join(item_path, f))]) + with os.scandir(item_path) as entries: + for entry in entries: + if entry.is_file(): + file_count += 1 + elif entry.is_dir(): + subfolder_count += 1 except PermissionError: file_count = 0 subfolder_count = 0 @@ -1253,7 +1261,9 @@ def _visual_group(file_data, threshold): def _run_screenshot_dedup_scan(task, method, threshold): """Background task: scan games for duplicate screenshots""" - screenshots_dir = os.path.join(config.STATIC_PATH, 'images', 'screenshots') + # Scraped media lives under IMAGE_PATH (the writable data root), not the + # read-only bundle STATIC_PATH — they differ in a frozen build. + screenshots_dir = os.path.join(config.IMAGE_PATH, 'screenshots') games = query(""" SELECT g.id, g.title, g.screenshots, s.name as system_name @@ -1415,7 +1425,9 @@ def api_screenshot_dedup_delete(): data = request.get_json() or {} deletions = data.get('deletions', []) # [{game_id, filename}, ...] - screenshots_dir = os.path.join(config.STATIC_PATH, 'images', 'screenshots') + # Scraped media lives under IMAGE_PATH (the writable data root), not the + # read-only bundle STATIC_PATH — they differ in a frozen build. + screenshots_dir = os.path.join(config.IMAGE_PATH, 'screenshots') results = {'deleted': 0, 'failed': 0, 'freed_space': 0, 'db_updated': 0} # Group by game_id diff --git a/tests/test_route_review_fixes.py b/tests/test_route_review_fixes.py new file mode 100644 index 00000000..042d7108 --- /dev/null +++ b/tests/test_route_review_fixes.py @@ -0,0 +1,141 @@ +"""Regression pins for route-layer fixes from the routes/ review pass. + +- Screenshot dedup reads screenshots from IMAGE_PATH (scraped media), not + the bundle's STATIC_PATH (they differ in a frozen build). +- Multi-disc scan lists each disc file once, matching extensions in any case. +- Auto-assign default controllers prefers the longest folder pattern, so an + SNES folder is not given the NES controller. +- /api/controllers fetches every controller's systems in one query. +- /api/calculate-storage writes sizes in one batch. +- /api/games/find floors `limit` (a negative LIMIT is unlimited in SQLite). +""" +import os +import threading +from types import SimpleNamespace + +from tests.test_pass59_rom_tools import _InlineThread, rom_client # noqa: F401 + + +# --- Screenshot dedup: IMAGE_PATH ----------------------------------------- + +def test_screenshot_dedup_scans_image_path(rom_client, monkeypatch, tmp_path): # noqa: F811 + import routes.tools as tools + post, _roms = rom_client + images = tmp_path / 'images' + (images / 'screenshots').mkdir(parents=True) + bundle = tmp_path / 'bundle' + (bundle / 'images' / 'screenshots').mkdir(parents=True) + for name in ('1_a.png', '1_b.png'): + (images / 'screenshots' / name).write_bytes(b'same bytes') + monkeypatch.setattr(tools.config, 'IMAGE_PATH', str(images)) + monkeypatch.setattr(tools.config, 'STATIC_PATH', str(bundle)) + monkeypatch.setattr(tools, 'query', lambda *_a, **_k: [ + {'id': 1, 'title': 'Game', 'screenshots': '1_a.png, 1_b.png', 'system_name': 'NES'}]) + shim = SimpleNamespace(**{**vars(threading), 'Thread': _InlineThread}) + monkeypatch.setattr(tools, 'threading', shim) + + resp = post('/api/rom-tools/screenshot-dedup/scan', {'method': 'hash'}) + task = tools._rom_tool_tasks[resp.get_json()['task_id']] + assert task['status'] == 'completed' + assert task['results']['games_with_dupes'] == 1 + assert task['results']['total_duplicates'] == 1 + + +# --- Multi-disc scan: one entry per file, any extension case -------------- + +def test_disc_files_listed_once_in_any_case(tmp_path): + from routes.reports import _disc_files_in + for name in ('Game (Disc 1).bin', 'Game (Disc 2).BIN', 'Other.Iso', + 'readme.txt', '.hidden.bin'): + (tmp_path / name).write_bytes(b'x') + (tmp_path / 'folder.iso').mkdir() + found = sorted(os.path.basename(p) for p in _disc_files_in(str(tmp_path), ['.bin', '.iso'])) + assert found == ['Game (Disc 1).bin', 'Game (Disc 2).BIN', 'Other.Iso'] + + +def test_single_disc_is_not_reported_as_multi_disc(rom_client, monkeypatch): # noqa: F811 + import routes.reports as reports + post, roms = rom_client + psx = roms / 'psx' + psx.mkdir() + (psx / 'Solo (Disc 1).bin').write_bytes(b'x') + monkeypatch.setattr(reports, 'query', lambda *_a, **_k: {'folder': 'psx'}) + resp = post('/api/reports/multidisc-scan', {'system': 'psx'}) + assert resp.status_code == 200 + assert resp.get_json()['games'] == [] + + +# --- Auto-assign default controllers: longest pattern wins ---------------- + +def test_assign_default_controller_snes_gets_snes_controller(rom_client, monkeypatch): # noqa: F811 + import routes.controllers as controllers + post, _roms = rom_client + library = [(1, 'NES Controller'), (2, 'SNES Controller'), + (3, 'Game Boy'), (4, 'Game Boy Advance')] + + def fake_query(sql, args=(), one=False): + if 'FROM systems' in sql: + return [{'id': 10, 'name': 'SNES', 'folder': 'snes'}, + {'id': 11, 'name': 'GBA', 'folder': 'gba'}] + needle = args[0].strip('%') + hits = [{'id': cid} for cid, name in library if needle in name] + return hits[0] if hits else None + + writes = [] + monkeypatch.setattr(controllers, 'query', fake_query) + monkeypatch.setattr(controllers, 'execute', lambda sql, args=(): writes.append(args)) + resp = post('/api/systems/assign-default-controllers', {}) + assert resp.status_code == 200 + assert dict((sid, cid) for cid, sid in writes) == {10: 2, 11: 4} + + +# --- /api/controllers: systems in one query ------------------------------- + +def test_controllers_list_groups_systems_without_n_plus_one(rom_client, monkeypatch): # noqa: F811 + import app as app_module + import routes.controllers as controllers + calls = [] + + def fake_query(sql, args=(), one=False): + calls.append(sql) + if 'FROM controllers c' in sql: + return [{'id': 1, 'name': 'Pad'}, {'id': 2, 'name': 'Stick'}] + return [{'controller_id': 1, 'id': 5, 'name': 'NES'}, + {'controller_id': 1, 'id': 6, 'name': 'SNES'}] + monkeypatch.setattr(controllers, 'query', fake_query) + resp = app_module.app.test_client().get('/api/controllers') + body = resp.get_json() + assert len(calls) == 2 + by_id = {c['id']: c for c in body['controllers']} + assert by_id[1]['system_ids'] == [5, 6] + assert by_id[1]['system_name'] == 'NES, SNES' + assert by_id[2]['system_ids'] == [] and by_id[2]['system_name'] is None + + +# --- /api/calculate-storage: one batched write ---------------------------- + +def test_calculate_storage_batches_writes(rom_client, monkeypatch): # noqa: F811 + import routes.systems as systems + post, roms = rom_client + a, b = roms / 'a.nes', roms / 'b.nes' + a.write_bytes(b'xx') + b.write_bytes(b'xxxx') + monkeypatch.setattr(systems, 'query', lambda *_a, **_k: [ + {'id': 1, 'rom_path': str(a)}, {'id': 2, 'rom_path': str(b)}, + {'id': 3, 'rom_path': str(roms / 'gone.nes')}]) + batches = [] + monkeypatch.setattr(systems, 'execute_many', lambda sql, rows: batches.append(rows)) + resp = post('/api/calculate-storage', {}) + assert resp.get_json()['updated'] == 2 + assert batches == [[(2, 1), (4, 2)]] + + +# --- /api/games/find: limit floored --------------------------------------- + +def test_local_search_floors_negative_limit(rom_client, monkeypatch): # noqa: F811 + import app as app_module + import routes.games_search as gs + seen = [] + monkeypatch.setattr(gs, 'query', lambda sql, args=(): seen.append(args) or []) + app_module.app.test_client().get('/api/games/find?q=zelda&limit=-1') + assert seen and seen[0][-1] == 1 From 9534c0bf7d43ac4c7ce816ac43088163891e14a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:25:59 +0000 Subject: [PATCH 2/8] =?UTF-8?q?fix(services,jobs):=20review=20sweep=20?= =?UTF-8?q?=E2=80=94=20data=20correctness=20and=20job=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit services: - Clear scraped data commits the filename-title reset (close() rolled it back) - Analytics "not started" sums NULL and 'not_started' groups - query/execute helpers close connections they open outside an app context - JPEG save converts P/LA/PA/I/F modes, not only RGBA - Orphan-media scan looks basenames up in a set (was orphans x references) jobs: - Per-system RA refresh accepted system_name (TypeError -> 500 before) - Swapped/demoted bulk scrapes drop their 'queued' job_queue row - Bulk scrape settings load inside the try (no stuck 'running' job) - HLTB / alt-titles jobs commit per game, not holding the writer lock across 25 network calls - Player-core worker re-runs a request that raced its shutdown - Museum image fetch: import/connect failures end the job cleanly - Recovery dedup no longer collapses distinct multi-system scrapes - GUI installer surfaces worker errors; .desktop Exec paths are quoted Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013s5ztLFVCcdTjjTeVcTnJr --- docs/specs/jobs.md | 6 +- install_gui.py | 12 +- scripts/install_launcher.py | 20 +++- services/analytics.py | 10 +- services/database.py | 39 +++++-- services/game_cleanup.py | 3 + services/image_utils.py | 4 +- services/jobs/alt_titles_backfill.py | 10 +- services/jobs/base.py | 7 +- services/jobs/bulk_scrape.py | 20 +++- services/jobs/hltb_bulk.py | 10 +- services/jobs/museum_images.py | 16 ++- services/jobs/player_cores.py | 15 ++- services/jobs/ra_refresh.py | 6 +- services/media_cleanup.py | 22 ++-- tests/test_jobs_review_fixes.py | 157 +++++++++++++++++++++++++++ tests/test_services_review_fixes.py | 121 +++++++++++++++++++++ 17 files changed, 429 insertions(+), 49 deletions(-) create mode 100644 tests/test_jobs_review_fixes.py create mode 100644 tests/test_services_review_fixes.py diff --git a/docs/specs/jobs.md b/docs/specs/jobs.md index f2c078bf..ed268bdf 100644 --- a/docs/specs/jobs.md +++ b/docs/specs/jobs.md @@ -318,8 +318,10 @@ Anything not persisted in the last progress-tick window (≤ 10 items or 2. `sweep_old_job_history()` — deletes terminal rows older than retention (§10). 3. The dashboard reads `get_recoverable_jobs()` and offers a banner. - `BulkScrapeJob` deduplicates by `(system_id, scrape_mode)` and - auto-dismisses losers (`base.py:606`). + `BulkScrapeJob` deduplicates single-system scrapes by + `(system_id, scrape_mode)` and auto-dismisses losers (`base.py:606`). + Multi-system scrapes (`system_id` None) are distinct selections and are + never deduplicated. 4. User clicks "Resume" → route calls `.resume_from_params(params, progress)` which restarts the worker at `progress['current']`. **Seven classes implement resume**: `BulkScrapeJob`, `RASyncJob`, `RARefreshJob`, diff --git a/install_gui.py b/install_gui.py index 576641cf..0909cdd7 100644 --- a/install_gui.py +++ b/install_gui.py @@ -324,12 +324,22 @@ def start_install(self): self.install_btn.configure(state='disabled', bg=TEXT_MUTED, cursor='arrow') - thread = threading.Thread(target=self._install_worker, daemon=True) + thread = threading.Thread(target=self._run_install_worker, daemon=True) thread.start() self._poll_queue() # ── Install worker (background thread) ─────────────────────────────── + def _run_install_worker(self): + """Run the worker so an unexpected error (an unwritable config copy or + directory, say) still reaches the UI. Uncaught, it ended the thread + before 'done' was queued, and the window sat with Install disabled.""" + try: + self._install_worker() + except Exception as e: + self._log(f'Installer error: {e}', 'error') + self._finish(False) + def _install_worker(self): base_dir = self.base_dir errors = [] diff --git a/scripts/install_launcher.py b/scripts/install_launcher.py index 1958b688..a1b15902 100644 --- a/scripts/install_launcher.py +++ b/scripts/install_launcher.py @@ -12,6 +12,24 @@ ROOT = Path(__file__).resolve().parent.parent +# Characters that make an Exec argument need quoting (Desktop Entry spec, +# "The Exec key"). +_EXEC_RESERVED = set(' \t\n"\'\\><~|&;$*?#()`') + + +def exec_arg(arg: str) -> str: + """Quote one Exec argument per the Desktop Entry spec, so an install + path with a space (~/My Games/RetroDB) is not split into two arguments. + Inside quotes, `"`, `` ` ``, `$` and `\\` are backslash-escaped; the key + file's own string escaping then doubles every backslash; `%` is a field + code prefix and is written `%%`.""" + arg = arg.replace('%', '%%') + if not any(c in _EXEC_RESERVED for c in arg): + return arg + for ch in ('\\', '"', '`', '$'): + arg = arg.replace(ch, '\\' + ch) + return '"' + arg.replace('\\', '\\\\') + '"' + def main() -> None: if sys.platform != 'linux': @@ -28,7 +46,7 @@ def main() -> None: shutil.copyfile(src_icon, dst_icon) launcher = ROOT / 'scripts' / 'retrodb_launcher.py' - exec_line = f'{sys.executable} {launcher}' + exec_line = f'{exec_arg(sys.executable)} {exec_arg(str(launcher))}' template = (ROOT / 'packaging' / 'RetroDB.desktop').read_text() desktop = template.replace('__EXEC__', exec_line).replace('__ICON__', str(dst_icon)) diff --git a/services/analytics.py b/services/analytics.py index 814173b3..fbc8571f 100644 --- a/services/analytics.py +++ b/services/analytics.py @@ -31,7 +31,13 @@ def _get_analytics_stats(): FROM games GROUP BY completion_status """) - completion_map = {row['completion_status'] or 'not_started': row['count'] for row in completion_counts} + # NULL / '' statuses count as not started. Summed, not assigned: they + # group apart from 'not_started', and a dict comprehension let the + # later group overwrite the earlier one's count. + completion_map = {} + for row in completion_counts: + status = row['completion_status'] or 'not_started' + completion_map[status] = completion_map.get(status, 0) + row['count'] completed = completion_map.get('completed', 0) + completion_map.get('100_percent', 0) completion_rate = round((completed / total_games * 100) if total_games > 0 else 0, 1) @@ -43,7 +49,7 @@ def _get_analytics_stats(): } completion_data = [ - completion_map.get('not_started', 0) + completion_map.get(None, 0), + completion_map.get('not_started', 0), completion_map.get('in_progress', 0), completion_map.get('played', 0), completion_map.get('completed', 0), diff --git a/services/database.py b/services/database.py index d5839943..e651267b 100644 --- a/services/database.py +++ b/services/database.py @@ -128,6 +128,17 @@ def get_request_db(): return g.db +def _borrow_db(): + """(connection, owned) for one helper call. Outside an app context + get_request_db() hands out a fresh connection nobody else holds; the + helper must close it (`owned`), or every background-job query leaves an + unclosed connection for the garbage collector (a ResourceWarning on + Python 3.13+).""" + if has_app_context(): + return get_request_db(), False + return get_db(), True + + def query(sql, args=(), one=False): """ Execute a SELECT query and return results as dictionaries. @@ -155,11 +166,14 @@ def query(sql, args=(), one=False): games = query("SELECT * FROM games WHERE system_id = ? AND title LIKE ?", (system_id, f"%{search}%")) """ - conn = get_request_db() - _t0 = time.perf_counter() - cur = conn.execute(sql, args) - rows = cur.fetchall() - _log_if_slow(sql, args, _t0) + conn, owned = _borrow_db() + try: + _t0 = time.perf_counter() + rows = conn.execute(sql, args).fetchall() + _log_if_slow(sql, args, _t0) + finally: + if owned: + conn.close() # Convert sqlite3.Row objects to dicts so .get() works if one: @@ -196,7 +210,7 @@ def execute(sql, args=()): # Delete a game execute("DELETE FROM games WHERE id = ?", (game_id,)) """ - conn = get_request_db() + conn, owned = _borrow_db() _t0 = time.perf_counter() try: cur = conn.execute(sql, args) @@ -213,6 +227,9 @@ def execute(sql, args=()): except Exception: pass raise + finally: + if owned: + conn.close() _log_if_slow(sql, args, _t0) return cur.lastrowid @@ -243,7 +260,7 @@ def execute_many(sql, args_list): games_data ) """ - conn = get_request_db() + conn, owned = _borrow_db() _t0 = time.perf_counter() try: cur = conn.executemany(sql, args_list) @@ -257,6 +274,9 @@ def execute_many(sql, args_list): except Exception: pass raise + finally: + if owned: + conn.close() _log_if_slow(sql, args_list, _t0) return cur.rowcount @@ -282,7 +302,7 @@ def execute_script(sql_script): CREATE INDEX IF NOT EXISTS idx_new ON new_table(id); ''') """ - conn = get_request_db() + conn, owned = _borrow_db() try: conn.executescript(sql_script) conn.commit() @@ -298,6 +318,9 @@ def execute_script(sql_script): except Exception: pass raise + finally: + if owned: + conn.close() def _fsync_path(path): diff --git a/services/game_cleanup.py b/services/game_cleanup.py index 2d7d0c2d..bd882eb1 100644 --- a/services/game_cleanup.py +++ b/services/game_cleanup.py @@ -212,6 +212,9 @@ def clear_scraped_data(system_id=None, delete_images=False): try: for game_id in game_ids_to_reset: reset_game_title_from_filename(game_id, conn) + # A caller-supplied connection is never committed by the reset + # itself; without this the close() rolled every title back. + conn.commit() finally: conn.close() diff --git a/services/image_utils.py b/services/image_utils.py index b601d29d..58fc8a35 100644 --- a/services/image_utils.py +++ b/services/image_utils.py @@ -1026,7 +1026,9 @@ def _save_image(img, path): else: _atomic_save(img, path, 'WEBP', quality=85) elif ext in ('.jpg', '.jpeg'): - if img.mode == 'RGBA': + # JPEG holds only RGB / L / CMYK; a palette or alpha PNG (P, LA, + # PA, RGBA...) otherwise raises "cannot write mode P as JPEG". + if img.mode not in ('RGB', 'L', 'CMYK'): img = img.convert('RGB') _atomic_save(img, path, 'JPEG', quality=90) elif ext == '.png': diff --git a/services/jobs/alt_titles_backfill.py b/services/jobs/alt_titles_backfill.py index 968ef9e3..19334953 100644 --- a/services/jobs/alt_titles_backfill.py +++ b/services/jobs/alt_titles_backfill.py @@ -186,6 +186,13 @@ def _worker(self, only_empty): last_persist = time.time() try: + # Commit the previous game's writes before the network + # search: an open write transaction holds SQLite's one + # writer lock, and batching 25 games kept it across 25 + # scraper round trips, stalling every other writer. + if write_conn.in_transaction: + _commit_with_retry(write_conn) + existing = [] if game.get('alternate_titles'): try: @@ -274,9 +281,6 @@ def _worker(self, only_empty): self.updated_count += 1 self.alts_added += added - if (i + 1) % 25 == 0: - _commit_with_retry(write_conn) - except Exception as e: logger.warning(f"Alt-titles backfill: failed for game {game['id']} ({game['title']}): {e}") with self._lock: diff --git a/services/jobs/base.py b/services/jobs/base.py index 490d3c55..5d5bf07a 100644 --- a/services/jobs/base.py +++ b/services/jobs/base.py @@ -627,13 +627,16 @@ def get_recoverable_jobs(): }) # Deduplicate: for bulk_scrape, keep only the best record per - # (system_id, scrape_mode) — prefer most progress, then newest + # (system_id, scrape_mode) — prefer most progress, then newest. + # A multi-system scrape (system_id None) is a hand-picked game + # selection, so two of them are different work, not duplicates: + # keying them all to (None, mode) auto-dismissed all but one. best = {} # key -> job dismiss_ids = [] jobs = [] for job in all_jobs: - if job['job_type'] == 'bulk_scrape': + if job['job_type'] == 'bulk_scrape' and job['params'].get('system_id') is not None: sid = job['params'].get('system_id') mode = job['params'].get('scrape_mode', 'fill_missing') key = (sid, mode) diff --git a/services/jobs/bulk_scrape.py b/services/jobs/bulk_scrape.py index 22a64235..2269b9b8 100644 --- a/services/jobs/bulk_scrape.py +++ b/services/jobs/bulk_scrape.py @@ -413,6 +413,11 @@ def swap_with_running(self, job_id): logger.info(f"Swapping running job {self.job_id} with queued job {job_id}") + # The promoted job leaves the queue here, not via _start_next_queued, + # so drop its 'queued' job_queue row too — left behind, a restart + # turned it into a recoverable "interrupted" scrape that had already run. + remove_queued_job(new_running_job.get('db_queue_id')) + # Wait for the old worker to actually exit its current iteration # before we mutate state for the new job. A bare `time.sleep(0.5)` # used to race here: the worker could wake after reset() and treat @@ -495,6 +500,9 @@ def demote_running(self): logger.info(f"Demoting running job {self.job_id}, promoting {new_running_job['job_id']}") + # As in swap_with_running: the promoted job's 'queued' row goes too. + remove_queued_job(new_running_job.get('db_queue_id')) + # Same race as swap_with_running — wait for the worker to exit # before we mutate state for the new job. if old_thread is not None and old_thread.is_alive(): @@ -764,12 +772,14 @@ def _run_scrape(self): except sqlite3.Error as e: logger.warning(f"Pre-fetch failed, will fall back to per-game queries: {e}") - # Load scraper settings once before the loop - scraper_settings = load_scraper_settings() - enabled_scrapers = scraper_settings.get('enabled', {}) - match_settings = get_match_settings() - try: + # Load scraper settings once before the loop. Inside the try: a + # raise here used to kill the thread with `running` still True, + # the job_queue row 'running' and the singleton lock held. + scraper_settings = load_scraper_settings() + enabled_scrapers = scraper_settings.get('enabled', {}) + match_settings = get_match_settings() + for i, game_id in enumerate(_game_ids): # Check for cancel with self._lock: diff --git a/services/jobs/hltb_bulk.py b/services/jobs/hltb_bulk.py index 93991a2b..0c619f29 100644 --- a/services/jobs/hltb_bulk.py +++ b/services/jobs/hltb_bulk.py @@ -230,6 +230,13 @@ def _worker(self): last_persist = time.time() try: + # Commit the previous game's write before the network + # lookup: an open write transaction holds SQLite's one + # writer lock, and batching 25 games kept it across 25 + # HLTB round trips, stalling every other writer. + if write_conn.in_transaction: + _commit_with_retry(write_conn) + alt_titles = _extract_alt_titles_list(game.get('alternate_titles')) result = lookup_playtime( @@ -312,9 +319,6 @@ def _worker(self): with self._lock: self.queued_count += 1 - if (i + 1) % 25 == 0: - _commit_with_retry(write_conn) - except Exception as e: logger.warning(f"HLTB bulk: failed for game {game['id']} ({game['title']}): {e}") with self._lock: diff --git a/services/jobs/museum_images.py b/services/jobs/museum_images.py index aec9f0c4..a4ee5d3a 100644 --- a/services/jobs/museum_images.py +++ b/services/jobs/museum_images.py @@ -94,12 +94,15 @@ def cancel(self): return {'success': False, 'error': 'No running image fetch to cancel'} def _worker(self, system_id): - # Lazy import: the fetch helpers live with the Museum routes. - from routes.museum import ( - _fetch_and_process_image, _get_removebg_key, _propagate_controller_image, - ) - conn = _get_conn() + # The import and connect sit inside the try, so a failure in either + # still ends the job instead of leaving it `running` with the lock held. + conn = None try: + # Lazy import: the fetch helpers live with the Museum routes. + from routes.museum import ( + _fetch_and_process_image, _get_removebg_key, _propagate_controller_image, + ) + conn = _get_conn() controllers = conn.execute( """SELECT c.id, c.name, c.manufacturer, c.image FROM controllers c JOIN system_controllers sc ON c.id = sc.controller_id @@ -146,7 +149,8 @@ def _worker(self, system_id): with self._lock: self.error_message = str(e) finally: - conn.close() + if conn is not None: + conn.close() with self._lock: self.completed = True self.running = False diff --git a/services/jobs/player_cores.py b/services/jobs/player_cores.py index 4d952236..caf09949 100644 --- a/services/jobs/player_cores.py +++ b/services/jobs/player_cores.py @@ -94,6 +94,7 @@ def _worker(self): self.queue, self.running, self.current_item = [], False, '' return self._singleton_fd = fd + drained = False try: with self.app.app_context(): while True: @@ -120,10 +121,20 @@ def _worker(self): logger.warning("player core %s not installed: %s", core, e) with self._lock: self.errors[core] = str(e) + drained = True finally: - with self._lock: - self.running, self.current_item = False, '' release_singleton_fd(self) + # A request() between the empty queue above and here saw + # `running` and only queued its core; run it rather than strand + # it. The lock is released first so the new worker can take it. + with self._lock: + again = drained and bool(self.queue) + self.current_item = '' + if not again: + self.running = False + if again: + self._thread = threading.Thread(target=self._worker, name='player-cores', daemon=True) + self._thread.start() def _shaders_for(self, player_cores, system_id): """The first install for a system whose chosen shader is not `none` diff --git a/services/jobs/ra_refresh.py b/services/jobs/ra_refresh.py index f294ddba..7f4671bf 100644 --- a/services/jobs/ra_refresh.py +++ b/services/jobs/ra_refresh.py @@ -71,8 +71,9 @@ def get_status(self): 'error': self.error_message } - def start(self, system_id=None): - """Start RA refresh scan""" + def start(self, system_id=None, system_name=None): + """Start RA refresh scan. `system_name` is shown until the worker + reads the name from the DB (routes/ra_sync.py passes it).""" with self._lock: if self.running: return {'success': False, 'error': 'Refresh already running'} @@ -88,6 +89,7 @@ def start(self, system_id=None): self._singleton_fd = singleton_fd self.job_id = f"ra_refresh_{int(time.time())}" self.system_id = system_id + self.system_name = system_name self.running = True # Start background thread diff --git a/services/media_cleanup.py b/services/media_cleanup.py index a622616b..2abc3982 100644 --- a/services/media_cleanup.py +++ b/services/media_cleanup.py @@ -182,6 +182,9 @@ def find_orphaned_media(games): game_ids = {g['id'] for g in games} referenced_files = _collect_referenced_files(games) + # Pass 48.2 matches orphans on basename; a set keeps that one lookup per + # file instead of a walk over every reference (orphans x references). + referenced_basenames = {os.path.basename(ref) for ref in referenced_files} # Pass 48.4 — each entry carries the base its rel_path is computed # against, instead of inferring it from a ``'static' in dir_path`` @@ -238,17 +241,14 @@ def find_orphaned_media(games): rel_path = os.path.relpath(filepath, rel_base) if filename in referenced_files or rel_path in referenced_files: is_orphaned = False - else: - # Pass 48.2 — match on basename, not a substring. A DB - # value may store a path (e.g. ``images/boxart/42.png``); - # comparing basenames keeps that protected without the old - # ``filename in ref`` test also sparing unrelated refs that - # merely contain the name as a substring (e.g. ``42.png`` - # inside ``442.png`` or ``42.png.bak``). - for ref in referenced_files: - if os.path.basename(ref) == filename: - is_orphaned = False - break + # Pass 48.2 — match on basename, not a substring. A DB + # value may store a path (e.g. ``images/boxart/42.png``); + # comparing basenames keeps that protected without the old + # ``filename in ref`` test also sparing unrelated refs that + # merely contain the name as a substring (e.g. ``42.png`` + # inside ``442.png`` or ``42.png.bak``). + elif filename in referenced_basenames: + is_orphaned = False if is_orphaned: try: diff --git a/tests/test_jobs_review_fixes.py b/tests/test_jobs_review_fixes.py new file mode 100644 index 00000000..538c69ef --- /dev/null +++ b/tests/test_jobs_review_fixes.py @@ -0,0 +1,157 @@ +"""Regression pins for the background-job / installer review fixes. + +- RARefreshJob.start() takes the system_name routes/ra_sync.py passes it + (the per-system refresh raised TypeError before). +- swap_with_running / demote_running drop the promoted job's 'queued' + job_queue row, as _start_next_queued already did. +- PlayerCoresJob runs a core queued in the window after its worker found + the queue empty, instead of stranding it. +- MuseumImageFetchJob ends (not stuck `running`) when the DB connect fails. +- install_launcher.exec_arg quotes an Exec path with a space. +""" + +import threading +from unittest.mock import MagicMock, patch + +import pytest + +from services.jobs.bulk_scrape import BulkScrapeJob +from tests._bulk_scrape_fixtures import ( + bulk_scrape_persistence_patches, + make_memory_db, +) + + +def test_ra_refresh_start_accepts_system_name(): + from services.jobs import ra_refresh as mod + job = mod.RARefreshJob() + with patch.object(mod, 'acquire_job_singleton_lock', return_value=0), \ + patch.object(mod.RARefreshJob, '_run_refresh', lambda self: None): + result = job.start(system_id=7, system_name='Mega Drive') + job._thread.join(timeout=2) + assert result['success'] + assert job.get_status()['system_name'] == 'Mega Drive' + + +@pytest.mark.parametrize('action', ['swap', 'demote']) +def test_promoted_job_queue_row_removed(action): + stop = threading.Event() + + def worker(self): + while not stop.is_set(): + with self._lock: + if self.cancelled: + return + stop.wait(0.02) + + with bulk_scrape_persistence_patches(make_memory_db()) as mod, \ + patch.object(BulkScrapeJob, '_run_scrape', worker), \ + patch.object(mod, 'remove_queued_job') as remove: + job = BulkScrapeJob() + try: + job.start([100], system_id=1) + job.start([200], system_id=2) # queued; persist_job_queued -> 999 + promoted = job._queue[0]['job_id'] + if action == 'swap': + assert job.swap_with_running(promoted)['success'] + else: + assert job.demote_running()['success'] + remove.assert_called_once_with(999) + finally: + stop.set() + if job._thread: + job._thread.join(timeout=2) + + +def test_player_cores_runs_a_core_queued_after_the_drain(): + from services.jobs import player_cores as mod + import services.player_cores as pc + + job = mod.PlayerCoresJob() + job.running = True + job.job_id = 'player_cores_test' + job.app = MagicMock() + real_next = mod.PlayerCoresJob._next + calls = {'n': 0} + + def racing_next(self): + calls['n'] += 1 + if calls['n'] == 1: + # request() lands just after the worker saw an empty queue. + with self._lock: + self.queue.append(('late_core', None, False)) + return None + return real_next(self) + + with patch.object(mod, 'acquire_job_singleton_lock', return_value=0), \ + patch.object(mod.PlayerCoresJob, '_next', racing_next), \ + patch.object(pc, 'is_installed', return_value=True): + job._worker() + if job._thread: + job._thread.join(timeout=5) + + status = job.get_status() + assert 'late_core' in status['success'] + assert status['running'] is False + assert status['queued'] == [] + + +def test_museum_images_job_ends_when_connect_fails(): + from services.jobs import museum_images as mod + job = mod.MuseumImageFetchJob() + job.running = True + with patch.object(mod, '_get_conn', side_effect=RuntimeError('db gone')): + job._worker(1) + status = job.get_status() + assert status['running'] is False + assert status['completed'] is True + assert 'db gone' in status['error'] + + +def test_install_launcher_quotes_exec_paths(): + from scripts.install_launcher import exec_arg + assert exec_arg('/usr/bin/python3') == '/usr/bin/python3' + assert exec_arg('/home/u/My Games/app.py') == '"/home/u/My Games/app.py"' + assert exec_arg('/a b/$x') == '"/a b/\\\\$x"' + assert exec_arg('/a/50%') == '/a/50%%' + + +def test_recoverable_multi_system_scrapes_are_not_collapsed(tmp_path): + import json + import sqlite3 + from services.jobs import base as base_mod + + db = tmp_path / 'jobs.db' + conn = sqlite3.connect(db) + conn.executescript(""" + CREATE TABLE job_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, job_type TEXT NOT NULL, + status TEXT, progress TEXT, created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, completed_at TEXT, error_message TEXT, + params TEXT); + """) + rows = [ + ({'system_id': None, 'game_ids': [1, 2]}, 1), # two different selections + ({'system_id': None, 'game_ids': [3, 4]}, 0), + ({'system_id': 5, 'game_ids': [6]}, 0), # same-system duplicates + ({'system_id': 5, 'game_ids': [6]}, 1), + ] + for i, (params, current) in enumerate(rows): + conn.execute( + "INSERT INTO job_queue (job_type, status, progress, created_at, updated_at, params) " + "VALUES ('bulk_scrape', 'interrupted', ?, ?, ?, ?)", + (json.dumps({'current': current}), f'2026-01-0{i + 1}', f'2026-01-0{i + 1}', + json.dumps(params))) + conn.commit() + conn.close() + + def _conn(): + c = sqlite3.connect(db) + c.row_factory = sqlite3.Row + return c + + with patch.object(base_mod, '_get_conn', _conn): + jobs = base_mod.get_recoverable_jobs() + + kept = sorted(tuple(j['params']['game_ids']) for j in jobs) + assert kept == [(1, 2), (3, 4), (6,)] diff --git a/tests/test_services_review_fixes.py b/tests/test_services_review_fixes.py new file mode 100644 index 00000000..7a54f870 --- /dev/null +++ b/tests/test_services_review_fixes.py @@ -0,0 +1,121 @@ +# ============================================================================= +# Regression pins for the services/ review fixes (2026-10-27). +# ============================================================================= + +import os +import sqlite3 + +import pytest + + +@pytest.fixture +def db_path(tmp_path, monkeypatch): + path = tmp_path / 'roms.db' + import config as _config + monkeypatch.setattr(_config, 'DB_PATH', str(path)) + return path + + +# ----------------------------------------------------------------------------- +# clear_scraped_data reset the titles on a connection it never committed +# ----------------------------------------------------------------------------- +def test_clear_scraped_data_commits_title_reset(db_path): + from services.game_cleanup import _SCRAPED_FIELDS + cols = ', '.join(f"{c} TEXT" for c in dict.fromkeys( + tuple(_SCRAPED_FIELDS) + ('scrape_history',))) + conn = sqlite3.connect(str(db_path)) + with conn: + conn.execute("CREATE TABLE systems (id INTEGER PRIMARY KEY, folder TEXT)") + conn.execute(f"""CREATE TABLE games (id INTEGER PRIMARY KEY, title TEXT, + sort_title TEXT, rom_path TEXT, system_id INTEGER, + scraped INTEGER DEFAULT 0, title_locked INTEGER DEFAULT 0, {cols})""") + conn.execute("INSERT INTO systems VALUES (1, 'snes')") + conn.execute("INSERT INTO games (id, title, rom_path, system_id, scraped) " + "VALUES (1, 'Scraped Name', '/roms/snes/Real Name (USA).sfc', 1, 1)") + conn.close() + + from services import game_cleanup + game_cleanup.clear_scraped_data() + + conn = sqlite3.connect(str(db_path)) + try: + (title,) = conn.execute("SELECT title FROM games WHERE id = 1").fetchone() + finally: + conn.close() + assert title == 'Real Name' + + +# ----------------------------------------------------------------------------- +# NULL and 'not_started' completion groups overwrote each other's count +# ----------------------------------------------------------------------------- +def test_analytics_counts_null_and_not_started_together(db_path): + conn = sqlite3.connect(str(db_path)) + with conn: + conn.execute("CREATE TABLE systems (id INTEGER PRIMARY KEY)") + conn.execute("CREATE TABLE games (id INTEGER PRIMARY KEY, file_size INTEGER, " + "completion_status TEXT)") + conn.executemany("INSERT INTO games (completion_status) VALUES (?)", + [(None,), (None,), ('not_started',), ('completed',)]) + conn.close() + + from services.analytics import _get_analytics_stats + total, _stats, completion = _get_analytics_stats() + assert total == 4 + assert completion[0] == 3 # two NULL + one not_started + assert completion[3] == 1 + + +# ----------------------------------------------------------------------------- +# query() outside an app context closes the connection it opened +# ----------------------------------------------------------------------------- +def test_query_outside_app_context_closes_its_connection(db_path, monkeypatch): + import services.database as database + opened = [] + real_get_db = database.get_db + + def tracking_get_db(): + conn = real_get_db() + opened.append(conn) + return conn + + monkeypatch.setattr(database, 'get_db', tracking_get_db) + assert database.query("SELECT 1 AS one", one=True) == {'one': 1} + database.execute("CREATE TABLE t (a)") + assert len(opened) == 2 + for conn in opened: + with pytest.raises(sqlite3.ProgrammingError): + conn.execute("SELECT 1") + + +# ----------------------------------------------------------------------------- +# A palette / alpha image could not be saved to a .jpg path +# ----------------------------------------------------------------------------- +@pytest.mark.parametrize('mode', ['P', 'LA', 'PA']) +def test_save_image_converts_non_jpeg_modes(tmp_path, mode): + from PIL import Image + from services.image_utils import _save_image + path = tmp_path / 'x.jpg' + _save_image(Image.new(mode, (8, 8)), str(path)) + assert path.exists() + with Image.open(path) as im: + assert im.format == 'JPEG' + + +# ----------------------------------------------------------------------------- +# The orphan scan still spares a file referenced by a path-form DB value +# ----------------------------------------------------------------------------- +def test_orphan_scan_spares_basename_reference(tmp_path, monkeypatch): + import config as _config + image_path = tmp_path / 'images' + boxart = image_path / 'boxart' + boxart.mkdir(parents=True) + (boxart / 'kept.png').write_bytes(b'x') + (boxart / '99_gone.png').write_bytes(b'x') + monkeypatch.setattr(_config, 'IMAGE_PATH', str(image_path)) + monkeypatch.setattr(_config, 'STATIC_PATH', str(tmp_path / 'static')) + + from services.media_cleanup import find_orphaned_media + games = [{'id': 1, 'boxart': 'some/dir/kept.png', 'boxart_3d': None, + 'fanart': None, 'video': None, 'manual': None, 'screenshots': None}] + orphans, _ = find_orphaned_media(games) + assert [os.path.basename(o['path']) for o in orphans] == ['99_gone.png'] From 97c183a14a63c15857db89216a8199ee2c7747a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:27:53 +0000 Subject: [PATCH 3/8] =?UTF-8?q?fix(ui):=20review=20sweep=20=E2=80=94=20sta?= =?UTF-8?q?le=20replies,=20card=20grid,=20undefined=20CSS=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Card grid: one-column phone layout no longer gets inline row lines; re-layout when a resize changes the column count; measure all cards before placing (one forced layout instead of one per card) - Game detail modal: late replies no longer paint a closed/other game; error message no longer replaces the spinner for good; AI Fill only reopens the game still shown - Job toasts: a slow status reply is no longer aborted by the next poll - Stale-reply guards: museum status, version search, log viewer, official-names proposals; Kill closes the Now-playing popover - CHD converter/verify escape disk file names in innerHTML - Replace undefined CSS variables with existing tokens (skip-link focus outline, settings tab bar, rename modal backgrounds were invisible) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013s5ztLFVCcdTjjTeVcTnJr --- static/asset_manifest.json | 6 ++-- static/css/components/buttons.css | 2 +- static/css/components/launch-indicator.css | 6 ++-- static/css/features/hltb.css | 2 +- static/css/layout/sidebar.css | 4 +-- static/css/main.min.css | 2 +- static/css/pages/game-list.css | 4 +-- static/css/pages/pages.css | 2 +- static/js/all-games-controller.js | 32 ++++++++++++++++++++-- static/js/core.bundle.js | 28 ++++++++++++------- static/js/game-modals.js | 23 ++++++++++++---- static/js/games.bundle.js | 18 ++++++++---- static/js/launch-indicator.js | 5 +++- static/js/log-viewer.js | 3 ++ static/js/main.js | 4 +-- static/js/museum.js | 3 ++ static/js/official-names.js | 5 +++- static/js/settings-page.js | 2 +- static/js/toast-controller.js | 29 ++++++++++++++------ static/js/versions.js | 2 ++ templates/chd_converter.html | 4 +-- templates/chd_verify.html | 2 +- templates/collector_trophies.html | 8 +++--- templates/compare_games.html | 6 ++-- templates/duplicate_finder.html | 2 +- templates/game_detail.html | 12 ++++---- templates/rom_tools_settings.html | 6 ++-- templates/screenshot_dedup.html | 2 +- templates/settings.html | 10 +++---- templates/systems.html | 4 +-- 30 files changed, 159 insertions(+), 79 deletions(-) diff --git a/static/asset_manifest.json b/static/asset_manifest.json index a7205ea1..112d3011 100644 --- a/static/asset_manifest.json +++ b/static/asset_manifest.json @@ -1,7 +1,7 @@ { "css/core/fonts.css": "87796dac", - "css/main.min.css": "eeeccda7", - "js/core.bundle.js": "5d24a88d", - "js/games.bundle.js": "72c5ea2e", + "css/main.min.css": "5680bb47", + "js/core.bundle.js": "4600be9c", + "js/games.bundle.js": "deb12869", "js/vendor/chart.umd.min.js": "48444a82" } \ No newline at end of file diff --git a/static/css/components/buttons.css b/static/css/components/buttons.css index 45c2d4fe..4aab2e89 100644 --- a/static/css/components/buttons.css +++ b/static/css/components/buttons.css @@ -26,7 +26,7 @@ .skip-link:focus { transform: translateY(0); - outline: 2px solid var(--primary-magenta); + outline: 2px solid var(--secondary-magenta); outline-offset: 2px; } diff --git a/static/css/components/launch-indicator.css b/static/css/components/launch-indicator.css index 94409a2e..39a256fe 100644 --- a/static/css/components/launch-indicator.css +++ b/static/css/components/launch-indicator.css @@ -11,7 +11,7 @@ align-items: center; gap: 0.5rem; padding: 0.4rem 0.9rem; - background: var(--bg-card); + background: var(--card-bg); border: 1px solid var(--primary-cyan); border-radius: 999px; box-shadow: 0 0 12px rgba(76, 201, 240, 0.35); @@ -45,9 +45,9 @@ z-index: 1001; min-width: 280px; padding: var(--spacing-md); - background: var(--bg-card); + background: var(--card-bg); border: 1px solid var(--primary-cyan); - border-radius: var(--border-radius); + border-radius: var(--radius-md); box-shadow: 0 0 20px rgba(76, 201, 240, 0.4); color: var(--text-primary); } diff --git a/static/css/features/hltb.css b/static/css/features/hltb.css index a23b27ce..d7f00c97 100644 --- a/static/css/features/hltb.css +++ b/static/css/features/hltb.css @@ -125,7 +125,7 @@ } .hltb-match-confidence { - color: var(--accent-green); + color: var(--neon-green); font-size: 0.8rem; font-weight: 500; } diff --git a/static/css/layout/sidebar.css b/static/css/layout/sidebar.css index 5cbb5cd9..29314f43 100644 --- a/static/css/layout/sidebar.css +++ b/static/css/layout/sidebar.css @@ -340,11 +340,11 @@ .user-indicator-rank.rank-tier-5 { background: rgba(245, 158, 11, 0.15); color: var(--neon-orange); border-color: rgba(245, 158, 11, 0.45); } .user-indicator-rank.rank-tier-6 { background: rgba(168, 85, 247, 0.15); color: var(--neon-purple); border-color: rgba(168, 85, 247, 0.4); } .user-indicator-rank.rank-tier-7 { background: rgba(168, 85, 247, 0.22); color: var(--neon-purple); border-color: rgba(168, 85, 247, 0.55); } -.user-indicator-rank.rank-tier-8 { background: rgba(247, 37, 133, 0.18); color: var(--primary-magenta); border-color: rgba(247, 37, 133, 0.5); } +.user-indicator-rank.rank-tier-8 { background: rgba(247, 37, 133, 0.18); color: var(--secondary-magenta); border-color: rgba(247, 37, 133, 0.5); } .user-indicator-rank.rank-tier-9 { background: linear-gradient(135deg, rgba(247, 37, 133, 0.3), rgba(168, 85, 247, 0.3)); color: var(--text-primary); - border-color: var(--primary-magenta); + border-color: var(--secondary-magenta); box-shadow: 0 0 8px rgba(247, 37, 133, 0.35); } diff --git a/static/css/main.min.css b/static/css/main.min.css index 6e861ffd..d1cf83ac 100644 --- a/static/css/main.min.css +++ b/static/css/main.min.css @@ -1 +1 @@ -:root{--primary-cyan:#4cc9f0;--primary-cyan-dark:#3a9fc2;--primary-cyan-light:#7dd8f5;--primary-cyan-glow:rgba(76,201,240,0.5);--secondary-magenta:#f72585;--secondary-magenta-dark:#c41d6a;--secondary-magenta-light:#f95fa3;--secondary-magenta-glow:rgba(247,37,133,0.5);--accent-purple:#7209b7;--accent-blue:#3a0ca3;--accent-violet:#560bad;--bg-darkest:#0a0e17;--bg-dark:#0f1419;--bg-darker:#0d1117;--bg-medium:#151b24;--bg-light:#1a222d;--bg-lighter:#212b38;--card-bg:#141a23;--card-bg-hover:#1a222d;--card-border:#2a3542;--card-border-hover:#3a4552;--text-primary:#e8eaed;--text-secondary:#9aa0a6;--text-muted:#6e7378;--text-accent:var(--primary-cyan);--border-color:#2a3542;--status-success:#00e676;--status-warning:#ffab00;--status-error:#ff5252;--status-info:var(--primary-cyan);--status-online:#00e676;--status-offline:#ff5252;--status-connected:var(--primary-cyan);--shadow-sm:0 2px 4px rgba(0,0,0,0.3);--shadow-md:0 4px 8px rgba(0,0,0,0.4);--shadow-lg:0 8px 16px rgba(0,0,0,0.5);--shadow-xl:0 12px 24px rgba(0,0,0,0.6);--shadow-glow-cyan:0 0 20px rgba(76,201,240,0.3);--shadow-glow-magenta:0 0 20px rgba(247,37,133,0.3);--radius-sm:4px;--radius-md:8px;--radius-lg:12px;--radius-xl:16px;--radius-full:9999px;--transition-fast:0.15s ease;--transition-normal:0.25s ease;--transition-slow:0.4s ease;--font-primary:'Rajdhani','Orbitron','Share Tech Mono','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif;--font-heading:'Orbitron','Rajdhani','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif;--font-mono:'Share Tech Mono','JetBrains Mono','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',monospace;--spacing-xs:4px;--spacing-sm:8px;--spacing-md:16px;--spacing-lg:24px;--spacing-xl:32px;--spacing-2xl:48px;--sidebar-width:260px;--sidebar-collapsed-width:70px;--mobile-header-height:56px;--mobile-bar-height:60px;--sticky-top:0px;--neon-cyan:#4cc9f0;--neon-green:#22c55e;--neon-orange:#f59e0b;--neon-red:#ef4444;--neon-purple:#a855f7;--neon-gray:#64748b;--neon-blue:#3b82f6;--text-on-color:#fff;--font-display:var(--font-heading);--accent-cyan:var(--primary-cyan);--cyan-bg:rgba(76,201,240,0.15);--cyan-bg-light:rgba(76,201,240,0.1);--cyan-bg-strong:rgba(76,201,240,0.2);--green-bg:rgba(34,197,94,0.15);--red-bg:rgba(239,68,68,0.15);--orange-bg:rgba(245,158,11,0.15);--purple-bg:rgba(168,85,247,0.15);--magenta-bg:rgba(247,37,133,0.15);--trophy-platinum:#7bb8d4;--trophy-gold:#cd9a2a;--trophy-silver:#a8a8a8;--trophy-bronze:#cd7f32;--trophy-display-platinum:#a8c8dc;--trophy-display-gold:#ffd700;--trophy-display-silver:#c0c0c0;--trophy-display-bronze:#cd7f32;--trophy-display-text:#111;--rating-esrb:#64b5f6;--rating-esrb-border:rgba(25,118,210,0.3);--rating-esrb-bg:rgba(25,118,210,0.1);--rating-pegi:#ce93d8;--rating-pegi-border:rgba(123,31,162,0.3);--rating-pegi-bg:rgba(123,31,162,0.1);--completion-gold:#fbbf24}*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html{font-size:16px;scroll-behavior:smooth}body{font-family:var(--font-primary);background-color:var(--bg-darkest);color:var(--text-primary);line-height:1.6;min-height:100vh;overflow-x:hidden;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}ul,ol{list-style:none}img,picture,video,canvas,svg{display:block;max-width:100%}button{font:inherit;cursor:pointer}input,textarea,select{font:inherit}input,button,textarea,select{font-family:inherit}a{color:inherit;text-decoration:none}@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:0.01ms !important;animation-iteration-count:1 !important;transition-duration:0.01ms !important;scroll-behavior:auto !important}}@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap');h1,.h1{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);letter-spacing:1px;text-transform:uppercase}h2,.h2{font-family:var(--font-heading);font-size:1.5rem;font-weight:600;color:var(--text-primary);letter-spacing:0.5px}h3,.h3{font-family:var(--font-heading);font-size:1.25rem;font-weight:600;color:var(--text-primary)}h4,.h4{font-family:var(--font-primary);font-size:1.1rem;font-weight:600;color:var(--text-primary)}h5,.h5{font-family:var(--font-primary);font-size:1rem;font-weight:600;color:var(--text-secondary)}h6,.h6{font-family:var(--font-primary);font-size:0.9rem;font-weight:500;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px}.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.page-header h1{margin-bottom:var(--spacing-sm)}.page-header p{color:var(--text-secondary);font-size:1rem}.page-title{font-family:var(--font-heading);font-size:2.5rem;font-weight:700;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:4px;text-shadow:0 0 10px var(--primary-cyan-glow),0 0 20px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow);animation:glow-pulse 3s ease-in-out infinite}.page-subtitle{color:var(--text-secondary);font-size:1rem;margin-top:var(--spacing-xs)}.page-actions{display:flex;gap:var(--spacing-md)}.section-title{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:2px;display:flex;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.section-title::before{content:'';width:4px;height:20px;background:var(--primary-cyan);border-radius:2px}.breadcrumb{display:flex;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);font-size:0.9rem}.breadcrumb a{color:var(--text-secondary);text-decoration:none;transition:color var(--transition-fast)}.breadcrumb a:hover{color:var(--primary-cyan)}.breadcrumb-separator{color:var(--text-muted)}.breadcrumb-current{color:var(--text-primary)}code,pre,.mono{font-family:var(--font-mono)}code{padding:0.2em 0.4em;background:rgba(76,201,240,0.1);border-radius:var(--radius-sm);font-size:0.9em;color:var(--primary-cyan)}pre{padding:var(--spacing-md);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow-x:auto}pre code{padding:0;background:none}[data-theme="matrix"]{--primary-cyan:#00ff41;--primary-cyan-dark:#00cc33;--primary-cyan-light:#66ff88;--primary-cyan-glow:rgba(0,255,65,0.5);--secondary-magenta:#008f11;--secondary-magenta-dark:#006b0d;--secondary-magenta-light:#33b833;--secondary-magenta-glow:rgba(0,143,17,0.5);--accent-purple:#00aa22;--accent-blue:#004d00;--accent-violet:#009a1a;--bg-darkest:#000000;--bg-dark:#050505;--bg-darker:#0a0a0a;--bg-medium:#111111;--bg-light:#161616;--bg-lighter:#1a1a1a;--card-bg:#0a0a0a;--card-bg-hover:#111111;--card-border:#1a3a1a;--card-border-hover:#2a5a2a;--text-primary:#c0ffc0;--text-secondary:#80c080;--text-muted:#508050;--text-accent:var(--primary-cyan);--border-color:#1a3a1a;--status-success:#00ff41;--status-warning:#a0e000;--status-error:#ff3333;--status-info:var(--primary-cyan);--status-online:#00ff41;--status-offline:#ff3333;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(0,255,65,0.3);--shadow-glow-magenta:0 0 20px rgba(0,143,17,0.3);--neon-cyan:#00ff41;--neon-green:#00ff41;--neon-orange:#a0e000;--neon-red:#ff3333;--neon-purple:#33cc66;--neon-gray:#4a6a4a;--trophy-platinum:#66ffaa;--trophy-gold:#a0e060;--trophy-silver:#88bb88;--trophy-bronze:#77aa55;--toast-bg-from:rgba(0,10,0,0.95);--toast-bg-to:rgba(0,20,5,0.95);--toast-queued-bg-from:rgba(0,12,0,0.90);--toast-queued-bg-to:rgba(0,22,5,0.90)}[data-theme="matrix"] body::before{background:linear-gradient( to top right,rgba(0,255,65,0.06) 0%,rgba(0,143,17,0.04) 25%,rgba(0,80,0,0.03) 50%,rgba(0,0,0,0.02) 75%,transparent 100% )}[data-theme="matrix"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,255,65,0.02) 2px,rgba(0,255,65,0.02) 4px )}[data-theme="amber"]{--primary-cyan:#ffb000;--primary-cyan-dark:#cc8c00;--primary-cyan-light:#ffc944;--primary-cyan-glow:rgba(255,176,0,0.5);--secondary-magenta:#ff6600;--secondary-magenta-dark:#cc5200;--secondary-magenta-light:#ff8833;--secondary-magenta-glow:rgba(255,102,0,0.5);--accent-purple:#cc7700;--accent-blue:#995500;--accent-violet:#bb6600;--bg-darkest:#0a0800;--bg-dark:#0d0a02;--bg-darker:#0f0c04;--bg-medium:#151008;--bg-light:#1a150c;--bg-lighter:#211c12;--card-bg:#120e04;--card-bg-hover:#181208;--card-border:#3a2a10;--card-border-hover:#4a3a1a;--text-primary:#ffe0a0;--text-secondary:#c09050;--text-muted:#806030;--text-accent:var(--primary-cyan);--border-color:#3a2a10;--status-success:#ffcc00;--status-warning:#ff9900;--status-error:#ff4422;--status-info:var(--primary-cyan);--status-online:#ffcc00;--status-offline:#ff4422;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(255,176,0,0.3);--shadow-glow-magenta:0 0 20px rgba(255,102,0,0.3);--neon-cyan:#ffb000;--neon-green:#ddaa00;--neon-orange:#ff7700;--neon-red:#ff4422;--neon-purple:#ff8844;--neon-gray:#7a6a4a;--trophy-platinum:#ffe8aa;--trophy-gold:#ffcc44;--trophy-silver:#ccaa77;--trophy-bronze:#aa7733;--toast-bg-from:rgba(18,14,4,0.95);--toast-bg-to:rgba(28,20,8,0.95);--toast-queued-bg-from:rgba(20,16,4,0.90);--toast-queued-bg-to:rgba(30,22,8,0.90)}[data-theme="amber"] body::before{background:radial-gradient(ellipse 30% 20% at 50% 100%,rgba(255,230,100,0.2) 0%,rgba(255,200,50,0.12) 40%,transparent 100%),radial-gradient(ellipse 60% 40% at 50% 100%,rgba(255,160,0,0.1) 0%,rgba(255,100,0,0.05) 50%,transparent 100%),linear-gradient(to top,rgba(255,120,0,0.08) 0%,rgba(255,80,0,0.04) 20%,rgba(120,50,0,0.02) 45%,transparent 70%)}[data-theme="amber"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(255,176,0,0.03) 2px,rgba(255,176,0,0.03) 4px )}[data-theme="ocean"]{--primary-cyan:#4dabf7;--primary-cyan-dark:#3d8bd4;--primary-cyan-light:#74c0fa;--primary-cyan-glow:rgba(77,171,247,0.5);--secondary-magenta:#20c997;--secondary-magenta-dark:#1aaa7f;--secondary-magenta-light:#4dd8ad;--secondary-magenta-glow:rgba(32,201,151,0.5);--accent-purple:#5c7cfa;--accent-blue:#3b5bdb;--accent-violet:#4c6ef5;--bg-darkest:#040810;--bg-dark:#050a14;--bg-darker:#060c18;--bg-medium:#0a1220;--bg-light:#0e1828;--bg-lighter:#142030;--card-bg:#081018;--card-bg-hover:#0c1620;--card-border:#1a2a40;--card-border-hover:#2a3a55;--text-primary:#dce4f0;--text-secondary:#8899b0;--text-muted:#5a6a80;--text-accent:var(--primary-cyan);--border-color:#1a2a40;--status-success:#40c057;--status-warning:#fab005;--status-error:#fa5252;--status-info:var(--primary-cyan);--status-online:#40c057;--status-offline:#fa5252;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(77,171,247,0.3);--shadow-glow-magenta:0 0 20px rgba(32,201,151,0.3);--neon-cyan:#4dabf7;--neon-green:#40c057;--neon-orange:#fab005;--neon-red:#fa5252;--neon-purple:#7950f2;--neon-gray:#5a6a80;--trophy-platinum:#a5d8ff;--trophy-gold:#ffe066;--trophy-silver:#adb5bd;--trophy-bronze:#e8a44a;--toast-bg-from:rgba(6,12,22,0.95);--toast-bg-to:rgba(10,18,32,0.95);--toast-queued-bg-from:rgba(8,14,24,0.90);--toast-queued-bg-to:rgba(12,20,34,0.90)}[data-theme="ocean"] body::before{background:radial-gradient(circle at 70% 10%,rgba(220,235,255,0.4) 0%,rgba(200,220,250,0.2) 1.5%,rgba(150,190,240,0.08) 4%,transparent 10%),radial-gradient(circle at 70% 10%,rgba(77,171,247,0.08) 0%,transparent 18%),radial-gradient(ellipse 80% 50% at 50% 90%,rgba(32,201,151,0.04) 0%,rgba(77,171,247,0.05) 40%,transparent 100%),linear-gradient(to bottom,rgba(10,20,40,0.02) 0%,rgba(77,171,247,0.03) 55%,rgba(32,201,151,0.02) 100%)}[data-theme="ocean"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(77,171,247,0.02) 2px,rgba(77,171,247,0.02) 4px )}[data-theme="christian"]{--primary-cyan:#d4a843;--primary-cyan-dark:#b08a30;--primary-cyan-light:#e8c86a;--primary-cyan-glow:rgba(212,168,67,0.5);--secondary-magenta:#7b4db0;--secondary-magenta-dark:#5f3a8a;--secondary-magenta-light:#9b6dd0;--secondary-magenta-glow:rgba(123,77,176,0.5);--accent-purple:#6a3d9a;--accent-blue:#4a2d7a;--accent-violet:#7b4db0;--bg-darkest:#0c0810;--bg-dark:#0e0a14;--bg-darker:#110d18;--bg-medium:#16101f;--bg-light:#1c1528;--bg-lighter:#231c30;--card-bg:#120e1a;--card-bg-hover:#181222;--card-border:#2a1f3a;--card-border-hover:#3a2d4e;--text-primary:#f0e8d8;--text-secondary:#b0a090;--text-muted:#706050;--text-accent:var(--primary-cyan);--border-color:#2a1f3a;--status-success:#d4a843;--status-warning:#e8a030;--status-error:#c04040;--status-info:var(--primary-cyan);--status-online:#d4a843;--status-offline:#c04040;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(212,168,67,0.3);--shadow-glow-magenta:0 0 20px rgba(123,77,176,0.3);--neon-cyan:#d4a843;--neon-green:#c0a040;--neon-orange:#e8a030;--neon-red:#c04040;--neon-purple:#9b6dd0;--neon-gray:#6a5a50;--trophy-platinum:#f0e0c0;--trophy-gold:#d4a843;--trophy-silver:#b0a090;--trophy-bronze:#8a6a40;--toast-bg-from:rgba(14,10,20,0.95);--toast-bg-to:rgba(22,16,30,0.95);--toast-queued-bg-from:rgba(16,12,22,0.90);--toast-queued-bg-to:rgba(24,18,32,0.90)}[data-theme="christian"] body::before{background:radial-gradient(ellipse 40% 60% at 50% 0%,rgba(212,168,67,0.15) 0%,rgba(212,168,67,0.06) 30%,transparent 70%),radial-gradient(ellipse 70% 50% at 50% 10%,rgba(180,140,50,0.06) 0%,rgba(123,77,176,0.03) 50%,transparent 100%),linear-gradient(to top,rgba(100,60,20,0.04) 0%,rgba(80,40,120,0.02) 40%,transparent 70%)}[data-theme="christian"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(212,168,67,0.015) 2px,rgba(212,168,67,0.015) 4px )}[data-theme="bladerunner"]{--primary-cyan:#1a9fff;--primary-cyan-dark:#1480d4;--primary-cyan-light:#55b8ff;--primary-cyan-glow:rgba(26,159,255,0.5);--secondary-magenta:#ff2d7c;--secondary-magenta-dark:#d42060;--secondary-magenta-light:#ff5a9a;--secondary-magenta-glow:rgba(255,45,124,0.5);--accent-purple:#b040e0;--accent-blue:#1060c0;--accent-violet:#8830c0;--bg-darkest:#06080e;--bg-dark:#080a12;--bg-darker:#0a0d16;--bg-medium:#0e121c;--bg-light:#131824;--bg-lighter:#1a1f2e;--card-bg:#0a0e16;--card-bg-hover:#10141e;--card-border:#1a2236;--card-border-hover:#2a3248;--text-primary:#d8dce8;--text-secondary:#8892a8;--text-muted:#78809a;--text-accent:var(--primary-cyan);--border-color:#1a2236;--status-success:#22cc88;--status-warning:#ff8c30;--status-error:#ff2d5e;--status-info:var(--primary-cyan);--status-online:#22cc88;--status-offline:#ff2d5e;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(26,159,255,0.3);--shadow-glow-magenta:0 0 20px rgba(255,45,124,0.3);--neon-cyan:#1a9fff;--neon-green:#22cc88;--neon-orange:#ff8c30;--neon-red:#ff2d5e;--neon-purple:#b040e0;--neon-gray:#4a5268;--trophy-platinum:#c0d8ff;--trophy-gold:#ffd066;--trophy-silver:#a0aab8;--trophy-bronze:#d08a40;--toast-bg-from:rgba(8,10,18,0.95);--toast-bg-to:rgba(14,18,28,0.95);--toast-queued-bg-from:rgba(10,12,20,0.90);--toast-queued-bg-to:rgba(16,20,30,0.90)}[data-theme="bladerunner"] body::before{background:radial-gradient(ellipse 40% 30% at 20% 95%,rgba(255,45,124,0.08) 0%,rgba(255,45,124,0.03) 50%,transparent 100%),radial-gradient(ellipse 40% 30% at 80% 90%,rgba(26,159,255,0.08) 0%,rgba(26,159,255,0.03) 50%,transparent 100%),radial-gradient(ellipse 60% 40% at 50% 70%,rgba(130,40,180,0.04) 0%,transparent 80%),linear-gradient(to bottom,rgba(6,8,14,0.02) 0%,rgba(20,15,30,0.04) 60%,rgba(255,45,124,0.02) 100%)}[data-theme="bladerunner"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(26,159,255,0.02) 2px,rgba(26,159,255,0.02) 4px )}[data-theme="elite"]{--primary-cyan:#00ff00;--primary-cyan-dark:#00cc00;--primary-cyan-light:#66ff66;--primary-cyan-glow:rgba(0,255,0,0.5);--secondary-magenta:#cccccc;--secondary-magenta-dark:#999999;--secondary-magenta-light:#e0e0e0;--secondary-magenta-glow:rgba(204,204,204,0.5);--accent-purple:#00cc66;--accent-blue:#008844;--accent-violet:#00aa55;--bg-darkest:#000000;--bg-dark:#020202;--bg-darker:#040404;--bg-medium:#080808;--bg-light:#0c0c0c;--bg-lighter:#111111;--card-bg:#050505;--card-bg-hover:#0a0a0a;--card-border:#1a331a;--card-border-hover:#2a552a;--text-primary:#d0ffd0;--text-secondary:#80cc80;--text-muted:#508050;--text-accent:var(--primary-cyan);--border-color:#1a331a;--status-success:#00ff00;--status-warning:#ffff00;--status-error:#ff0000;--status-info:var(--primary-cyan);--status-online:#00ff00;--status-offline:#ff0000;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(0,255,0,0.3);--shadow-glow-magenta:0 0 20px rgba(204,204,204,0.3);--neon-cyan:#00ff00;--neon-green:#00ff00;--neon-orange:#ffff00;--neon-red:#ff0000;--neon-purple:#00cc66;--neon-gray:#4a6a4a;--trophy-platinum:#ccffcc;--trophy-gold:#ffff66;--trophy-silver:#aaccaa;--trophy-bronze:#88aa44;--toast-bg-from:rgba(0,5,0,0.95);--toast-bg-to:rgba(0,12,0,0.95);--toast-queued-bg-from:rgba(0,8,0,0.90);--toast-queued-bg-to:rgba(0,15,0,0.90)}[data-theme="elite"] body::before{background:radial-gradient(circle at 30% 20%,rgba(255,255,255,0.03) 0%,transparent 8%),radial-gradient(circle at 70% 15%,rgba(255,255,255,0.02) 0%,transparent 5%),radial-gradient(ellipse 40% 25% at 50% 95%,rgba(0,255,0,0.04) 0%,transparent 80%),linear-gradient(to bottom,rgba(0,0,0,0) 0%,rgba(0,20,0,0.02) 100%)}[data-theme="elite"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,255,0,0.025) 2px,rgba(0,255,0,0.025) 4px )}.app-container{display:flex;min-height:100vh;position:relative;z-index:2}.main-content{flex:1;margin-left:var(--sidebar-width);padding:var(--spacing-xl);transition:margin-left var(--transition-normal);display:flex;justify-content:center;position:relative}.main-content.sidebar-collapsed{margin-left:var(--sidebar-collapsed-width)}.content-wrapper{width:65%;max-width:1400px;min-width:600px;position:relative;z-index:1}.content-wrapper.adaptive{width:100%;max-width:none;min-width:0}.main-content.adaptive{min-width:0}.page-box{max-width:var(--page-max,none);margin-inline:auto}.page-box.grid-4{--page-max:calc(5 * var(--card-min) + 4 * var(--card-gap) - 1px)}.page-box.data-page{--page-max:1400px}.page-box.form-page{--page-max:1100px}.page-box.detail-page{--page-max:1600px}.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg)}.section-link{color:var(--primary-cyan);text-decoration:none;font-size:0.85rem;font-weight:500;text-transform:uppercase;letter-spacing:1px;transition:all var(--transition-fast)}.section-link:hover{text-shadow:0 0 10px var(--primary-cyan-glow)}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--spacing-lg)}.systems-section{margin-bottom:var(--spacing-xl)}.sidebar{position:fixed;left:0;top:0;width:var(--sidebar-width);height:100vh;background:linear-gradient(180deg,color-mix(in srgb,var(--bg-medium) 88%,transparent) 0%,color-mix(in srgb,var(--bg-dark) 88%,transparent) 100% );backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-right:1px solid var(--card-border);display:flex;flex-direction:column;z-index:1000;transition:width var(--transition-normal)}.sidebar-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.logo{display:flex;align-items:center;gap:var(--spacing-md);text-decoration:none}.logo-icon{width:40px;height:40px;background:linear-gradient(135deg,var(--primary-cyan) 0%,var(--secondary-magenta) 100%);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:var(--shadow-glow-cyan)}.logo-text{font-family:var(--font-heading);font-size:1.5rem;font-weight:700;color:var(--text-primary);letter-spacing:2px}.logo-text span{color:var(--primary-cyan);text-shadow:0 0 10px var(--primary-cyan-glow)}.sidebar-nav{flex:1;padding:var(--spacing-md);overflow-y:auto}.nav-section{margin-bottom:var(--spacing-lg)}.nav-section-title{font-size:0.7rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;padding:var(--spacing-sm) var(--spacing-md);margin-bottom:var(--spacing-xs)}.nav-item{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);border-radius:var(--radius-md);color:var(--text-secondary);text-decoration:none;transition:all var(--transition-fast);margin-bottom:var(--spacing-xs);position:relative;overflow:hidden}.nav-item::before{content:'';position:absolute;left:0;top:0;height:100%;width:3px;background:var(--primary-cyan);transform:scaleY(0);transition:transform var(--transition-fast)}.nav-item:hover{background:var(--card-bg-hover);color:var(--text-primary)}.nav-item:hover::before{transform:scaleY(1)}.nav-item.active{background:linear-gradient(90deg,rgba(76,201,240,0.15) 0%,transparent 100%);color:var(--primary-cyan);border-left:3px solid var(--primary-cyan)}.nav-item.active::before{transform:scaleY(1)}.nav-icon{width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:1.1rem}.nav-text{font-weight:500;font-size:0.95rem}.sidebar-footer{padding:var(--spacing-md);border-top:1px solid var(--card-border)}.version-info{font-size:0.75rem;color:var(--primary-cyan);text-align:center;font-family:var(--font-mono);opacity:0.8;text-shadow:0 0 8px rgba(76,201,240,0.4);display:flex;align-items:center;justify-content:center;gap:0.5rem;transition:all var(--transition-fast);padding:0.5rem;border-radius:var(--radius-md);background:transparent;border:none;width:100%;cursor:pointer}.version-info:hover{background:rgba(76,201,240,0.1);color:var(--primary-cyan)}.version-icon{font-size:1rem}.upscale-status{display:flex;align-items:center;gap:0.5rem;margin-bottom:var(--spacing-sm);padding:0.5rem;font-size:0.75rem;color:var(--text-secondary);border:1px solid var(--card-border);border-radius:var(--radius-md)}.upscale-status[hidden]{display:none}.upscale-status-icon{font-size:1rem;color:var(--primary-cyan)}.user-indicator{display:flex;align-items:center;justify-content:space-between;gap:0.5rem;padding:0.75rem;background:rgba(76,201,240,0.05);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-top:var(--spacing-md)}.user-indicator-info{display:flex;align-items:center;gap:0.75rem;min-width:0;flex:1}.user-indicator-avatar{font-size:1.25rem;flex-shrink:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center}.user-avatar-img{width:100%;height:100%;border-radius:50%;object-fit:cover}.user-indicator-details{display:flex;flex-direction:column;min-width:0}.user-indicator-name{font-size:0.85rem;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.user-indicator-role{font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:0.1rem 0.4rem;border-radius:var(--radius-full);width:fit-content}.user-indicator-role.admin{background:rgba(168,85,247,0.2);color:var(--neon-purple)}.user-indicator-role.editor{background:rgba(34,197,94,0.2);color:var(--neon-green)}.user-indicator-role.viewer{background:rgba(245,158,11,0.2);color:var(--neon-orange)}.user-indicator-meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:2px}.user-indicator-rank{font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.4px;padding:2px 8px;border-radius:var(--radius-full);text-decoration:none;white-space:nowrap;border:1px solid transparent;transition:all var(--transition-fast);line-height:1;display:inline-flex;align-items:center;gap:3px}.user-indicator-rank:hover{transform:translateY(-1px);filter:brightness(1.15)}.user-indicator-rank.rank-tier-0{background:rgba(110,115,120,0.18);color:var(--text-muted);border-color:rgba(110,115,120,0.35)}.user-indicator-rank.rank-tier-1{background:rgba(154,160,166,0.18);color:var(--text-secondary);border-color:rgba(154,160,166,0.4)}.user-indicator-rank.rank-tier-2{background:rgba(76,201,240,0.12);color:var(--primary-cyan);border-color:rgba(76,201,240,0.3)}.user-indicator-rank.rank-tier-3{background:rgba(76,201,240,0.18);color:var(--primary-cyan);border-color:rgba(76,201,240,0.45)}.user-indicator-rank.rank-tier-4{background:rgba(34,197,94,0.15);color:var(--neon-green);border-color:rgba(34,197,94,0.4)}.user-indicator-rank.rank-tier-5{background:rgba(245,158,11,0.15);color:var(--neon-orange);border-color:rgba(245,158,11,0.45)}.user-indicator-rank.rank-tier-6{background:rgba(168,85,247,0.15);color:var(--neon-purple);border-color:rgba(168,85,247,0.4)}.user-indicator-rank.rank-tier-7{background:rgba(168,85,247,0.22);color:var(--neon-purple);border-color:rgba(168,85,247,0.55)}.user-indicator-rank.rank-tier-8{background:rgba(247,37,133,0.18);color:var(--primary-magenta);border-color:rgba(247,37,133,0.5)}.user-indicator-rank.rank-tier-9{background:linear-gradient(135deg,rgba(247,37,133,0.3),rgba(168,85,247,0.3));color:var(--text-primary);border-color:var(--primary-magenta);box-shadow:0 0 8px rgba(247,37,133,0.35)}.user-logout-btn{font-size:1.1rem;padding:0.25rem;opacity:0.6;transition:all var(--transition-fast);text-decoration:none;flex-shrink:0}.user-logout-btn:hover{opacity:1;transform:scale(1.1)}.skip-link{position:absolute;top:0;left:0;padding:0.75rem 1.25rem;background:var(--primary-cyan);color:var(--bg-darkest);font-family:var(--font-heading);font-weight:700;text-decoration:none;border-radius:0 0 6px 0;z-index:10000;transform:translateY(-150%);transition:transform 0.15s ease-out}.skip-link:focus{transform:translateY(0);outline:2px solid var(--primary-magenta);outline-offset:2px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:var(--spacing-md) var(--spacing-lg);font-family:var(--font-primary);font-size:0.9rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;border:2px solid;border-radius:var(--radius-md);cursor:pointer;transition:all 0.3s ease;text-decoration:none;position:relative;overflow:hidden}.btn-primary{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan);box-shadow:0 0 15px rgba(76,201,240,0.5),0 0 30px rgba(76,201,240,0.3),inset 0 0 20px rgba(76,201,240,0.1);text-shadow:0 0 10px rgba(76,201,240,0.8)}.btn-primary:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 20px rgba(76,201,240,0.7),0 0 40px rgba(76,201,240,0.4),inset 0 0 25px rgba(76,201,240,0.15);transform:translateY(-2px)}.btn-secondary{background:rgba(148,163,184,0.1);border-color:var(--neon-gray);color:#94a3b8;box-shadow:0 0 10px rgba(100,116,139,0.3),0 0 20px rgba(100,116,139,0.2)}.btn-secondary:hover{background:rgba(148,163,184,0.2);border-color:#94a3b8;color:#cbd5e1;box-shadow:0 0 15px rgba(148,163,184,0.5),0 0 30px rgba(148,163,184,0.3)}.btn-success{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green);box-shadow:0 0 15px rgba(34,197,94,0.5),0 0 30px rgba(34,197,94,0.3),inset 0 0 20px rgba(34,197,94,0.1);text-shadow:0 0 10px rgba(34,197,94,0.8)}.btn-success:hover{background:rgba(34,197,94,0.25);box-shadow:0 0 20px rgba(34,197,94,0.7),0 0 40px rgba(34,197,94,0.4),inset 0 0 25px rgba(34,197,94,0.15);transform:translateY(-2px)}.btn-warning{background:rgba(245,158,11,0.15);border-color:var(--neon-orange);color:var(--neon-orange);box-shadow:0 0 15px rgba(245,158,11,0.5),0 0 30px rgba(245,158,11,0.3),inset 0 0 20px rgba(245,158,11,0.1);text-shadow:0 0 10px rgba(245,158,11,0.8)}.btn-warning:hover{background:rgba(245,158,11,0.25);box-shadow:0 0 20px rgba(245,158,11,0.7),0 0 40px rgba(245,158,11,0.4),inset 0 0 25px rgba(245,158,11,0.15);transform:translateY(-2px)}.btn-danger{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red);box-shadow:0 0 15px rgba(239,68,68,0.5),0 0 30px rgba(239,68,68,0.3),inset 0 0 20px rgba(239,68,68,0.1);text-shadow:0 0 10px rgba(239,68,68,0.8)}.btn-danger:hover{background:rgba(239,68,68,0.25);box-shadow:0 0 20px rgba(239,68,68,0.7),0 0 40px rgba(239,68,68,0.4),inset 0 0 25px rgba(239,68,68,0.15);transform:translateY(-2px)}.btn-sm{padding:var(--spacing-sm) var(--spacing-md);font-size:0.8rem}.btn-xs{padding:3px 8px;font-size:0.7rem;line-height:1.2}.btn-lg{padding:var(--spacing-lg) var(--spacing-xl);font-size:1rem}.btn-icon{font-size:1.1em}.btn:focus-visible{outline:none;box-shadow:0 0 0 2px rgba(76,201,240,0.5),0 0 15px rgba(76,201,240,0.3)}.btn:disabled,.btn[disabled]{opacity:0.4;cursor:not-allowed;pointer-events:none;box-shadow:none}input[type="file"]::file-selector-button{padding:8px 16px;margin-right:12px;background:rgba(76,201,240,0.15);border:2px solid var(--neon-cyan);border-radius:var(--radius-md);color:var(--neon-cyan);cursor:pointer;font-weight:600;font-family:var(--font-primary);text-transform:uppercase;letter-spacing:0.5px;font-size:0.85rem;box-shadow:0 0 15px rgba(76,201,240,0.5),0 0 30px rgba(76,201,240,0.3),inset 0 0 20px rgba(76,201,240,0.1);text-shadow:0 0 10px rgba(76,201,240,0.8);transition:all 0.3s ease}input[type="file"]::file-selector-button:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 20px rgba(76,201,240,0.7),0 0 40px rgba(76,201,240,0.4),inset 0 0 25px rgba(76,201,240,0.15)}.pagination{display:flex;justify-content:center;align-items:center;gap:var(--spacing-sm);margin-top:var(--spacing-xl)}.pagination-btn{padding:var(--spacing-sm) var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);cursor:pointer;transition:all var(--transition-fast)}.pagination-btn:hover{border-color:var(--primary-cyan);color:var(--primary-cyan)}.pagination-btn.active{background:var(--primary-cyan);border-color:var(--primary-cyan);color:var(--bg-darkest)}.pagination-btn:disabled{opacity:0.5;cursor:not-allowed}.back-to-top-btn{position:fixed;bottom:30px;right:30px;width:50px;height:50px;border-radius:50%;background:rgba(20,26,35,0.95);border:2px solid var(--neon-cyan);color:var(--neon-cyan);font-size:1.5rem;cursor:pointer;opacity:0;visibility:hidden;transform:translateY(20px);transition:opacity 0.3s ease,visibility 0.3s ease,transform 0.3s ease,background 0.3s ease,box-shadow 0.3s ease;z-index:9998;display:flex;align-items:center;justify-content:center;box-shadow:0 0 15px rgba(76,201,240,0.3),inset 0 0 10px rgba(76,201,240,0.1)}.back-to-top-btn.visible{opacity:1;visibility:visible;transform:translateY(0)}.back-to-top-btn:hover{background:rgba(76,201,240,0.2);box-shadow:0 0 25px rgba(76,201,240,0.5),inset 0 0 15px rgba(76,201,240,0.2);transform:translateY(-3px)}.back-to-top-btn:active{transform:translateY(0)}.back-to-top-icon{font-weight:bold;text-shadow:0 0 10px rgba(76,201,240,0.8)}.btn-group{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-md)}.btn-danger-outline{background:rgba(239,68,68,0.1);border:2px solid var(--neon-red);color:var(--neon-red);box-shadow:0 0 10px rgba(239,68,68,0.3),0 0 20px rgba(239,68,68,0.2);text-shadow:0 0 8px rgba(239,68,68,0.6)}.btn-danger-outline:hover{background:rgba(239,68,68,0.2);box-shadow:0 0 15px rgba(239,68,68,0.5),0 0 30px rgba(239,68,68,0.3)}.form-input,input[type="text"],input[type="number"],input[type="email"],input[type="password"],input[type="search"],input[type="date"],textarea{width:100%;padding:0.75rem 1rem;background:var(--bg-darker,#0d1117);border:1px solid var(--border-color,#2a3542);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:border-color 0.2s ease,box-shadow 0.2s ease}.form-input:focus,input[type="text"]:focus,input[type="number"]:focus,input[type="email"]:focus,input[type="password"]:focus,input[type="search"]:focus,input[type="date"]:focus,textarea:focus{outline:none;border-color:var(--neon-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15),0 0 15px rgba(76,201,240,0.2)}.form-input::placeholder,input::placeholder,textarea::placeholder{color:var(--text-muted)}select,.form-select{width:100%;padding:0.75rem 2.5rem 0.75rem 1rem;background:var(--bg-darker,#0d1117);border:1px solid var(--border-color,#2a3542);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 1rem center;transition:border-color 0.2s ease,box-shadow 0.2s ease}select:focus,.form-select:focus{outline:none;border-color:var(--neon-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15),0 0 15px rgba(76,201,240,0.2)}select:hover,.form-select:hover{border-color:var(--neon-cyan)}select option{background:var(--bg-medium);color:var(--text-primary);padding:0.5rem}.styled-select{appearance:none;-webkit-appearance:none;-moz-appearance:none;padding:0.75rem 2.5rem 0.75rem 1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 1rem center;transition:border-color 0.2s ease,box-shadow 0.2s ease}.styled-select:hover{border-color:var(--primary-cyan)}.styled-select:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.toggle-switch{position:relative;display:inline-block;width:52px;height:28px;flex-shrink:0}.toggle-switch input{opacity:0;width:0;height:0}.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--neon-red);border-radius:28px;transition:all 0.3s ease;box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(239,68,68,0.3)}.toggle-slider:before{position:absolute;content:"";height:22px;width:22px;left:3px;bottom:3px;background:white;border-radius:50%;transition:all 0.3s ease;box-shadow:0 2px 4px rgba(0,0,0,0.3)}.toggle-switch input:checked + .toggle-slider{background:var(--neon-green);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 15px rgba(34,197,94,0.5)}.toggle-switch input:checked + .toggle-slider:before{transform:translateX(24px)}.toggle-switch input:focus + .toggle-slider{box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 0 2px rgba(76,201,240,0.3)}.toggle-item{display:flex;align-items:center;gap:0.75rem}.toggle-label{color:var(--text-secondary);font-size:0.9rem;cursor:pointer;user-select:none;transition:color 0.2s ease}.toggle-label.active{color:var(--text-primary)}.toggle-field{display:flex;align-items:center;gap:var(--spacing-md)}.toggle-switch-wrapper{display:flex;align-items:center;gap:var(--spacing-sm)}.toggle-switch-wrapper .toggle-label-text{font-size:0.85rem;color:var(--text-secondary)}.form-group{margin-bottom:var(--spacing-md)}.form-label{display:block;font-size:0.85rem;font-weight:600;color:var(--text-secondary);margin-bottom:var(--spacing-xs)}.filter-select{padding:var(--spacing-md) var(--spacing-lg);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;transition:all var(--transition-fast);min-width:150px}.filter-select:focus{outline:none;border-color:var(--primary-cyan)}.locked-path-wrapper{display:flex;align-items:center;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);overflow:hidden;flex:1}.locked-path-wrapper:focus-within{border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.locked-path-prefix{padding:0.75rem 0 0.75rem 1rem;color:var(--text-muted);font-family:var(--font-mono);font-size:0.9rem;background:rgba(76,201,240,0.05);border-right:1px solid var(--border-color);white-space:nowrap;user-select:none}.locked-path-wrapper input{flex:1;padding:0.75rem 1rem;background:transparent;border:none;color:var(--text-primary);font-family:var(--font-mono);font-size:0.9rem;outline:none}.locked-path-wrapper input::placeholder{color:var(--text-muted)}.edit-section{margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.edit-section:last-child{border-bottom:none;margin-bottom:0}.edit-section-title{color:var(--primary-cyan);font-family:var(--font-heading);font-size:1rem;font-weight:600;margin-bottom:var(--spacing-lg);padding-bottom:var(--spacing-sm);border-bottom:1px solid rgba(76,201,240,0.3)}.edit-field{margin-bottom:var(--spacing-md)}.edit-field label{display:block;color:var(--text-primary);font-weight:500;font-size:0.9rem;margin-bottom:6px}.edit-field .field-tip{display:block;font-size:0.75rem;color:var(--text-muted);margin-top:4px;font-style:italic}.edit-field .title-lock-check,.gem-field .title-lock-check{display:flex;align-items:center;gap:var(--spacing-xs);margin:var(--spacing-xs) 0 0;font-size:0.8rem;font-weight:normal;color:var(--text-muted);text-transform:none;letter-spacing:normal;cursor:pointer}.edit-field .title-lock-check input,.gem-field .title-lock-check input{width:auto;padding:0;margin:0}.edit-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md) var(--spacing-lg)}.edit-grid .full-width{grid-column:1 / -1}.form-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-md)}.form-help{font-size:0.8rem;color:var(--text-muted);margin-top:var(--spacing-xs)}.toggle-grid{display:flex;flex-wrap:wrap;gap:1.5rem}.card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);transition:all var(--transition-normal)}.card:hover{border-color:var(--card-border-hover);box-shadow:var(--shadow-lg)}.card + .card{margin-top:1.5rem}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-md);border-bottom:1px solid var(--card-border)}.card-title{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:0.5rem;text-transform:uppercase;letter-spacing:1px}.card-body{color:var(--text-secondary)}.glass-card,.card.glass,.glass-panel{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(76,201,240,0.2)}.glass-light,.glass-panel-light{background:rgba(20,26,35,0.6) !important;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}.glass-medium,.glass-panel-medium{background:rgba(20,26,35,0.8) !important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}.glass-dark,.glass-panel-dark{background:rgba(20,26,35,0.9) !important;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.glass-panel-hover:hover{background:rgba(20,26,35,0.85);border-color:var(--card-border-hover);box-shadow:var(--shadow-lg),0 0 20px rgba(76,201,240,0.1)}.system-card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);text-decoration:none;transition:all var(--transition-normal);display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;overflow:hidden}.system-card::before{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(180deg,transparent 60%,rgba(76,201,240,0.05) 100%);opacity:0;transition:opacity var(--transition-normal)}.system-card:hover{border-color:var(--primary-cyan);transform:translateY(-4px);box-shadow:var(--shadow-lg),0 0 20px rgba(76,201,240,0.1)}.system-card:hover::before{opacity:1}.system-logo{width:80px;height:80px;margin-bottom:var(--spacing-md);display:flex;align-items:center;justify-content:center}.system-logo img{max-width:100%;max-height:100%;object-fit:contain;filter:brightness(0.9);transition:all var(--transition-normal)}.system-card:hover .system-logo img{filter:brightness(1) drop-shadow(0 0 8px var(--primary-cyan-glow));transform:scale(1.05)}.system-logo-placeholder{width:80px;height:80px;background:var(--bg-lighter);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:2.5rem;color:var(--text-muted)}.system-name{font-family:var(--font-heading);font-size:1rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-xs);text-transform:uppercase;letter-spacing:1px}.system-info{display:flex;align-items:center;gap:var(--spacing-xs);font-size:0.8rem;color:var(--text-secondary)}.system-info-icon{color:var(--primary-cyan)}.section{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.status-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.status-item{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md) 0;border-bottom:1px solid var(--card-border)}.status-item:last-child{border-bottom:none}.status-info{display:flex;align-items:center;gap:var(--spacing-md)}.status-indicator{width:10px;height:10px;border-radius:50%;animation:pulse-status 2s ease-in-out infinite}.status-indicator.online{background:var(--status-online);box-shadow:0 0 10px var(--status-online)}.status-indicator.offline{background:var(--status-offline);box-shadow:0 0 10px var(--status-offline);animation:none}.status-name{color:var(--text-primary);font-weight:500}.status-badge{padding:var(--spacing-xs) var(--spacing-md);border-radius:var(--radius-full);font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:1px}.status-badge.online{background:rgba(0,230,118,0.15);color:var(--status-online);border:1px solid rgba(0,230,118,0.3)}.status-badge.connected{background:rgba(76,201,240,0.15);color:var(--primary-cyan);border:1px solid rgba(76,201,240,0.3)}.status-badge.offline{background:rgba(255,82,82,0.15);color:var(--status-offline);border:1px solid rgba(255,82,82,0.3)}.scraper-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-xl);margin-bottom:var(--spacing-xl)}.scraper-section h2{font-family:var(--font-heading);font-size:1.2rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-lg);padding-bottom:var(--spacing-md);border-bottom:1px solid var(--card-border)}.scraper-form{margin-bottom:var(--spacing-lg)}.system-controller-card{transition:border-color 0.2s ease,box-shadow 0.2s ease}.system-controller-card:hover{border-color:rgba(76,201,240,0.3);box-shadow:0 0 10px rgba(76,201,240,0.1)}.controller-item{transition:background 0.2s ease,transform 0.15s ease}.controller-item:hover{background:rgba(76,201,240,0.1) !important;transform:translateX(4px)}.controller-details{border-left:2px solid var(--neon-cyan)}.add-controller-form{border:1px solid rgba(34,197,94,0.2)}.add-controller-form:hover{border-color:rgba(34,197,94,0.3)}.badge{display:inline-flex;align-items:center;gap:0.5rem;padding:0.35rem 0.75rem;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-full);font-size:0.8rem;font-weight:500;color:var(--neon-cyan);white-space:nowrap}.badge-success{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green)}.badge-warning{background:rgba(245,158,11,0.15);border-color:var(--neon-orange);color:var(--neon-orange)}.badge-danger{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red)}.badge-purple{background:rgba(168,85,247,0.15);border-color:var(--neon-purple);color:var(--neon-purple)}.badge-secondary{background:rgba(100,116,139,0.15);border-color:var(--neon-gray);color:#94a3b8}.badge-removable{padding-right:0.5rem}.badge-remove{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;margin-left:0.25rem;background:transparent;border:none;border-radius:50%;color:inherit;cursor:pointer;font-size:1rem;line-height:1;opacity:0.7;transition:all 0.2s ease}.badge-remove:hover{opacity:1;background:rgba(255,255,255,0.1)}.game-badge{display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 10px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;border:1px solid;white-space:nowrap;line-height:1;height:28px;box-sizing:border-box}.game-badge.esrb-e{background:rgba(32,178,170,0.15);border-color:#20b2aa;color:#20b2aa}.game-badge.esrb-e10{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.esrb-t{background:rgba(234,179,8,0.15);border-color:#eab308;color:#eab308}.game-badge.esrb-m{background:rgba(249,115,22,0.15);border-color:#f97316;color:#f97316}.game-badge.esrb-ao{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}.game-badge.esrb-rp{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.pegi-3{background:rgba(32,178,170,0.15);border-color:#20b2aa;color:#20b2aa}.game-badge.pegi-7{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.pegi-12{background:rgba(234,179,8,0.15);border-color:#eab308;color:#eab308}.game-badge.pegi-16{background:rgba(249,115,22,0.15);border-color:#f97316;color:#f97316}.game-badge.pegi-18{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}.game-badge.age-rating-badge{background:transparent;border-color:transparent;color:var(--text-secondary);padding:0}.game-badge.age-rating-badge .age-rating-img{height:28px;width:auto;display:block;filter:drop-shadow(0 1px 3px rgba(0,0,0,0.4))}.game-badge.age-rating-badge.crossmapped{opacity:0.75}.game-badge.genre{background:rgba(168,85,247,0.15);border-color:#a855f7;color:#a855f7}.game-badge.year{background:rgba(76,201,240,0.15);border-color:#4cc9f0;color:#4cc9f0}.game-badge.platform{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.region{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.players{background:rgba(59,130,246,0.15);border-color:#3b82f6;color:#3b82f6}.game-badge.completion{font-size:0.7rem}.game-badge.completion.not-started{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.completion.in-progress{background:rgba(245,158,11,0.15);border-color:#f59e0b;color:#f59e0b}.game-badge.completion.played{background:rgba(59,130,246,0.15);border-color:#3b82f6;color:#3b82f6}.game-badge.completion.completed{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.completion.\31 00-percent,.game-badge.completion.hundred-percent{background:linear-gradient(135deg,rgba(255,215,0,0.25) 0%,rgba(255,170,0,0.25) 100%);border-color:#ffd700;color:#ffd700;text-shadow:0 0 8px rgba(255,215,0,0.5);box-shadow:0 0 10px rgba(255,215,0,0.3),inset 0 0 10px rgba(255,215,0,0.1)}.game-badge.score-badge{font-weight:700;min-width:50px}.game-badge.score-badge.score-good{background:rgba(34,197,94,0.2);border-color:#22c55e;color:#22c55e}.game-badge.score-badge.score-mixed{background:rgba(234,179,8,0.2);border-color:#eab308;color:#fcd34d}.game-badge.score-badge.score-bad{background:rgba(239,68,68,0.2);border-color:#ef4444;color:#f87171}.meta-badge{padding:var(--spacing-xs) var(--spacing-md);border-radius:var(--radius-full);font-size:0.8rem;font-weight:600}.meta-badge.system{background:linear-gradient(135deg,var(--primary-cyan),var(--primary-cyan-dark));color:var(--bg-darkest)}.meta-badge.year{background:var(--bg-lighter);color:var(--text-primary);border:1px solid var(--card-border)}.meta-badge.genre{background:rgba(114,9,183,0.2);color:#b388ff;border:1px solid rgba(114,9,183,0.3)}.metadata-status{display:inline-flex;align-items:center;gap:var(--spacing-xs);padding:var(--spacing-xs) var(--spacing-sm);border-radius:var(--radius-full);font-size:0.7rem;font-weight:600;text-transform:uppercase}.metadata-status.complete{background:rgba(0,230,118,0.15);color:var(--status-success)}.metadata-status.missing{background:rgba(255,171,0,0.15);color:var(--status-warning)}.source-badge{font-weight:600;text-transform:uppercase}.source-badge.thegamesdb{background:rgba(76,175,80,0.2);color:#81c784}.source-badge.igdb{background:rgba(33,150,243,0.2);color:#64b5f6}.source-badge.primary-source{border:2px solid currentColor;position:relative}.source-badge.primary-source::before{content:"★ ";font-size:0.8em}.source-badge.fallback-source{opacity:0.75;border:1px dashed currentColor}.psn-platform-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:var(--radius-sm);font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;border:1px solid;width:fit-content}.platform-ps3{background:rgba(205,154,42,0.15);color:#cd9a2a;border-color:rgba(205,154,42,0.3)}.platform-ps4{background:rgba(0,112,209,0.15);color:#4da6ff;border-color:rgba(0,112,209,0.3)}.platform-ps5{background:rgba(76,201,240,0.15);color:#4cc9f0;border-color:rgba(76,201,240,0.3)}.platform-psvita,.platform-vita{background:rgba(168,85,247,0.15);color:#a855f7;border-color:rgba(168,85,247,0.3)}.data-table{width:100%;border-collapse:collapse}@media (max-width:600px){.page-box .data-table{display:block;overflow-x:auto}}@media (max-width:600px){.page-box .data-table.stack-narrow,.data-table.stack-narrow tbody,.data-table.stack-narrow tr,.data-table.stack-narrow td{display:block;overflow-x:visible}.data-table.stack-narrow thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}.data-table.stack-narrow tr{margin-bottom:var(--spacing-sm);padding:var(--spacing-xs) 0;border:1px solid var(--card-border);border-radius:var(--radius-md)}.data-table.stack-narrow tr[hidden]{display:none}.data-table.stack-narrow td{padding:var(--spacing-xs) var(--spacing-sm);border-bottom:none;overflow-wrap:anywhere}.data-table.stack-narrow td[data-label]{display:flex;gap:var(--spacing-sm)}.data-table.stack-narrow td[data-label]::before{content:attr(data-label);flex:0 0 35%;color:var(--text-secondary);font-size:0.8rem;font-weight:600}}.data-table th{background:var(--bg-darker,#0d1117);color:var(--text-secondary);font-weight:600;text-align:left;padding:0.75rem 1rem;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid var(--card-border);position:sticky;top:0;z-index:10}.data-table td{padding:0.75rem 1rem;border-bottom:1px solid var(--card-border);color:var(--text-primary);font-size:0.9rem}.data-table tbody tr:hover{background:rgba(76,201,240,0.05)}.games-table-container{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);overflow:hidden}.games-table{width:100%;border-collapse:collapse}.games-table thead{background:linear-gradient(90deg,var(--bg-medium) 0%,var(--bg-light) 100%)}.games-table th{padding:var(--spacing-md) var(--spacing-lg);text-align:left;font-family:var(--font-heading);font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;border-bottom:1px solid var(--card-border)}.games-table tbody tr{border-bottom:1px solid var(--card-border);transition:background-color var(--transition-fast)}.games-table tbody tr:hover{background:var(--card-bg-hover)}.games-table tbody tr:last-child{border-bottom:none}.games-table td{padding:var(--spacing-md) var(--spacing-lg);vertical-align:middle}.game-row-boxart{width:60px;height:80px;object-fit:cover;border-radius:var(--radius-sm);border:1px solid var(--card-border)}.game-row-boxart.boxart-3d{object-fit:contain;background:var(--bg-medium)}.game-row-boxart-placeholder{width:60px;height:80px;background:var(--bg-lighter);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:1.5rem}.game-row-title{color:var(--text-primary);font-weight:600;text-decoration:none;transition:color var(--transition-fast)}.game-row-title:hover{color:var(--primary-cyan)}.game-row-system{color:var(--text-secondary);font-size:0.85rem}.game-row-genre{color:var(--text-secondary);font-size:0.85rem}.game-row-year{color:var(--text-secondary);font-size:0.85rem;font-family:var(--font-mono)}.score-list-section{background:rgba(0,0,0,0.2);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.score-list-section h4{color:var(--text-primary);font-family:var(--font-heading);font-size:1.1rem;margin-bottom:var(--spacing-md)}.score-list{display:flex;flex-direction:column;gap:var(--spacing-sm)}.score-list-item{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);text-decoration:none;transition:var(--transition-fast)}.score-list-item:hover{border-color:var(--primary-cyan);background:var(--card-bg-hover)}.score-game-info{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}.score-game-title{color:var(--text-primary);font-weight:600;font-size:0.95rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.score-game-system{color:var(--text-muted);font-size:0.8rem}.score-game-scores{display:flex;gap:var(--spacing-sm);flex-shrink:0}.score-lists-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-xl);margin-top:var(--spacing-xl)}.bulk-filter-section{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.bulk-action-section{padding:var(--spacing-lg);background:var(--bg-dark);border-radius:var(--radius-md);border:1px solid var(--card-border)}.bulk-preview-count{font-size:1.5rem;font-weight:700;color:var(--primary-cyan);margin-bottom:var(--spacing-sm)}.bulk-game-list{max-height:300px;overflow-y:auto;margin-top:var(--spacing-md)}.bulk-game-item{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.bulk-game-item:last-child{border-bottom:none}.tool-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.tool-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.tool-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.tool-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.tool-tab .tab-icon{font-size:1.1rem}.tool-tab .tab-label{}.system-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.system-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.625rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-align:center;white-space:nowrap}.system-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.system-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan);font-weight:600}.system-tab .tab-count{font-size:0.75rem;padding:0.15rem 0.5rem;background:rgba(0,0,0,0.3);border-radius:var(--radius-full);color:var(--text-secondary)}.system-tab.active .tab-count{background:var(--primary-cyan);color:var(--bg-darkest)}@media (max-width:768px){.tool-tabs,.system-tabs{gap:var(--spacing-xs);padding:var(--spacing-xs)}.tool-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.tool-tab .tab-label{display:none}.tool-tab .tab-icon{font-size:1.25rem}.system-tab{padding:0.5rem 0.75rem;font-size:0.8rem}}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:1000;opacity:0;visibility:hidden;transition:all 0.3s ease}.modal-overlay.active{opacity:1;visibility:visible}.modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);max-width:90%;max-height:90vh;overflow:auto;box-shadow:0 0 50px rgba(76,201,240,0.2)}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.modal-header h2{font-family:var(--font-heading);font-size:1.25rem;color:var(--neon-cyan)}.modal-close,.custom-modal-close,.about-modal-close,.game-detail-modal-close,.game-edit-modal-close,.modal-close-btn,.archive-modal-close,.modal .modal-close{background:none;border:none;font-size:1.75rem;font-weight:normal;color:var(--text-secondary);cursor:pointer;padding:0;line-height:1;transition:color 0.2s}.modal-close:hover,.custom-modal-close:hover,.about-modal-close:hover,.game-detail-modal-close:hover,.game-edit-modal-close:hover,.modal-close-btn:hover,.archive-modal-close:hover,.modal .modal-close:hover{color:var(--neon-red)}.modal-close:focus-visible,.custom-modal-close:focus-visible,.about-modal-close:focus-visible,.game-detail-modal-close:focus-visible,.game-edit-modal-close:focus-visible,.modal-close-btn:focus-visible,.archive-modal-close:focus-visible,.modal .modal-close:focus-visible{outline:2px solid rgba(76,201,240,0.5);outline-offset:2px}.modal-body{padding:var(--spacing-lg)}.modal-footer{display:flex;justify-content:flex-end;gap:var(--spacing-md);padding:var(--spacing-lg);border-top:1px solid var(--card-border)}.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:10000;align-items:center;justify-content:center}.modal.active{display:flex}.modal-content{max-width:90%;max-height:90%;border-radius:var(--radius-lg)}.modal-nav{position:absolute;top:50%;transform:translateY(-50%);width:50px;height:50px;background:var(--card-bg);border:none;border-radius:50%;color:var(--text-primary);font-size:1.5rem;cursor:pointer;transition:all var(--transition-fast)}.modal-nav:hover{background:var(--primary-cyan);color:var(--bg-darkest)}.modal-nav.prev{left:var(--spacing-lg)}.modal-nav.next{right:var(--spacing-lg)}.custom-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:10100;align-items:center;justify-content:center}.custom-modal.active{display:flex}.custom-modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);width:90%;max-width:500px;box-shadow:0 20px 60px rgba(0,0,0,0.5)}.custom-modal-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);display:flex;justify-content:space-between;align-items:center}.custom-modal-header h3{margin:0;font-family:var(--font-heading);color:var(--primary-cyan);font-size:1.1rem}.custom-modal-body{padding:var(--spacing-lg);color:var(--text-secondary);line-height:1.6}.custom-modal-body code{word-break:break-all;overflow-wrap:break-word;display:inline-block;max-width:100%}.custom-modal-body ul{max-width:100%;overflow:hidden}.custom-modal-body li{word-break:break-word;overflow-wrap:break-word}.custom-modal-footer{padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border);display:flex;justify-content:flex-end;gap:var(--spacing-sm)}.about-modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);z-index:10000;align-items:center;justify-content:center;backdrop-filter:blur(4px)}.about-modal-overlay.active{display:flex}.about-modal{background:linear-gradient(135deg,rgba(20,26,35,0.98),rgba(15,20,25,0.98));border:1px solid var(--primary-cyan);border-radius:var(--radius-lg);max-width:560px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:0 0 40px rgba(76,201,240,0.3),0 20px 40px rgba(0,0,0,0.5);animation:modalSlideIn 0.3s ease}.about-modal-header{display:flex;justify-content:space-between;align-items:center;padding:1.25rem 1.5rem;border-bottom:1px solid var(--card-border)}.about-modal-header h2{margin:0;font-size:1.25rem;font-family:var(--font-heading);color:var(--primary-cyan)}.about-modal-body{padding:1.5rem}.about-logo{display:flex;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.5rem;padding-bottom:1.5rem;border-bottom:1px solid var(--card-border)}.about-logo-icon{font-size:3rem;filter:drop-shadow(0 0 10px rgba(76,201,240,0.5))}.about-logo-text{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);letter-spacing:2px}.about-logo-text span{color:var(--primary-cyan);text-shadow:0 0 10px var(--primary-cyan-glow)}.about-details{display:flex;flex-direction:column;gap:0.75rem;margin-bottom:1.5rem}.about-item{display:flex;justify-content:space-between;align-items:center;padding:0.75rem;background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.about-item.about-description{flex-direction:column;align-items:flex-start;gap:0.5rem}.about-label{color:var(--text-secondary);font-size:0.85rem;font-weight:500}.about-value{color:var(--text-primary);font-weight:600}.about-description .about-value{font-weight:400;font-size:0.9rem;line-height:1.5}.about-links{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0.75rem}.folder-item{display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;border-radius:var(--radius-md);cursor:pointer;transition:background 0.15s ease;background:transparent;border:none;width:100%;text-align:left;font:inherit;color:inherit;appearance:none;-webkit-appearance:none}.folder-item:hover{background:rgba(76,201,240,0.1)}.folder-item.folder-parent{border-bottom:1px solid var(--border-color);margin-bottom:0.5rem;padding-bottom:0.75rem;border-radius:0}.folder-item .folder-icon{font-size:1.25rem}.folder-item .folder-name{flex:1;color:var(--text-primary);font-weight:500}.folder-item .folder-info{color:var(--text-muted);font-size:0.8rem}@keyframes toast-slide-in{from{transform:translateX(-100%);opacity:0}to{transform:translateX(0);opacity:1}}@keyframes toast-slide-out{from{transform:translateX(0);opacity:1}to{transform:translateX(-100%);opacity:0}}.unified-toast-container{position:fixed;bottom:24px;left:280px;z-index:10000;display:flex;flex-direction:column-reverse;gap:10px;width:360px}.sidebar-collapsed ~ .main-content .unified-toast-container,body:has(.sidebar.collapsed) .unified-toast-container{left:calc(var(--sidebar-collapsed-width, 70px) + 20px)}@media (max-width:768px){.unified-toast-container{left:16px;right:16px;width:auto;max-width:none}}.unified-toast{animation:toast-slide-in 0.3s ease-out forwards;animation-iteration-count:1;width:100%;max-width:360px;border-radius:var(--radius-lg);overflow:hidden;transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.unified-toast.removing{animation:toast-slide-out 0.3s ease-out forwards}.toast-content{display:flex;align-items:stretch;background:linear-gradient(135deg,var(--toast-bg-from,rgba(20,26,35,0.95)),var(--toast-bg-to,rgba(30,40,55,0.95)));border:2px solid var(--toast-color,var(--primary-cyan));border-radius:var(--radius-lg);box-shadow:0 0 20px rgba(var(--toast-color-rgb,76,201,240),0.3),0 4px 20px rgba(0,0,0,0.4);backdrop-filter:blur(10px)}.active-toast .toast-content{background:linear-gradient(135deg,var(--toast-bg-from,rgba(20,26,35,0.95)),var(--toast-bg-to,rgba(30,40,55,0.95)))}.queued-toast .toast-content{background:linear-gradient(135deg,var(--toast-queued-bg-from,rgba(25,30,40,0.90)),var(--toast-queued-bg-to,rgba(35,45,60,0.90)));opacity:0.9}.toast-main{flex:1;display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;transition:background var(--transition-fast);min-width:0}.toast-main:hover{background:rgba(var(--toast-color-rgb,76,201,240),0.08)}.toast-icon{font-size:1.5rem;flex-shrink:0}.toast-icon:not(.paused){animation:pulse 1.5s ease-in-out infinite}.toast-icon.paused{animation:none;opacity:0.7}.toast-info{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}.toast-title{font-weight:600;font-size:0.9rem;color:var(--toast-color,var(--primary-cyan))}.toast-title.paused{color:var(--neon-orange)}.toast-system-name{color:var(--neon-purple);font-size:0.75rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.toast-subtitle{color:var(--text-primary);font-size:0.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.toast-progress-bar{height:4px;background:rgba(var(--toast-color-rgb,76,201,240),0.2);border-radius:2px;overflow:hidden;margin:4px 0}.toast-progress-fill{height:100%;background:linear-gradient(90deg,var(--toast-color,var(--primary-cyan)),var(--secondary-magenta,#f72585));border-radius:2px;transition:width var(--transition-normal)}.toast-progress-text{display:flex;justify-content:space-between;font-size:0.75rem;color:var(--text-secondary);font-family:var(--font-mono)}.toast-stats{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:0.85rem;font-family:var(--font-mono);margin-top:2px}.toast-stats .stat{display:flex;align-items:center;gap:3px;white-space:nowrap}.toast-stats .stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700;color:var(--text-primary)}.toast-stats .stat.success,.toast-stats .stat-success{color:var(--neon-green)}.toast-stats .stat.failed,.toast-stats .stat-failed{color:var(--neon-red)}.toast-stats .stat.skipped,.toast-stats .stat-skipped{color:var(--neon-orange)}.toast-meta{font-size:0.75rem;color:var(--text-muted)}.toast-queued-info{font-size:0.75rem;color:var(--text-muted)}.toast-controls{display:flex;flex-direction:column;border-left:1px solid rgba(var(--toast-color-rgb,76,201,240),0.2);flex-shrink:0}.toast-btn,.toast-ctrl-btn{flex:1;padding:8px 10px;border:none;background:transparent;cursor:pointer;font-size:0.9rem;transition:all var(--transition-fast);color:var(--text-secondary);display:flex;align-items:center;justify-content:center;min-width:36px}.toast-btn:hover,.toast-ctrl-btn:hover{background:rgba(var(--toast-color-rgb,76,201,240),0.15);color:var(--toast-color,var(--primary-cyan))}.toast-btn.pause:hover,.toast-btn.pause.is-paused,.toast-ctrl-btn.pause:hover{background:rgba(245,158,11,0.2);color:var(--neon-orange)}.toast-btn.pause.is-paused{background:rgba(34,197,94,0.15);color:var(--neon-green)}.toast-btn.cancel:hover,.toast-ctrl-btn.cancel:hover{background:rgba(239,68,68,0.2);color:var(--neon-red)}.bulk-scrape-toast{--toast-color:var(--primary-cyan);--toast-color-rgb:76,201,240}.ra-sync-toast{--toast-color:var(--neon-orange);--toast-color-rgb:245,158,11}.ra-sync-toast .toast-main:hover{background:rgba(245,158,11,0.08)}.ra-refresh-toast{--toast-color:var(--neon-purple);--toast-color-rgb:168,85,247}.ra-refresh-toast .toast-main:hover{background:rgba(168,85,247,0.08)}.bulk-scrape-queued-toast{--toast-color:var(--primary-cyan-dark);--toast-color-rgb:58,159,194}.ra-sync-queued-toast{--toast-color:#c47d09;--toast-color-rgb:196,125,9}.ra-refresh-queued-toast{--toast-color:#8b46d4;--toast-color-rgb:139,70,212}.image-resize-toast{--toast-color:var(--neon-green);--toast-color-rgb:34,197,94}.image-resize-toast .toast-main:hover{background:rgba(34,197,94,0.08)}.toast-completed{--toast-color:var(--neon-green);--toast-color-rgb:34,197,94}.queued-toast{width:360px}.queued-toast .toast-content{padding:0}.queued-toast .toast-main{padding:10px 14px}.queued-toast .toast-icon{font-size:1.2rem}.queued-toast .toast-title{font-size:0.85rem}.queued-toast .toast-controls{flex-direction:row;min-width:36px}.queued-toast .toast-btn{min-width:36px;min-height:36px}.notification-container{position:fixed;top:20px;right:20px;z-index:10002;display:flex;flex-direction:column;gap:10px;max-width:400px}.notification{display:flex;align-items:center;gap:12px;padding:14px 18px;background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);box-shadow:var(--shadow-lg),0 0 20px rgba(0,0,0,0.3);transform:translateX(120%);opacity:0;transition:all var(--transition-normal)}.notification.show{transform:translateX(0);opacity:1}.notification-icon{font-size:1.2rem;flex-shrink:0}.notification-message{flex:1;color:var(--text-primary);font-size:0.9rem;line-height:1.4}.notification-close{background:none;border:none;color:var(--text-muted);font-size:1.2rem;cursor:pointer;padding:0 4px;opacity:0.6;transition:opacity 0.2s}.notification-close:hover{opacity:1;color:var(--text-primary)}.notification-success{border-color:var(--neon-green);box-shadow:var(--shadow-lg),0 0 15px rgba(34,197,94,0.2)}.notification-error{border-color:var(--neon-red);box-shadow:var(--shadow-lg),0 0 15px rgba(239,68,68,0.2)}.notification-warning{border-color:var(--neon-orange);box-shadow:var(--shadow-lg),0 0 15px rgba(245,158,11,0.2)}.notification-info{border-color:var(--primary-cyan);box-shadow:var(--shadow-lg),0 0 15px rgba(76,201,240,0.2)}.progress-container{background:#1a1a3e;border:2px solid var(--neon-cyan);border-radius:var(--radius-md);height:36px;overflow:hidden;position:relative;box-shadow:0 0 15px rgba(76,201,240,0.3)}.progress-bar{height:100%;background:linear-gradient(90deg,var(--neon-cyan),#7209b3);border-radius:calc(var(--radius-md) - 2px);display:flex;align-items:center;justify-content:center;color:white;font-size:0.9rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7);transition:width 0.3s ease;min-width:0}.progress-bar[data-percent="0"]{min-width:0;width:0%}.progress-bar:not([data-percent="0"]){min-width:60px}.progress-stats{display:flex;justify-content:space-between;color:var(--text-secondary);font-size:0.85rem;margin-top:0.5rem}.progress-container-sm{height:24px;border-width:1px}.progress-container-sm .progress-bar{font-size:0.75rem}.loading{display:flex;align-items:center;justify-content:center;padding:var(--spacing-2xl)}.loading-spinner{width:40px;height:40px;border:3px solid var(--card-border);border-top-color:var(--primary-cyan);border-radius:50%;animation:spin 1s linear infinite}.loading-spinner-small{width:20px;height:20px;border:2px solid var(--card-border);border-top-color:var(--primary-cyan);border-radius:50%;animation:spin 1s linear infinite}.loading-text{margin-left:var(--spacing-md);color:var(--text-secondary)}.loading-overlay{position:fixed;inset:0;background:rgba(10,14,23,0.85);display:flex;align-items:center;justify-content:center;z-index:10001;opacity:0;visibility:hidden;transition:all 0.3s ease}.loading-overlay.active{opacity:1;visibility:visible}.loading-content{display:flex;flex-direction:column;align-items:center;gap:16px}.loading-content .loading-text{color:var(--text-primary);font-size:1rem;font-family:var(--font-primary);margin-left:0}.empty-state{text-align:center;padding:var(--spacing-2xl);color:var(--text-secondary)}.empty-state-icon{font-size:4rem;margin-bottom:var(--spacing-md);opacity:0.5}.empty-state-title{font-size:1.2rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-sm)}.empty-state-text{margin-bottom:var(--spacing-lg)}.scan-log-container{background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);margin-top:var(--spacing-lg)}.scan-log-header{display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:var(--bg-dark);border-bottom:1px solid var(--border-color)}.scan-log-header h4{margin:0;font-size:0.9rem;color:var(--text-secondary);display:flex;align-items:center;gap:0.5rem}.scan-log-content{height:200px;overflow-y:auto;padding:0.75rem 1rem;font-family:var(--font-mono);font-size:0.8rem;line-height:1.6}.scan-log-content .log-entry{color:var(--text-secondary);padding:0.15rem 0}.scan-log-content .log-entry.info{color:var(--primary-cyan)}.scan-log-content .log-entry.warning{color:var(--neon-orange)}.scan-log-content .log-entry.error{color:var(--neon-red)}.rate-limits-container{background:var(--bg-darker,#0d1117);border:1px solid var(--card-border);border-radius:var(--radius-md);padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-md)}.rate-limit-header{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-muted);font-size:0.8rem;padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.rate-limit-item{display:flex;flex-direction:column;gap:var(--spacing-xs);padding:var(--spacing-md);background:rgba(255,255,255,0.02);border-radius:var(--radius-sm);border-left:3px solid var(--text-muted)}.rate-limit-item.status-green{border-left-color:var(--neon-green)}.rate-limit-item.status-yellow{border-left-color:var(--neon-orange)}.rate-limit-item.status-red{border-left-color:var(--neon-red)}.rate-limit-item.status-gray{border-left-color:var(--text-muted)}.rate-limit-item strong{color:var(--text-primary);font-size:0.95rem}.rate-limit-value{color:var(--text-secondary);font-size:0.9rem}.rate-limit-bar{width:100%;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden;margin:4px 0}.rate-limit-bar-fill{height:100%;border-radius:3px;transition:width 0.6s ease}.rate-limit-detail{color:var(--text-muted);font-size:0.8rem;line-height:1.5}.progress-bar-container{background:#1a1a3e;border:2px solid var(--neon-cyan);border-radius:var(--radius-md);height:36px;overflow:hidden;margin-bottom:0.5rem;box-shadow:0 0 15px rgba(76,201,240,0.3)}.progress-bar-fill{height:100%;background:linear-gradient(90deg,var(--neon-cyan),#7209b3);width:0%;transition:width 0.3s ease;display:flex;align-items:center;justify-content:center;color:white;font-size:0.9rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7);border-radius:calc(var(--radius-md) - 2px)}.progress-details{display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);margin-top:0.75rem}.progress-status{color:var(--text-secondary);font-size:0.9rem}.progress-status strong{color:var(--primary-cyan)}.scanner-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);padding:1.5rem;margin-bottom:1.5rem}.scanner-section h3{color:var(--text-primary);font-size:1rem;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem}.scanner-controls{display:flex;flex-wrap:wrap;gap:1rem;align-items:center}.quick-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.tag{display:inline-flex;align-items:center;gap:4px;padding:6px 12px;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-full);font-size:0.85rem;color:var(--text-secondary);white-space:nowrap}.tag.tag-system{background:rgba(76,201,240,0.15);border-color:rgba(76,201,240,0.4);color:var(--primary-cyan)}.tag.tag-date{background:rgba(168,85,247,0.15);border-color:rgba(168,85,247,0.4);color:#c084fc}.tag.tag-genre{background:rgba(34,197,94,0.15);border-color:rgba(34,197,94,0.4);color:#4ade80}.genre-select-container{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);min-height:44px}.genre-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);border-radius:var(--radius-full);color:var(--neon-purple);font-size:0.8rem;font-weight:500}.genre-tag .remove-genre{cursor:pointer;opacity:0.7;transition:opacity 0.15s}.genre-tag .remove-genre:hover{opacity:1}.genre-dropdown{position:relative}.genre-dropdown-menu{position:absolute;top:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);z-index:100;display:none}.genre-dropdown-menu.active{display:block}.genre-option{padding:8px 12px;cursor:pointer;transition:background 0.15s}.genre-option:hover{background:rgba(76,201,240,0.1)}.genre-option.selected{background:rgba(168,85,247,0.2);color:var(--neon-purple)}.controller-select-container{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);min-height:44px}.controller-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-full);color:var(--neon-cyan);font-size:0.8rem;font-weight:500}.controller-tag .remove-controller{cursor:pointer;opacity:0.7;transition:opacity 0.15s}.controller-tag .remove-controller:hover{opacity:1}.controller-dropdown{position:relative}.active-filter-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.active-filter-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(76,201,240,0.15);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-full);color:var(--primary-cyan);font-size:0.8rem;font-weight:500}.active-filter-tag .remove-filter{background:none;border:none;color:var(--primary-cyan);font-size:1rem;line-height:1;cursor:pointer;padding:0;opacity:0.7;transition:opacity 0.2s}.active-filter-tag .remove-filter:hover{opacity:1}.gem-tag-container{display:flex;flex-wrap:wrap;gap:var(--spacing-xs);min-height:36px;padding:var(--spacing-sm);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-xs)}.gem-tag-container:empty::before{content:'None selected';color:var(--text-muted);font-style:italic;font-size:0.85rem}.gem-tag-dropdown{width:100%;padding:8px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.9rem;cursor:pointer}.gem-tag-dropdown:hover,.gem-tag-dropdown:focus{border-color:var(--primary-cyan);outline:none}.gem-controller-inputs{display:flex;gap:var(--spacing-xs)}.gem-controller-inputs select{flex:1}.gem-custom-input{flex:1;padding:8px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.9rem}.gem-custom-input:hover,.gem-custom-input:focus{border-color:var(--primary-cyan);outline:none}#queueManagerModal .queue-item{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:10px;transition:all 0.2s ease}#queueManagerModal .queue-item:hover{border-color:var(--primary-cyan)}#queueManagerModal .queue-item.running{border-color:var(--primary-cyan);border-width:2px;background:rgba(76,201,240,0.05)}#queueManagerModal .queue-item.queued{border-color:var(--neon-orange)}#queueManagerModal .queue-item-icon{font-size:1.5rem;flex-shrink:0}#queueManagerModal .queue-item-info{flex:1;min-width:0}#queueManagerModal .queue-item-title{font-weight:600;color:var(--text-primary);margin-bottom:2px}#queueManagerModal .queue-item-system{font-size:0.85rem;color:var(--neon-purple)}#queueManagerModal .queue-item.queued .queue-item-system{color:var(--neon-orange)}#queueManagerModal .queue-item-games{font-size:0.8rem;color:var(--text-secondary)}#queueManagerModal .queue-item-progress{font-size:0.75rem;color:var(--primary-cyan);margin-top:4px}#queueManagerModal .queue-item-controls{display:flex;flex-direction:column;gap:4px}#queueManagerModal .queue-btn{padding:6px 10px;border:1px solid var(--card-border);border-radius:var(--radius-sm);background:var(--bg-medium);color:var(--text-secondary);cursor:pointer;font-size:0.8rem;transition:all 0.15s ease;white-space:nowrap}#queueManagerModal .queue-btn:hover{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}#queueManagerModal .queue-btn.danger:hover{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}#queueManagerModal .queue-btn:disabled{opacity:0.3;cursor:not-allowed}#queueManagerModal .queue-empty{text-align:center;color:var(--text-muted);padding:20px;font-style:italic}.launch-indicator{position:fixed;bottom:var(--spacing-lg);right:var(--spacing-lg);z-index:1000;display:inline-flex;align-items:center;gap:0.5rem;padding:0.4rem 0.9rem;background:var(--bg-card);border:1px solid var(--primary-cyan);border-radius:999px;box-shadow:0 0 12px rgba(76,201,240,0.35);color:var(--text-primary);font-family:var(--font-mono,monospace);font-size:0.85rem;cursor:pointer;transition:opacity 0.2s ease,transform 0.2s ease}.launch-indicator.hidden{opacity:0;pointer-events:none;transform:translateY(8px)}.launch-indicator-icon{color:var(--neon-green,#44ff66);font-weight:bold}.launch-indicator-count{font-weight:bold;color:var(--primary-cyan)}.launch-indicator-popover{position:fixed;bottom:calc(var(--spacing-lg) + 2.5rem);right:var(--spacing-lg);z-index:1001;min-width:280px;padding:var(--spacing-md);background:var(--bg-card);border:1px solid var(--primary-cyan);border-radius:var(--border-radius);box-shadow:0 0 20px rgba(76,201,240,0.4);color:var(--text-primary)}.launch-indicator-popover-title{font-family:var(--font-heading);font-size:0.95rem;color:var(--primary-cyan);margin-bottom:var(--spacing-sm);text-transform:uppercase;letter-spacing:0.05em}.launch-indicator-popover-row{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-sm);padding:0.4rem 0;border-top:1px solid var(--border-color,rgba(255,255,255,0.08));font-size:0.85rem}.launch-indicator-popover-row:first-of-type{border-top:none}.global-search{position:relative;margin-top:var(--spacing-md)}.global-search-input{width:100%;padding:var(--spacing-sm) var(--spacing-md);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.global-search-input::placeholder{color:var(--text-muted)}.global-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:var(--shadow-glow-cyan)}.global-search-list{position:absolute;top:calc(100% + var(--spacing-xs));left:0;right:0;z-index:1001;max-height:60vh;overflow-y:auto;background:var(--card-bg);border:1px solid var(--card-border-hover);border-radius:var(--radius-md);box-shadow:var(--shadow-lg);padding:var(--spacing-xs) 0}.global-search-group{padding:var(--spacing-xs) var(--spacing-sm);color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em}.global-search-option{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-xs) var(--spacing-sm);color:var(--text-primary);text-decoration:none;min-height:36px}.global-search-option:hover,.global-search-option[aria-selected="true"]{background:var(--card-bg-hover);color:var(--text-accent)}.global-search-thumb{flex:0 0 24px;height:32px;display:flex;align-items:center;justify-content:center}.global-search-thumb img{max-width:24px;max-height:32px;object-fit:contain}.global-search-text{flex:1;min-width:0;display:flex;flex-direction:column}.global-search-label{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.25}.global-search-meta{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:0.8rem}.global-search-empty{padding:var(--spacing-sm);color:var(--text-muted)}.game-card-wrapper{background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden;transition:all var(--transition-normal)}.game-card-wrapper:hover{border-color:var(--primary-cyan);box-shadow:0 0 20px rgba(76,201,240,0.2);transform:translateY(-2px)}.game-hero{display:grid;grid-template-columns:300px 1fr;gap:var(--spacing-xl);margin-bottom:var(--spacing-xl);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-xl);position:relative;overflow:hidden}.game-hero::before{content:'';position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta),var(--primary-cyan))}.hero-boxart{width:300px;flex-shrink:0;position:relative}.hero-boxart .game-boxart-image{width:100%;height:auto;border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);border:2px solid var(--card-border)}.hero-info{display:flex;flex-direction:column;min-width:0;flex:1}.game-boxart-container{position:relative}.game-boxart{width:100%;border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);border:2px solid var(--card-border)}.game-boxart-placeholder{width:100%;aspect-ratio:3/4;background:var(--bg-lighter);border-radius:var(--radius-lg);display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text-muted);font-size:4rem;border:2px dashed var(--card-border)}.game-boxart-placeholder span{font-size:1rem;margin-top:var(--spacing-md)}.game-info{display:flex;flex-direction:column}.game-title-section{margin-bottom:var(--spacing-lg)}.game-title{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);margin-bottom:var(--spacing-sm);line-height:1.2}.game-meta-badges{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.game-ratings{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.rating-display{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm) var(--spacing-md);background:var(--bg-lighter);border-radius:var(--radius-md);border:1px solid var(--card-border)}.rating-display img{height:32px}.rating-display .rating-text{font-weight:600;font-size:0.9rem}.rating-display.esrb{border-color:var(--rating-esrb-border);background:var(--rating-esrb-bg)}.rating-display.esrb .rating-text{color:var(--rating-esrb)}.rating-display.pegi{border-color:var(--rating-pegi-border);background:var(--rating-pegi-bg)}.rating-display.pegi .rating-text{color:var(--rating-pegi)}.game-details-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.detail-item{display:flex;flex-direction:column;gap:var(--spacing-xs)}.detail-label{font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px}.detail-value{color:var(--text-primary);font-size:0.95rem}.game-description{margin-bottom:var(--spacing-lg)}.game-description h3{font-family:var(--font-heading);font-size:1rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-sm)}.game-description p{color:var(--text-secondary);line-height:1.7}.screenshots-section{margin-bottom:var(--spacing-xl)}.screenshots-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:var(--spacing-md)}.screenshot-item{position:relative;aspect-ratio:16/9;border-radius:var(--radius-md);overflow:hidden;cursor:pointer;border:2px solid var(--card-border);transition:all var(--transition-fast)}.screenshot-item:hover{border-color:var(--primary-cyan);transform:scale(1.02)}.screenshot-item img{width:100%;height:100%;object-fit:cover}.screenshot-item::after{content:'🔍';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:2rem;opacity:0;transition:opacity var(--transition-fast)}.screenshot-item:hover::after{opacity:1}.search-results{margin-top:var(--spacing-lg)}.search-result{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm);transition:all var(--transition-fast)}.search-result:hover{border-color:var(--primary-cyan);background:var(--bg-light)}.search-result-info h4{color:var(--text-primary);font-weight:600;margin-bottom:var(--spacing-xs)}.search-result-meta{display:flex;gap:var(--spacing-sm);font-size:0.8rem}.search-result-meta span{padding:2px 8px;background:var(--bg-lighter);border-radius:var(--radius-sm);color:var(--text-secondary)}.search-result-alts{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:var(--spacing-xs);font-size:0.78rem}.search-result-alts-label{color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;font-size:0.7rem;font-weight:600}.game-detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(10,14,23,0.9);backdrop-filter:blur(4px);z-index:10000;align-items:center;justify-content:center;padding:var(--spacing-lg)}.game-detail-modal.active{display:flex}.game-detail-modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);width:100%;max-width:800px;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 0 40px rgba(0,0,0,0.5),0 0 80px rgba(76,201,240,0.1)}.game-detail-modal-close{position:absolute;top:var(--spacing-md);right:var(--spacing-md);z-index:10}.game-detail-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:var(--spacing-xl);min-height:300px;color:var(--text-secondary)}.game-detail-content{padding:var(--spacing-lg)}.gdm-header{display:flex;gap:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.gdm-boxart-container{flex-shrink:0;width:180px}.gdm-boxart{width:100%;border-radius:var(--radius-md);box-shadow:0 4px 20px rgba(0,0,0,0.3)}.gdm-info{flex:1}.gdm-title{font-family:var(--font-heading);font-size:1.5rem;color:var(--primary-cyan);margin:0 0 var(--spacing-sm) 0}.gdm-meta{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.gdm-system-badge{background:var(--primary-cyan);color:var(--bg-darkest);padding:2px 8px;border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600}.gdm-year,.gdm-players{background:rgba(var(--toast-color-rgb,76,201,240),0.15);color:var(--text-primary);padding:2px 8px;border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600;border:1px solid rgba(var(--toast-color-rgb,76,201,240),0.25)}.gdm-ratings{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.gdm-ratings .rating-badge{display:inline-block;padding:4px 8px;border-radius:var(--radius-sm);font-size:0.75rem;font-weight:600}.gdm-ratings .rating-badge.esrb{background:rgba(168,85,247,0.2);color:var(--neon-purple);border:1px solid rgba(168,85,247,0.3)}.gdm-ratings .rating-badge.score{background:rgba(34,197,94,0.2);color:var(--neon-green);border:1px solid rgba(34,197,94,0.3)}.gdm-completion .completion-badge{display:inline-block;padding:4px 10px;border-radius:var(--radius-sm);font-size:0.75rem;font-weight:600}.gdm-completion .status-in_progress,.gdm-completion .status-in-progress{background:rgba(76,201,240,0.2);color:var(--primary-cyan);border:1px solid rgba(76,201,240,0.3)}.gdm-completion .status-completed{background:rgba(34,197,94,0.2);color:var(--neon-green);border:1px solid rgba(34,197,94,0.3)}.gdm-completion .status-played{background:rgba(59,130,246,0.2);color:#3b82f6;border:1px solid rgba(59,130,246,0.3)}.gdm-completion .status-100_percent,.gdm-completion .status-100-percent{background:linear-gradient(135deg,rgba(245,158,11,0.2),rgba(247,37,133,0.2));color:var(--completion-gold);border:1px solid rgba(245,158,11,0.3)}.gdm-description{color:var(--text-secondary);line-height:1.6;margin-bottom:var(--spacing-lg);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.gdm-details-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.gdm-detail{display:flex;flex-direction:column}.gdm-detail .label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px}.gdm-detail span:last-child{color:var(--text-primary)}.gdm-achievements{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.ra-badge-modal{background:linear-gradient(135deg,var(--neon-orange),#d97706);color:white;padding:6px 12px;border-radius:var(--radius-md);font-weight:600}.gdm-achievements .game-badge.achievement-progress{font-size:0.75rem;padding:4px 10px}.gdm-achievements .ach-icon{width:16px;height:16px;vertical-align:middle}.gdm-achievements .ach-bar{display:inline-block;width:40px;height:4px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;vertical-align:middle;margin-left:4px}.gdm-achievements .ach-fill{display:block;height:100%;border-radius:2px;transition:width 0.3s ease}.gdm-screenshots{display:flex;gap:var(--spacing-sm);overflow-x:auto;padding-bottom:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.gdm-screenshot{height:80px;border-radius:var(--radius-sm);cursor:pointer;transition:transform var(--transition-fast)}.gdm-screenshot:hover{transform:scale(1.05)}.gdm-video{margin-bottom:var(--spacing-lg)}.gdm-video video{width:100%;max-height:300px;border-radius:var(--radius-md);background:#000}.gdm-lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.95);z-index:10002;align-items:center;justify-content:center;flex-direction:column}.gdm-lightbox.active{display:flex}.gdm-lightbox-image{max-width:90%;max-height:80vh;object-fit:contain;border-radius:var(--radius-md)}.gdm-lightbox-close{position:absolute;top:20px;right:35px;font-size:2.5rem;color:var(--text-primary);cursor:pointer;z-index:10003;transition:color var(--transition-fast);background:none;border:none;line-height:1}.gdm-lightbox-close:hover{color:var(--primary-cyan)}.gdm-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.5);border:2px solid var(--card-border);color:var(--text-primary);font-size:3rem;padding:10px 20px;cursor:pointer;border-radius:var(--radius-md);transition:all var(--transition-fast);z-index:10003}.gdm-lightbox-nav:hover{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.gdm-lightbox-nav.prev{left:20px}.gdm-lightbox-nav.next{right:20px}.gdm-lightbox-counter{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);color:var(--text-secondary);font-size:0.9rem;font-family:var(--font-mono)}.gdm-lightbox-hint{position:absolute;bottom:45px;left:50%;transform:translateX(-50%);color:var(--text-muted);font-size:0.75rem;white-space:nowrap}.gdm-actions{display:flex;gap:var(--spacing-md);padding-top:var(--spacing-md);border-top:1px solid var(--card-border)}.game-edit-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(10,14,23,0.95);backdrop-filter:blur(4px);z-index:10001;align-items:center;justify-content:center;padding:var(--spacing-lg)}.game-edit-modal.active{display:flex}.game-edit-modal-content{background:var(--bg-medium);border:1px solid var(--primary-cyan);border-radius:var(--radius-lg);width:100%;max-width:700px;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 0 40px rgba(0,0,0,0.5),0 0 60px rgba(76,201,240,0.15)}.game-edit-modal-close{position:absolute;top:var(--spacing-md);right:var(--spacing-md);z-index:10}.gem-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.gem-title{font-family:var(--font-heading);font-size:1.25rem;color:var(--primary-cyan);margin:0;padding-right:2rem}.gem-body{padding:var(--spacing-lg);display:grid}.gem-section{margin-bottom:var(--spacing-lg)}.gem-section:last-child{margin-bottom:0}.gem-label{display:block;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-sm)}.gem-select{width:100%;padding:10px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:1rem;cursor:pointer;transition:border-color var(--transition-fast)}.gem-select:hover,.gem-select:focus{border-color:var(--primary-cyan);outline:none}.gem-hltb-display{padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.gem-hltb-empty{color:var(--text-muted);font-style:italic}.gem-hltb-current{color:var(--text-primary);display:block}.gem-hltb-match-info{display:block;font-size:0.8rem;color:var(--text-muted);margin-top:var(--spacing-xs)}.gem-hltb-actions{display:flex;gap:var(--spacing-sm)}.gem-hltb-results{margin-top:var(--spacing-md);padding:var(--spacing-md);background:rgba(76,201,240,0.05);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-md)}.gem-hltb-searching{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-secondary)}.gem-hltb-match{margin-bottom:var(--spacing-md)}.gem-hltb-match-title{display:block;color:var(--text-primary);font-weight:600}.gem-hltb-match-confidence{font-size:0.8rem;color:var(--neon-green)}.gem-hltb-match-platform{display:block;font-size:0.8rem;color:var(--neon-purple);margin-top:2px}.gem-hltb-times{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.gem-hltb-time{text-align:center;padding:var(--spacing-sm);background:rgba(0,0,0,0.2);border-radius:var(--radius-sm)}.gem-hltb-time-label{display:block;font-size:0.7rem;color:var(--text-muted);text-transform:uppercase;margin-bottom:2px}.gem-hltb-time-value{display:block;color:var(--primary-cyan);font-weight:600}.gem-hltb-error{color:var(--neon-red);font-size:0.9rem}.gem-footer{display:flex;justify-content:space-between;gap:var(--spacing-md);padding:var(--spacing-lg);border-top:1px solid var(--card-border)}.gem-footer-right{display:flex;gap:var(--spacing-sm)}.gem-tabs{display:flex;gap:var(--spacing-xs);margin-top:var(--spacing-md);overflow-x:auto;padding-bottom:var(--spacing-xs)}.gem-tab{padding:8px 12px;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-size:0.85rem;cursor:pointer;transition:all var(--transition-fast);white-space:nowrap}.gem-tab:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.gem-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.gem-tab-content{grid-area:1 / 1;visibility:hidden;pointer-events:none}.gem-tab-content.active{visibility:visible;pointer-events:auto}.gem-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md)}.gem-field{display:flex;flex-direction:column}.gem-field label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-xs)}.gem-field input,.gem-field select,.gem-field textarea{padding:10px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.95rem;transition:border-color var(--transition-fast)}.gem-field input:hover,.gem-field input:focus,.gem-field select:hover,.gem-field select:focus,.gem-field textarea:hover,.gem-field textarea:focus{border-color:var(--primary-cyan);outline:none}.gem-field input::placeholder,.gem-field textarea::placeholder{color:var(--text-muted)}.gem-field-full{grid-column:1 / -1}.gem-field textarea{resize:vertical;min-height:120px}.gem-field-row{display:flex;gap:var(--spacing-xs)}.gem-field-row input{flex:1}.gem-field-row .btn{flex-shrink:0}.gem-toggle-row{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) 0}.gem-toggle-label{color:var(--text-secondary);font-size:0.9rem}.psn-profile-bar{display:flex;align-items:center;gap:var(--spacing-lg);padding:var(--spacing-md) var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-avatar{width:48px;height:48px;border-radius:50%;border:2px solid var(--primary-cyan);object-fit:cover}.psn-avatar-placeholder{width:48px;height:48px;border-radius:50%;border:2px solid var(--card-border);background:var(--bg-medium);display:flex;align-items:center;justify-content:center;font-size:1.5rem}.psn-profile-info{flex:1}.psn-username{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary)}.psn-level{font-size:0.8rem;color:var(--text-secondary)}.psn-level strong{color:var(--primary-cyan)}.psn-sync-status{font-size:0.75rem;color:var(--text-muted);display:flex;align-items:center;gap:6px}.psn-stats-bar{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.psn-stat-box{padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);text-align:center}.psn-stat-icon{font-size:1.5rem;margin-bottom:var(--spacing-xs)}.psn-stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700}.psn-stat-value.platinum{color:var(--trophy-platinum)}.psn-stat-value.gold{color:var(--trophy-gold)}.psn-stat-value.silver{color:var(--trophy-silver)}.psn-stat-value.bronze{color:var(--trophy-bronze)}.psn-stat-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.platform-filters{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);flex-wrap:wrap}.platform-filter-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast)}.platform-filter-btn:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.platform-filter-btn.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.filter-count{font-size:0.7rem;padding:2px 6px;background:rgba(255,255,255,0.1);border-radius:var(--radius-full)}.platform-filter-btn.active .filter-count{background:rgba(76,201,240,0.2)}.psn-search-sort-row{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg);align-items:center}.psn-search-wrapper{flex:1;position:relative}.psn-search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-muted);pointer-events:none}.psn-search-input{width:100%;padding:10px 12px 10px 36px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem}.psn-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.psn-search-input::placeholder{color:var(--text-muted)}.psn-sort-controls{display:flex;align-items:center;gap:var(--spacing-sm)}.psn-sort-label{font-size:0.8rem;color:var(--text-muted);white-space:nowrap}.psn-sort-select{padding:8px 32px 8px 12px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.85rem;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}.psn-sort-select:focus{outline:none;border-color:var(--primary-cyan)}.psn-games-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-md)}.psn-trophy-game-card{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);transition:all var(--transition-fast);text-decoration:none;color:inherit}.psn-trophy-game-card:hover{border-color:var(--primary-cyan);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}.psn-trophy-game-thumb{width:64px;height:64px;border-radius:var(--radius-sm);object-fit:cover;border:1px solid var(--card-border);flex-shrink:0}.psn-trophy-game-thumb-placeholder{width:64px;height:64px;border-radius:var(--radius-sm);background:var(--bg-medium);border:1px solid var(--card-border);display:flex;align-items:center;justify-content:center;font-size:1.5rem;flex-shrink:0}.psn-trophy-game-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.psn-trophy-game-name{font-size:0.9rem;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.psn-trophy-game-npwr{font-family:var(--font-mono);font-size:0.65rem;color:var(--text-muted)}.psn-trophy-game-progress{display:flex;align-items:center;gap:var(--spacing-sm)}.psn-progress-track{flex:1;height:6px;background:var(--bg-dark);border-radius:3px;overflow:hidden}.psn-progress-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--neon-purple));border-radius:3px;transition:width var(--transition-normal)}.psn-progress-pct{font-family:var(--font-mono);font-size:0.7rem;color:var(--primary-cyan);min-width:32px;text-align:right}.psn-trophy-mini-badges{display:flex;gap:6px}.trophy-mini-badge{display:inline-flex;align-items:center;gap:3px;font-size:0.65rem;font-weight:600}.tmb-platinum{color:var(--trophy-platinum)}.tmb-gold{color:var(--trophy-gold)}.tmb-silver{color:var(--trophy-silver)}.tmb-bronze{color:var(--trophy-bronze)}.psn-game-info-card{display:flex;gap:var(--spacing-lg);padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-game-boxart{width:180px;flex-shrink:0}.psn-game-boxart img{width:100%;border-radius:var(--radius-md);border:1px solid var(--card-border)}.psn-game-boxart-placeholder{width:100%;aspect-ratio:1;background:var(--bg-medium);border-radius:var(--radius-md);border:1px solid var(--card-border);display:flex;align-items:center;justify-content:center;font-size:4rem}.psn-game-meta{flex:1}.psn-game-badges{display:flex;gap:var(--spacing-sm);flex-wrap:wrap;margin-bottom:var(--spacing-md)}.psn-game-description{font-size:0.85rem;color:var(--text-secondary);line-height:1.6;margin-bottom:var(--spacing-md);max-height:100px;overflow:hidden}.psn-meta-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-md)}.psn-meta-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin-bottom:2px}.psn-meta-value{font-size:0.85rem;color:var(--text-secondary)}.psn-trophy-progress-card{padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-trophy-progress-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--spacing-md)}.psn-trophy-progress-title{font-family:var(--font-heading);font-size:1rem;font-weight:600;display:flex;align-items:center;gap:8px}.psn-trophy-progress-fraction{font-family:var(--font-mono);font-size:1rem;color:var(--text-secondary)}.psn-trophy-progress-pct{font-family:var(--font-heading);font-size:1.2rem;font-weight:700;color:var(--primary-cyan)}.psn-trophy-progress-bar-container{height:28px;background:rgba(26,26,62,0.8);border:2px solid var(--primary-cyan);border-radius:var(--radius-md);overflow:hidden;margin-bottom:var(--spacing-md);box-shadow:0 0 15px rgba(76,201,240,0.2)}.psn-trophy-progress-bar-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--neon-purple));border-radius:var(--radius-md);transition:width 0.8s ease;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7)}.psn-trophy-type-breakdown{display:flex;justify-content:center;gap:var(--spacing-xl)}.psn-trophy-type-item{display:flex;align-items:center;gap:6px;font-size:0.85rem}.psn-trophy-type-icon{font-size:1.1rem}.psn-trophy-type-count{font-family:var(--font-mono)}.type-platinum .psn-trophy-type-count{color:var(--trophy-platinum)}.type-gold .psn-trophy-type-count{color:var(--trophy-gold)}.type-silver .psn-trophy-type-count{color:var(--trophy-silver)}.type-bronze .psn-trophy-type-count{color:var(--trophy-bronze)}.psn-info-panels-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.psn-info-panel{padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg)}.psn-info-panel-title{font-family:var(--font-heading);font-size:0.9rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-md);display:flex;align-items:center;gap:8px}.psn-detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-sm) var(--spacing-lg)}.psn-detail-label{font-size:0.75rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.psn-detail-value{font-size:0.85rem;color:var(--text-secondary)}.psn-mono{font-family:var(--font-mono);font-size:0.8rem}.psn-hltb-input{width:100%;padding:8px 12px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.85rem;margin-bottom:var(--spacing-sm)}.psn-hltb-input:focus{outline:none;border-color:var(--primary-cyan)}.psn-trophy-lists-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-lg)}.psn-trophy-list-panel{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);overflow:hidden}.psn-trophy-list-header{padding:var(--spacing-md) var(--spacing-lg);border-bottom:1px solid var(--card-border);font-family:var(--font-heading);font-size:0.9rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;display:flex;align-items:center;gap:8px}.psn-trophy-list-header.header-unlocked{color:var(--neon-green)}.psn-trophy-list-header.header-locked{color:var(--neon-gray)}.psn-trophy-list-body{max-height:600px;overflow-y:auto;padding:var(--spacing-sm)}.psn-trophy-list-body::-webkit-scrollbar{width:6px}.psn-trophy-list-body::-webkit-scrollbar-track{background:var(--bg-dark)}.psn-trophy-list-body::-webkit-scrollbar-thumb{background:var(--card-border);border-radius:3px}.psn-trophy-list-body::-webkit-scrollbar-thumb:hover{background:var(--primary-cyan)}.psn-trophy-entry{display:flex;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);border-radius:var(--radius-md);transition:background var(--transition-fast)}.psn-trophy-entry:hover{background:rgba(76,201,240,0.03)}.psn-trophy-entry + .psn-trophy-entry{border-top:1px solid rgba(42,53,66,0.5)}.psn-trophy-icon-box{width:48px;height:48px;border-radius:var(--radius-sm);background:var(--bg-medium);display:flex;align-items:center;justify-content:center;font-size:1.4rem;flex-shrink:0;border:1px solid var(--card-border);overflow:hidden}.psn-trophy-icon-box.locked{opacity:0.5;filter:grayscale(0.5)}.psn-trophy-icon-img{width:100%;height:100%;object-fit:cover}.psn-trophy-details{flex:1;min-width:0}.psn-trophy-name{font-size:0.85rem;font-weight:600;color:var(--text-primary);margin-bottom:2px}.psn-trophy-description{font-size:0.75rem;color:var(--text-muted);line-height:1.4}.psn-trophy-earned-info{display:flex;align-items:center;gap:var(--spacing-sm);margin-top:4px;flex-wrap:wrap}.psn-trophy-type-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:3px;font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.5px}.psn-trophy-type-badge.ttb-platinum{background:rgba(123,184,212,0.2);color:var(--trophy-platinum);border:1px solid rgba(123,184,212,0.3)}.psn-trophy-type-badge.ttb-gold{background:rgba(205,154,42,0.2);color:var(--trophy-gold);border:1px solid rgba(205,154,42,0.3)}.psn-trophy-type-badge.ttb-silver{background:rgba(168,168,168,0.2);color:var(--trophy-silver);border:1px solid rgba(168,168,168,0.3)}.psn-trophy-type-badge.ttb-bronze{background:rgba(205,127,50,0.2);color:var(--trophy-bronze);border:1px solid rgba(205,127,50,0.3)}.psn-trophy-rarity{display:inline-flex;align-items:center;gap:3px;font-size:0.65rem;color:var(--text-muted);padding:2px 6px;border-radius:3px;background:rgba(255,255,255,0.03)}.psn-trophy-rarity.rarity-ultra-rare{color:var(--neon-cyan)}.psn-trophy-rarity.rarity-very-rare{color:var(--neon-purple)}.psn-trophy-rarity.rarity-rare{color:var(--neon-orange)}.psn-trophy-rarity.rarity-common{color:var(--neon-gray)}.psn-trophy-earned-date{font-family:var(--font-mono);font-size:0.65rem;color:var(--text-muted);display:flex;align-items:center;gap:4px}.psn-trophy-earned-date .date-icon{font-size:0.6rem}.psn-dlc-separator{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);margin-top:var(--spacing-sm)}.psn-dlc-separator-line{flex:1;height:1px;background:var(--card-border)}.psn-dlc-separator-label{font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--neon-purple);white-space:nowrap}.psn-trophy-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:var(--spacing-xl);color:var(--text-muted);text-align:center}.psn-trophy-empty-icon{font-size:2rem;margin-bottom:var(--spacing-sm)}.psn-trophy-complete{color:var(--neon-green)}.psn-not-configured{text-align:center;padding:var(--spacing-2xl)}.psn-not-configured-icon{font-size:4rem;margin-bottom:var(--spacing-lg)}.psn-not-configured-title{font-family:var(--font-heading);font-size:1.5rem;color:var(--text-primary);margin-bottom:var(--spacing-md)}.psn-not-configured-text{color:var(--text-secondary);margin-bottom:var(--spacing-lg);max-width:500px;margin-left:auto;margin-right:auto}.psn-no-games{text-align:center;padding:var(--spacing-2xl);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-lg)}.psn-no-games-icon{font-size:3rem;margin-bottom:var(--spacing-md)}.psn-no-games-title{font-family:var(--font-heading);font-size:1.2rem;color:var(--text-primary);margin-bottom:var(--spacing-sm)}.psn-no-games-text{color:var(--text-muted)}.psn-visible-count{text-align:center;padding:var(--spacing-md);color:var(--text-muted);font-size:0.85rem}.achievement-overview{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg)}.achievement-stat{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md)}.achievement-stat-icon{font-size:2rem}.achievement-stat-info{display:flex;flex-direction:column}.achievement-stat-label{font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.achievement-stat-value{font-size:1.3rem;font-weight:600;color:var(--neon-cyan)}.achievements-page{--card-min:280px;--card-gap:var(--spacing-md)}.achievement-games-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.achievement-game-card{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md);text-decoration:none;color:inherit;transition:all 0.2s ease}.achievement-game-card:hover{border-color:var(--neon-cyan);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}.achievement-game-icon{flex-shrink:0;width:80px;height:80px;border-radius:var(--radius-sm);overflow:hidden;background:var(--bg-darker)}.achievement-game-icon img{width:100%;height:100%;object-fit:cover}.achievement-game-card .no-boxart{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2rem;background:var(--bg-darker)}.achievement-game-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.achievement-game-title{font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.achievement-game-system{font-size:0.8rem;color:var(--text-muted)}.achievement-game-progress{margin-top:auto}.achievement-progress-bar{height:6px;background:var(--bg-darker);border-radius:3px;overflow:hidden;margin-bottom:4px}.achievement-progress-fill{height:100%;background:linear-gradient(90deg,var(--neon-cyan),var(--neon-purple));border-radius:3px;transition:width 0.3s ease}.achievement-progress-fill.xbox-fill{background:linear-gradient(90deg,var(--neon-green),#14b873)}.achievement-progress-text{font-size:0.75rem;color:var(--text-muted)}.no-boxart-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:4rem;background:var(--bg-darker);color:var(--text-muted)}.trophy-type-label.gamerscore-earned{background:#ffd700;color:#111}.trophy-type-label.gamerscore-locked{background:var(--bg-lighter);color:var(--text-muted)}.stat-box{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);text-align:center;transition:all var(--transition-normal)}.stat-box:hover{border-color:var(--neon-cyan);box-shadow:0 0 20px rgba(76,201,240,0.2)}.stat-value{font-family:var(--font-heading);font-size:2.5rem;font-weight:700;color:var(--neon-cyan);line-height:1.2;text-shadow:0 0 20px rgba(76,201,240,0.5)}.stat-label{font-size:0.9rem;color:var(--text-secondary);margin-top:0.5rem;text-transform:uppercase;letter-spacing:0.5px}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.stat-card{background:linear-gradient(135deg,var(--card-bg) 0%,var(--bg-medium) 100%);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);position:relative;overflow:hidden;transition:all var(--transition-normal)}.stat-card::before{content:'';position:absolute;top:0;left:0;width:100%;height:3px;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta))}.stat-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-xl);border-color:var(--primary-cyan)}.stat-card-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:var(--spacing-md)}.stat-card .stat-label{font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;margin-top:0}.stat-icon{width:40px;height:40px;background:linear-gradient(135deg,rgba(76,201,240,0.2) 0%,rgba(247,37,133,0.2) 100%);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:1.2rem;color:var(--primary-cyan)}.stat-card .stat-value{font-family:var(--font-heading);font-size:3rem;font-weight:700;color:var(--text-primary);line-height:1;margin-bottom:var(--spacing-xs);text-shadow:none}.stat-subtitle{font-size:0.85rem;color:var(--text-secondary)}.stat-card.warning .stat-icon{background:linear-gradient(135deg,rgba(255,171,0,0.2) 0%,rgba(255,143,0,0.2) 100%);color:var(--status-warning)}.stat-card.warning::before{background:var(--status-warning)}.stat-card.success .stat-icon{background:linear-gradient(135deg,rgba(0,230,118,0.2) 0%,rgba(0,200,83,0.2) 100%);color:var(--status-success)}.stat-card.success::before{background:var(--status-success)}.psn-stats-bar{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.psn-stat-box{padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);text-align:center}.psn-stat-icon{font-size:1.5rem;margin-bottom:var(--spacing-xs)}.psn-stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700}.psn-stat-value.platinum{color:var(--trophy-platinum)}.psn-stat-value.gold{color:var(--trophy-gold)}.psn-stat-value.silver{color:var(--trophy-silver)}.psn-stat-value.bronze{color:var(--trophy-bronze)}.psn-stat-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.stat-box.warning .stat-value{color:var(--neon-orange)}.stat-box.danger .stat-value{color:var(--neon-red)}.stat-box.success .stat-value{color:var(--neon-green)}.filter-bar{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-xl);flex-wrap:wrap}.search-box{--search-icon-offset:var(--spacing-md);--search-icon-size:1.15rem;--search-icon-width:2rem;--search-icon-gap:var(--spacing-sm);flex:1;min-width:250px;position:relative}.search-icon{position:absolute;left:var(--search-icon-offset);top:50%;transform:translateY(-50%);color:var(--text-muted);font-size:var(--search-icon-size)}input.search-input{width:100%;padding:var(--spacing-md) var(--spacing-lg);padding-left:calc(var(--search-icon-offset) + var(--search-icon-width) + var(--search-icon-gap));background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:all var(--transition-fast)}input.search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 3px rgba(76,201,240,0.1)}input.search-input::placeholder{color:var(--text-muted)}.filter-select{padding:var(--spacing-md) var(--spacing-lg);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;transition:all var(--transition-fast);min-width:150px}.filter-select:focus{outline:none;border-color:var(--primary-cyan)}.platform-filters{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);flex-wrap:wrap}.platform-filter-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast)}.platform-filter-btn:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.platform-filter-btn.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.filter-count{font-size:0.7rem;padding:2px 6px;background:rgba(255,255,255,0.1);border-radius:var(--radius-full)}.platform-filter-btn.active .filter-count{background:rgba(76,201,240,0.2)}.bulk-filter-section{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.bulk-action-section{padding:var(--spacing-lg);background:var(--bg-dark);border-radius:var(--radius-md);border:1px solid var(--card-border)}.bulk-preview-count{font-size:1.5rem;font-weight:700;color:var(--primary-cyan);margin-bottom:var(--spacing-sm)}.bulk-game-list{max-height:300px;overflow-y:auto;margin-top:var(--spacing-md)}.bulk-game-item{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.bulk-game-item:last-child{border-bottom:none}.psn-search-sort-row{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg);align-items:center}.psn-search-wrapper{flex:1;position:relative}.psn-search-wrapper .search-input{width:100%;padding-left:calc(var(--search-icon-offset, var(--spacing-md)) + var(--search-icon-width, 2rem) + var(--search-icon-gap, var(--spacing-sm)))}.psn-sort-select{padding:var(--spacing-sm) var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;cursor:pointer}.psn-sort-select:focus{outline:none;border-color:var(--primary-cyan)}.hltb-card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-md)}.hltb-card-title{display:flex;align-items:center;gap:var(--spacing-sm);font-family:var(--font-primary);font-size:1rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-md)}.hltb-search-input{width:100%;padding:10px 14px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;margin-bottom:var(--spacing-sm)}.hltb-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.btn-hltb{width:100%;display:flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:10px 16px;background:rgba(76,201,240,0.1);border:1px solid var(--primary-cyan);border-radius:var(--radius-md);color:var(--primary-cyan);font-family:var(--font-primary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.btn-hltb:hover{background:rgba(76,201,240,0.2)}.btn-hltb:disabled{opacity:0.5;cursor:not-allowed}.hltb-results{margin-top:var(--spacing-md)}.hltb-match-header{display:flex;flex-wrap:wrap;align-items:center;gap:var(--spacing-xs);padding-bottom:var(--spacing-sm);margin-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.hltb-match-title{color:var(--primary-cyan);font-weight:600;font-size:0.9rem}.hltb-match-platform{color:var(--text-secondary);font-size:0.8rem}.hltb-match-year{color:var(--neon-orange);font-size:0.8rem;font-weight:500}.hltb-match-developer{color:var(--text-secondary);font-size:0.8rem;font-style:italic}.hltb-match-warning{color:var(--neon-orange);font-size:0.8rem;font-weight:600}.hltb-match-confidence{color:var(--accent-green);font-size:0.8rem;font-weight:500}.hltb-match-alt-notice{display:inline-flex;align-items:center;gap:0.25rem;color:var(--neon-orange);background:rgba(245,158,11,0.12);border:1px solid rgba(245,158,11,0.4);padding:2px 8px;border-radius:var(--radius-full);font-size:0.75rem;font-weight:600;cursor:help;white-space:nowrap}.hltb-time-row{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-sm) 0;border-bottom:1px solid var(--card-border)}.hltb-time-row:last-child{border-bottom:none}.hltb-label{color:var(--text-secondary);font-size:0.85rem}.hltb-value{color:var(--text-primary);font-size:0.85rem;font-weight:500}.hltb-value.highlight{color:var(--primary-cyan)}.hltb-actions{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-md)}.hltb-actions .btn{flex:1}.hltb-empty{color:var(--text-muted);font-size:0.9rem;padding:var(--spacing-sm) 0}.hltb-loading{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-secondary);font-size:0.9rem;padding:var(--spacing-sm) 0}.hltb-compact .hltb-time-row{padding:var(--spacing-xs) 0}.hltb-compact .hltb-label,.hltb-compact .hltb-value{font-size:0.8rem}.manual-reader{display:flex;flex-direction:column;width:min(96vw,1400px);height:94vh;background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl)}.manual-reader-header{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);border-bottom:1px solid var(--card-border)}.manual-reader-header h2{flex:1;min-width:0;margin:0;font-family:var(--font-heading);font-size:1.1rem;color:var(--neon-cyan);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.manual-reader-stage{flex:1;min-height:0;display:grid;place-items:center;padding:var(--spacing-sm);overflow:hidden;perspective:2400px;touch-action:pan-y;user-select:none}.manual-reader-stage:focus-visible{outline:2px solid var(--primary-cyan);outline-offset:-2px}.manual-reader-sheet{grid-area:1 / 1;max-width:100%;max-height:100%;box-shadow:var(--shadow-lg);transform-origin:left center;backface-visibility:hidden}.manual-reader-sheet.turn-out{z-index:1;animation:manual-turn-out 0.6s ease-in-out forwards}.manual-reader-sheet.turn-in{z-index:1;animation:manual-turn-in 0.6s ease-in-out forwards}@keyframes manual-turn-out{from{transform:rotateY(0deg)}to{transform:rotateY(-180deg)}}@keyframes manual-turn-in{from{transform:rotateY(-180deg)}to{transform:rotateY(0deg)}}@media (prefers-reduced-motion:reduce){.manual-reader-sheet.turn-out,.manual-reader-sheet.turn-in{animation:none}}.manual-reader-message{grid-area:1 / 1;color:var(--text-secondary);text-align:center}.manual-reader-controls{display:flex;align-items:center;justify-content:center;gap:var(--spacing-lg);padding:var(--spacing-sm);border-top:1px solid var(--card-border)}.manual-reader-controls .btn{white-space:nowrap}.manual-reader-page{min-width:8em;text-align:center;color:var(--text-primary);font-family:var(--font-mono)}@media (max-width:600px){.manual-reader{width:100vw;height:100dvh;border-radius:0}.manual-reader-header .btn{font-size:0.75rem}}.score-badge.score-good{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green)}.score-badge.score-mixed{background:rgba(234,179,8,0.15);border-color:var(--neon-orange);color:var(--neon-orange)}.score-badge.score-bad{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red)}.bulk-option-label{display:flex;align-items:center;gap:6px;padding:6px 12px;background:rgba(168,85,247,0.1);border:1px solid var(--neon-purple);border-radius:var(--radius-md);color:var(--neon-purple);font-size:0.85rem;cursor:pointer;transition:all var(--transition-fast)}.bulk-option-label:hover{background:rgba(168,85,247,0.2)}.bulk-option-label input[type="checkbox"]{width:16px;height:16px;accent-color:var(--neon-purple);cursor:pointer}.bulk-mode-selector{display:flex;gap:8px;align-items:center}.scrape-mode-toggle{display:flex;align-items:center;gap:12px;padding:6px 16px;background:rgba(100,116,139,0.1);border:1px solid var(--neon-gray);border-radius:var(--radius-md)}.scrape-mode-toggle .mode-label{font-size:0.85rem;font-weight:500;transition:all var(--transition-fast)}.scrape-mode-toggle .mode-label.mode-off{color:var(--text-muted)}.scrape-mode-toggle .mode-label.mode-on{color:var(--text-muted)}.scrape-mode-toggle:has(input:not(:checked)) .mode-off{color:var(--neon-orange);text-shadow:0 0 8px rgba(245,158,11,0.5)}.scrape-mode-toggle:has(input:checked) .mode-on{color:var(--neon-cyan);text-shadow:0 0 8px rgba(76,201,240,0.5)}.keep-media-option{display:flex;align-items:center;gap:6px;font-size:0.85rem;color:var(--text-muted);cursor:pointer}.bulk-mode-selector:has(#scrapeModeToggle:checked) .keep-media-option{display:none}.scrape-mode-toggle .toggle-switch{position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0}.scrape-mode-toggle .toggle-switch input{opacity:0;width:0;height:0}.scrape-mode-toggle .toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--neon-orange);border-radius:24px;transition:all var(--transition-normal);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(245,158,11,0.3)}.scrape-mode-toggle .toggle-slider:before{position:absolute;content:"";height:18px;width:18px;left:3px;bottom:3px;background:white;border-radius:50%;transition:all var(--transition-normal);box-shadow:0 2px 4px rgba(0,0,0,0.3)}.scrape-mode-toggle .toggle-switch input:checked + .toggle-slider{background:var(--neon-cyan);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(76,201,240,0.3)}.scrape-mode-toggle .toggle-switch input:checked + .toggle-slider:before{transform:translateX(20px)}.fanart-background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;background-size:cover;background-position:center;background-repeat:no-repeat;opacity:0;transition:opacity 0.5s ease,background-image 0.5s ease;pointer-events:none}.fanart-background::after{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient( to bottom,rgba(10,14,20,0.7) 0%,rgba(10,14,20,0.85) 50%,rgba(10,14,20,0.95) 100% )}.fanart-background.active{opacity:1}.glass-panel{background:rgba(20,28,40,0.7);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(76,201,240,0.2)}.glass-card{background:rgba(20,28,40,0.5);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px)}.glitch-text{position:relative}.glitch-text::before,.glitch-text::after{content:attr(data-text);position:absolute;top:0;left:0;width:100%;height:100%}.glitch-text::before{left:2px;text-shadow:-2px 0 var(--primary-magenta);clip:rect(24px,550px,90px,0);animation:glitch-anim 3s infinite linear alternate-reverse}.glitch-text::after{left:-2px;text-shadow:-2px 0 var(--primary-cyan);clip:rect(85px,550px,140px,0);animation:glitch-anim2 3s infinite linear alternate-reverse}@keyframes glitch-anim{0%{clip:rect(20px,9999px,40px,0)}25%{clip:rect(60px,9999px,80px,0)}50%{clip:rect(10px,9999px,30px,0)}75%{clip:rect(70px,9999px,90px,0)}100%{clip:rect(30px,9999px,50px,0)}}@keyframes glitch-anim2{0%{clip:rect(80px,9999px,100px,0)}25%{clip:rect(20px,9999px,40px,0)}50%{clip:rect(50px,9999px,70px,0)}75%{clip:rect(10px,9999px,30px,0)}100%{clip:rect(60px,9999px,80px,0)}}.scan-lines{position:absolute;top:0;left:0;width:100%;height:100%;background:repeating-linear-gradient( 0deg,rgba(0,0,0,0.1),rgba(0,0,0,0.1) 1px,transparent 1px,transparent 2px );pointer-events:none;opacity:0;transition:opacity var(--transition-normal)}.game-card-new:hover .scan-lines{opacity:0.3}.card-glow{position:absolute;top:-2px;left:-2px;right:-2px;bottom:-2px;background:linear-gradient(45deg,var(--primary-cyan),var(--primary-magenta),var(--primary-cyan));border-radius:calc(var(--radius-lg) + 2px);z-index:-1;opacity:0;filter:blur(8px);transition:opacity var(--transition-normal)}.game-card-new:hover .card-glow{opacity:0.5;animation:glow-pulse 2s ease-in-out infinite}@keyframes glow-pulse{0%,100%{opacity:0.3}50%{opacity:0.6}}.filter-bar-new{display:flex;gap:var(--spacing-md);align-items:center;flex-wrap:wrap;padding:var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);position:sticky;top:var(--sticky-top);z-index:100;background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.search-box-integrated{--search-icon-offset:12px;--search-icon-size:1rem;--search-icon-width:1.75rem;--search-icon-gap:var(--spacing-sm);position:relative;min-width:200px;max-width:350px;flex:1}.search-icon-inside{position:absolute;left:var(--search-icon-offset);top:50%;transform:translateY(-50%);font-size:var(--search-icon-size);pointer-events:none;z-index:1}.search-input-integrated{width:100%;padding:var(--spacing-sm) var(--spacing-md);padding-left:calc(var(--search-icon-offset) + var(--search-icon-width) + var(--search-icon-gap));padding-right:32px;background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.95rem;outline:none;transition:border-color var(--transition-fast)}.search-clear-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text-muted);font-size:1.2rem;cursor:pointer;padding:0 4px;line-height:1;opacity:0;transition:opacity 0.2s,color 0.2s;z-index:1}.search-clear-btn:hover{color:var(--primary-cyan)}.search-box-integrated:has(.search-input-integrated:not(:placeholder-shown)) .search-clear-btn{opacity:1}.search-input-integrated:focus{border-color:var(--primary-cyan)}.search-input-integrated::placeholder{color:var(--text-muted)}.filter-buttons{display:flex;gap:var(--spacing-sm);flex-wrap:wrap}.filter-btn{display:flex;align-items:center;gap:var(--spacing-xs);padding:var(--spacing-sm) var(--spacing-md);background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);cursor:pointer;transition:all var(--transition-fast);font-size:0.85rem}.filter-btn:hover{border-color:var(--primary-cyan);color:var(--primary-cyan);background:rgba(76,201,240,0.1)}.filter-btn:disabled{opacity:0.35;cursor:default;pointer-events:none}.filter-btn.active{border-color:var(--primary-cyan);background:rgba(76,201,240,0.2);color:var(--primary-cyan)}.filter-btn.clear-btn{border-color:var(--status-error);color:var(--status-error)}.filter-btn.clear-btn:hover{background:rgba(255,107,107,0.2)}.filter-btn.ra-filter-btn{border-color:rgba(255,215,0,0.4);color:var(--trophy-display-gold)}.filter-btn.ra-filter-btn:hover{border-color:var(--trophy-display-gold);background:rgba(255,215,0,0.15)}.filter-btn.ra-filter-btn.active{border-color:var(--trophy-display-gold);background:rgba(255,215,0,0.25);color:var(--trophy-display-gold);box-shadow:0 0 10px rgba(255,215,0,0.2)}.filter-btn.bonus-filter-btn{border-color:rgba(245,158,11,0.4);color:var(--neon-orange)}.filter-btn.bonus-filter-btn:hover{border-color:var(--neon-orange);background:rgba(245,158,11,0.15)}.filter-btn.bonus-filter-btn.active{border-color:var(--neon-orange);background:rgba(245,158,11,0.25);color:var(--neon-orange);box-shadow:0 0 10px rgba(245,158,11,0.2)}.filter-btn.refresh-ra-btn{border-color:rgba(76,201,240,0.4);color:var(--primary-cyan)}.filter-btn.refresh-ra-btn:hover{border-color:var(--primary-cyan);background:rgba(76,201,240,0.15)}.filter-btn.refresh-ra-btn:disabled{opacity:0.6;cursor:not-allowed}.filter-tag.ra-tag{background:rgba(255,215,0,0.2);border-color:rgba(255,215,0,0.4);color:var(--trophy-display-gold)}.filter-bar-top{display:flex;align-items:center;gap:var(--spacing-md);width:100%}.bulk-controls{display:flex;gap:var(--spacing-sm);align-items:center;margin-left:auto;flex-wrap:wrap;justify-content:flex-end}.bulk-btn{border-color:rgba(147,51,234,0.4);color:var(--neon-purple)}.bulk-btn:hover{border-color:var(--neon-purple);background:rgba(147,51,234,0.15)}.bulk-btn.active{border-color:var(--neon-purple);background:rgba(147,51,234,0.25);color:var(--neon-purple)}.bulk-action-btn{border-color:rgba(59,130,246,0.4);color:var(--neon-blue)}.bulk-action-btn:hover{border-color:var(--neon-blue);background:rgba(59,130,246,0.15)}.bulk-scrape-btn{border-color:rgba(34,197,94,0.4);color:var(--neon-green)}.bulk-scrape-btn:hover{border-color:var(--neon-green);background:rgba(34,197,94,0.15)}.game-card-wrapper{position:relative}.bulk-select-checkbox{position:absolute;top:8px;right:8px;z-index:20}.bulk-select-checkbox input[type="checkbox"]{width:22px;height:22px;cursor:pointer;accent-color:var(--primary-cyan)}.game-card-wrapper.selected .game-card-new{border-color:var(--primary-cyan);box-shadow:0 0 15px rgba(76,201,240,0.4)}.bulk-scrape-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:3000;align-items:center;justify-content:center}.bulk-scrape-modal.active{display:flex}.bulk-scrape-content{width:90%;max-width:500px;border-radius:var(--radius-lg);overflow:hidden}.bulk-scrape-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);display:flex;justify-content:space-between;align-items:center}.bulk-scrape-header h2{margin:0;color:var(--primary-cyan)}.bulk-scrape-body{padding:var(--spacing-lg)}.bulk-progress-info{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md)}.bulk-progress-text{flex:1;overflow:hidden}#bulkCurrentGame{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text-primary);font-weight:500}.bulk-progress-numbers{color:var(--primary-cyan);font-family:var(--font-mono);font-size:1.1rem;margin-left:var(--spacing-md)}.bulk-progress-bar{width:100%;height:20px;background:var(--bg-medium);border-radius:10px;margin-bottom:var(--spacing-md);border:2px solid rgba(76,201,240,0.5);box-shadow:inset 0 2px 4px rgba(0,0,0,0.5);overflow:hidden;padding:2px}.bulk-progress-fill{display:block;height:100%;width:var(--progress,0%);min-width:0;max-width:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta));border-radius:8px;box-shadow:0 0 10px rgba(76,201,240,0.5)}.bulk-status{text-align:center;color:var(--text-secondary);font-size:0.9rem;margin-bottom:var(--spacing-md)}.bulk-controls-row{display:flex;justify-content:center;gap:var(--spacing-md);margin-top:var(--spacing-lg);margin-bottom:var(--spacing-md);padding-top:var(--spacing-sm)}.bulk-results{display:flex;justify-content:space-around;padding:var(--spacing-md);background:rgba(0,0,0,0.3);border-radius:var(--radius-md);margin-top:var(--spacing-md)}.bulk-result-item{display:flex;align-items:center;gap:var(--spacing-xs);font-size:0.9rem}.bulk-result-item.success{color:var(--neon-green)}.bulk-result-item.failed{color:var(--neon-red)}.bulk-result-item.skipped{color:var(--neon-orange)}.bulk-scrape-footer{padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border);display:flex;justify-content:center}.active-filter-tags{display:flex;gap:var(--spacing-sm);flex:1;min-width:0;overflow-x:auto;scrollbar-width:thin}.active-filter-tags .filter-tag{flex-shrink:0;white-space:nowrap}.filter-tag{display:flex;align-items:center;gap:var(--spacing-xs);padding:4px 10px;background:var(--primary-cyan);color:var(--bg-dark);border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600}.filter-tag-exclude{background:rgba(239,68,68,0.2);border:1px solid rgba(239,68,68,0.5);color:var(--neon-red)}.filter-tag-remove{cursor:pointer;opacity:0.7;transition:opacity 0.2s}.filter-tag-remove:hover{opacity:1}.alphabet-nav{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md) var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);position:sticky;top:calc(var(--sticky-top) + 80px);z-index:99}.alphabet-label{color:var(--text-muted);font-size:0.85rem;white-space:nowrap}.alphabet-letters{display:flex;flex-wrap:wrap;gap:4px}.alphabet-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-sm);color:var(--text-secondary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.alphabet-btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.active{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.disabled{opacity:0.3;cursor:not-allowed}.alphabet-btn.disabled:hover{background:rgba(76,201,240,0.1);border-color:rgba(76,201,240,0.3);color:var(--text-secondary)}.cards-page{--card-min:380px;--card-gap:var(--spacing-lg);max-width:calc(4 * 480px + 3 * var(--card-gap));margin-inline:auto}.games-grid-new{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card-min,380px),1fr));grid-auto-rows:1px;column-gap:var(--spacing-lg)}.game-card-wrapper{grid-row-end:span 296;align-self:start;transition:transform var(--transition-normal);contain:layout style paint;content-visibility:auto;contain-intrinsic-size:auto 380px 296px}.game-card-new{position:relative;display:flex;flex-direction:row;border-radius:var(--radius-lg);overflow:hidden;text-decoration:none;transition:all var(--transition-normal);border:1px solid transparent;min-height:240px}.game-card-wrapper:hover{transform:translateY(-4px)}.game-card-new:hover{box-shadow:0 8px 30px rgba(76,201,240,0.2)}.game-card-wrapper.hidden{display:none}.game-card-new.hidden{display:none}.card-boxart-section{flex-shrink:0;width:135px;align-self:stretch;background:var(--bg-medium);display:flex;align-items:center;justify-content:center;position:relative}.card-play-btn{position:absolute;top:var(--spacing-sm);right:var(--spacing-sm);z-index:5;width:28px;height:28px;border-radius:var(--radius-full);border:1px solid var(--primary-cyan);background:var(--bg-darkest);color:var(--primary-cyan);font-size:0.8rem;line-height:1;cursor:pointer;box-shadow:var(--shadow-md);transition:background var(--transition-fast),color var(--transition-fast),box-shadow var(--transition-fast)}.card-play-btn{display:flex;align-items:center;justify-content:center;padding:0}.card-play-btn svg{width:60%;height:60%;fill:currentColor}.card-play-btn:hover,.card-play-btn:focus-visible{background:var(--primary-cyan);color:var(--bg-darkest);box-shadow:var(--shadow-glow-cyan)}.card-play-btn:disabled{cursor:progress}.games-grid-new.bulk-mode .card-play-btn{display:none}.game-card-new.has-play .card-info-section >:first-child{padding-right:calc(28px + var(--spacing-sm))}.card-info-section{flex:1;padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-sm);background:rgba(0,0,0,0.4)}.title-row{display:flex;align-items:flex-start;gap:var(--spacing-sm)}.title-row .game-card-title{flex:1}.game-card-ra-badge{position:absolute;top:8px;left:8px;width:28px;height:28px;background:linear-gradient(135deg,var(--trophy-display-gold) 0%,#ffaa00 100%);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.9rem;z-index:10;box-shadow:0 2px 8px rgba(255,215,0,0.4);border:2px solid rgba(255,255,255,0.3)}.game-card-rating-badge{position:absolute;top:8px;right:8px;z-index:10}.rating-text-badge{font-size:0.65rem;padding:2px 6px;border-radius:var(--radius-sm);font-weight:700;text-transform:uppercase}.rating-text-badge.esrb{background:rgba(0,0,0,0.7);color:var(--text-on-color);border:1px solid var(--text-on-color)}.rating-text-badge.pegi{background:rgba(141,198,63,0.9);color:var(--text-on-color);border:1px solid var(--text-on-color)}.system-badge-row{display:flex;align-items:center;gap:var(--spacing-sm)}.system-badge{padding:3px 8px;background:rgba(76,201,240,0.2);border:1px solid var(--primary-cyan);border-radius:var(--radius-sm);font-size:0.65rem;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:0.5px}.system-badge.nes{background:rgba(198,40,40,0.25);border-color:#e53935;color:#e53935}.system-badge.snes{background:rgba(229,57,53,0.2);border-color:#ef5350;color:#ef5350}.system-badge.n64{background:rgba(244,67,54,0.2);border-color:#f44336;color:#f44336}.system-badge.gc,.system-badge.gamecube{background:rgba(103,58,183,0.2);border-color:#9575cd;color:#9575cd}.system-badge.wii{background:rgba(255,255,255,0.15);border-color:#e0e0e0;color:#e0e0e0}.system-badge.wiiu{background:rgba(33,150,243,0.2);border-color:#42a5f5;color:#42a5f5}.system-badge.switch{background:rgba(229,57,53,0.2);border-color:#ef5350;color:#ef5350}.system-badge.gb{background:rgba(139,195,74,0.25);border-color:#9ccc65;color:#9ccc65}.system-badge.gbc{background:rgba(156,39,176,0.2);border-color:#ba68c8;color:#ba68c8}.system-badge.gba{background:rgba(103,58,183,0.2);border-color:#7e57c2;color:#7e57c2}.system-badge.nds{background:rgba(158,158,158,0.2);border-color:#bdbdbd;color:#bdbdbd}.system-badge.3ds{background:rgba(229,57,53,0.2);border-color:#e57373;color:#e57373}.system-badge.virtualboy,.system-badge.vb{background:rgba(183,28,28,0.3);border-color:#c62828;color:#ef5350}.system-badge.fds{background:rgba(255,193,7,0.2);border-color:#ffc107;color:#ffc107}.system-badge.psx,.system-badge.ps1{background:rgba(144,202,249,0.25);border-color:#90caf9;color:#90caf9}.system-badge.ps2{background:rgba(33,150,243,0.2);border-color:#42a5f5;color:#42a5f5}.system-badge.ps3{background:rgba(21,101,192,0.25);border-color:#1976d2;color:#64b5f6}.system-badge.ps4{background:rgba(13,71,161,0.3);border-color:#1565c0;color:#42a5f5}.system-badge.psp{background:rgba(100,181,246,0.2);border-color:#64b5f6;color:#64b5f6}.system-badge.psvita,.system-badge.vita{background:rgba(30,136,229,0.2);border-color:#1e88e5;color:#42a5f5}.system-badge.sg-1000,.system-badge.sg1000{background:rgba(0,77,64,0.3);border-color:#00897b;color:#4db6ac}.system-badge.mastersystem,.system-badge.sms{background:rgba(0,121,107,0.25);border-color:#00897b;color:#4db6ac}.system-badge.genesis,.system-badge.megadrive{background:rgba(0,150,136,0.2);border-color:#26a69a;color:#26a69a}.system-badge.segacd,.system-badge.megacd{background:rgba(0,137,123,0.25);border-color:#00897b;color:#4db6ac}.system-badge.sega32x,.system-badge.32x{background:rgba(0,105,92,0.3);border-color:#00796b;color:#4db6ac}.system-badge.saturn{background:rgba(128,203,196,0.2);border-color:#80cbc4;color:#80cbc4}.system-badge.dreamcast,.system-badge.dc{background:rgba(255,152,0,0.2);border-color:#ffb74d;color:#ffb74d}.system-badge.gamegear,.system-badge.gg{background:rgba(0,188,212,0.2);border-color:#4dd0e1;color:#4dd0e1}.system-badge.atari2600{background:rgba(230,81,0,0.25);border-color:#ff6d00;color:#ffab40}.system-badge.atari5200{background:rgba(255,111,0,0.2);border-color:#ff9100;color:#ffab40}.system-badge.atari7800{background:rgba(255,143,0,0.2);border-color:#ffa000;color:#ffca28}.system-badge.atarilynx,.system-badge.lynx{background:rgba(255,193,7,0.2);border-color:#ffc107;color:#ffc107}.system-badge.atarijaguar,.system-badge.jaguar{background:rgba(255,87,34,0.2);border-color:#ff7043;color:#ff7043}.system-badge.atarist,.system-badge.st{background:rgba(121,85,72,0.25);border-color:#a1887f;color:#bcaaa4}.system-badge.arcade{background:rgba(233,30,99,0.2);border-color:#ec407a;color:#ec407a}.system-badge.mame{background:rgba(233,30,99,0.2);border-color:#ec407a;color:#ec407a}.system-badge.fbneo{background:rgba(233,30,99,0.2);border-color:#f06292;color:#f06292}.system-badge.cps1,.system-badge.cps2,.system-badge.cps3{background:rgba(216,27,96,0.2);border-color:#e91e63;color:#f48fb1}.system-badge.naomi{background:rgba(194,24,91,0.25);border-color:#d81b60;color:#f48fb1}.system-badge.neogeo{background:rgba(255,193,7,0.25);border-color:#ffc107;color:#ffc107}.system-badge.neogeocd{background:rgba(255,179,0,0.2);border-color:#ffb300;color:#ffc107}.system-badge.ngp{background:rgba(255,213,79,0.2);border-color:#ffd54f;color:#ffd54f}.system-badge.ngpc{background:rgba(255,224,130,0.2);border-color:#ffe082;color:#ffe082}.system-badge.pcengine,.system-badge.tg16{background:rgba(156,39,176,0.2);border-color:#ab47bc;color:#ce93d8}.system-badge.pcenginecd,.system-badge.tg-cd{background:rgba(142,36,170,0.2);border-color:#9c27b0;color:#ce93d8}.system-badge.supergrafx{background:rgba(171,71,188,0.2);border-color:#ba68c8;color:#ce93d8}.system-badge.pcfx{background:rgba(123,31,162,0.25);border-color:#8e24aa;color:#ce93d8}.system-badge.c64{background:rgba(121,85,72,0.25);border-color:#8d6e63;color:#bcaaa4}.system-badge.amiga{background:rgba(141,110,99,0.2);border-color:#a1887f;color:#d7ccc8}.system-badge.vic20{background:rgba(93,64,55,0.3);border-color:#6d4c41;color:#a1887f}.system-badge.xbox{background:rgba(76,175,80,0.2);border-color:#66bb6a;color:#81c784}.system-badge.xbox360{background:rgba(56,142,60,0.25);border-color:#43a047;color:#81c784}.system-badge.3do{background:rgba(255,215,0,0.2);border-color:#ffd700;color:#ffd700}.system-badge.philipscdi,.system-badge.cdi{background:rgba(255,193,7,0.15);border-color:#ffca28;color:#ffca28}.system-badge.dos{background:rgba(96,125,139,0.2);border-color:#78909c;color:#90a4ae}.system-badge.pc{background:rgba(69,90,100,0.25);border-color:#607d8b;color:#90a4ae}.system-badge.pc88,.system-badge.pc98{background:rgba(84,110,122,0.2);border-color:#78909c;color:#b0bec5}.system-badge.msx{background:rgba(0,188,212,0.2);border-color:#00bcd4;color:#4dd0e1}.system-badge.msx2{background:rgba(0,172,193,0.2);border-color:#00acc1;color:#4dd0e1}.system-badge.wonderswan,.system-badge.ws{background:rgba(63,81,181,0.2);border-color:#5c6bc0;color:#7986cb}.system-badge.wonderswancolor,.system-badge.wsc{background:rgba(48,63,159,0.2);border-color:#3f51b5;color:#7986cb}.system-badge.pokemini{background:rgba(255,235,59,0.25);border-color:#ffee58;color:#fff176}.system-badge.nggage,.system-badge.ngage{background:rgba(255,112,67,0.2);border-color:#ff7043;color:#ff8a65}.system-badge.colecovision{background:rgba(183,28,28,0.2);border-color:#c62828;color:#ef5350}.system-badge.intellivision{background:rgba(255,152,0,0.2);border-color:#ff9800;color:#ffb74d}.system-badge.vectrex{background:rgba(255,255,255,0.2);border-color:#eeeeee;color:#eeeeee}.system-badge.scummvm{background:rgba(139,195,74,0.2);border-color:#8bc34a;color:#aed581}.system-badge.daphne{background:rgba(103,58,183,0.2);border-color:#7e57c2;color:#b39ddb}.title-badges{display:flex;gap:4px;flex-shrink:0}.title-badges .ra-badge{width:14px;height:14px;flex-shrink:0;object-fit:contain;filter:drop-shadow(0 0 4px rgba(255,193,7,0.5))}.import-badge.clz-badge{filter:drop-shadow(0 0 4px rgba(168,85,247,0.5))}.import-badge{width:14px;height:14px;flex-shrink:0;object-fit:contain}.import-badge.steam-badge{filter:drop-shadow(0 0 4px rgba(102,192,244,0.5))}.import-badge.xbox-badge{filter:drop-shadow(0 0 4px rgba(16,124,16,0.5))}.import-badge.psn-badge{filter:drop-shadow(0 0 4px rgba(0,55,145,0.5))}.bonus-badge{font-size:0.9rem;filter:drop-shadow(0 0 4px rgba(245,158,11,0.5));flex-shrink:0}.has-bonus-badge{font-size:0.75rem;background:rgba(168,85,247,0.2);border:1px solid var(--neon-purple);color:var(--neon-purple);padding:1px 4px;border-radius:4px;filter:drop-shadow(0 0 4px rgba(168,85,247,0.4));flex-shrink:0}.versions-badge{font-size:0.75rem;background:color-mix(in srgb,var(--neon-cyan) 20%,transparent);border:1px solid var(--neon-cyan);color:var(--neon-cyan);padding:1px 4px;border-radius:4px;flex-shrink:0}.game-card-wrapper.bonus-disc .game-card-new{border-left:3px solid var(--neon-orange);opacity:0.85}.game-card-wrapper.bonus-disc .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(245,158,11,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.clz-import .game-card-new{border-left:3px solid var(--neon-purple)}.game-card-wrapper.clz-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(168,85,247,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.steam-import .game-card-new{border-left:3px solid #66c0f4}.game-card-wrapper.steam-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(102,192,244,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.xbox-import .game-card-new{border-left:3px solid #107c10}.game-card-wrapper.xbox-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(16,124,16,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.psn-import .game-card-new{border-left:3px solid #0070d1}.game-card-wrapper.psn-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(0,112,209,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-title{font-size:0.95rem;font-weight:600;color:var(--text-primary);margin:0;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 12px;flex:1}.meta-item{display:flex;flex-direction:column;gap:1px}.meta-label{font-size:0.6rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.meta-value{font-size:0.75rem;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta-value.meta-clickable{cursor:pointer;padding:2px 6px;margin:-2px -6px;border-radius:var(--radius-sm);transition:all var(--transition-fast);display:inline-block}.meta-value.meta-clickable:hover{background:rgba(76,201,240,0.15);color:var(--neon-cyan)}.rating-badges{display:flex;gap:var(--spacing-sm);flex-wrap:wrap;margin-top:auto}.rating-badge{padding:3px 8px;border-radius:var(--radius-sm);font-size:0.65rem;font-weight:700}.rating-badge.esrb{background:linear-gradient(135deg,var(--bg-medium),#16213e);color:#fff;border:1px solid #4a4a6a}.rating-badge.pegi{background:linear-gradient(135deg,#0f3460,#16213e);color:var(--primary-cyan);border:1px solid var(--primary-cyan)}.game-badge.achievement-progress{gap:4px;font-size:0.65rem;font-weight:700;font-family:var(--font-mono)}.ach-icon{width:14px;height:14px;object-fit:contain;flex-shrink:0;filter:drop-shadow(0 0 3px rgba(255,255,255,0.3))}.game-badge.achievement-progress.ra{background:linear-gradient(135deg,rgba(74,144,217,0.12) 0%,rgba(212,160,23,0.12) 100%);border-image:linear-gradient(135deg,#4A90D9,#D4A017) 1;color:#D4A017}.game-badge.achievement-progress.ra.ach-complete{background:linear-gradient(135deg,rgba(74,144,217,0.2) 0%,rgba(212,160,23,0.2) 100%);color:#e8b820}.game-badge.achievement-progress.steam{background:linear-gradient(135deg,rgba(27,40,56,0.35) 0%,rgba(102,192,244,0.12) 100%);border-image:linear-gradient(135deg,#2A6DAB,#66C0F4) 1;color:#66C0F4}.game-badge.achievement-progress.steam.ach-complete{background:linear-gradient(135deg,rgba(27,40,56,0.45) 0%,rgba(102,192,244,0.2) 100%);color:#88d4f8}.game-badge.achievement-progress.xbox{background:linear-gradient(135deg,rgba(16,124,16,0.12) 0%,rgba(82,176,67,0.12) 100%);border-image:linear-gradient(135deg,#107C10,#52B043) 1;color:#52B043}.game-badge.achievement-progress.xbox.ach-complete{background:linear-gradient(135deg,rgba(16,124,16,0.2) 0%,rgba(82,176,67,0.2) 100%);color:#6dcc5e}.game-badge.achievement-progress.psn{background:linear-gradient(135deg,rgba(0,48,135,0.15) 0%,rgba(108,168,217,0.12) 100%);border-image:linear-gradient(135deg,#0070D1,#6CA8D9) 1;color:#6CA8D9}.game-badge.achievement-progress.psn.ach-complete{background:linear-gradient(135deg,rgba(0,48,135,0.25) 0%,rgba(108,168,217,0.2) 100%);color:#8dc0e8}.game-badge.achievement-progress.rpcs3{background:linear-gradient(135deg,rgba(74,122,140,0.12) 0%,rgba(168,208,224,0.1) 100%);border-image:linear-gradient(135deg,#4A7A8C,#A8D0E0) 1;color:#A8D0E0}.game-badge.achievement-progress.rpcs3.ach-complete{background:linear-gradient(135deg,rgba(74,122,140,0.2) 0%,rgba(168,208,224,0.18) 100%);color:#c0e0ec}.game-badge.achievement-progress.ach-low{opacity:0.7}.ach-bar{display:inline-block;width:30px;height:4px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;vertical-align:middle}.ach-fill{display:block;height:100%;border-radius:2px;transition:width 0.3s ease}.ra .ach-fill{background:linear-gradient(90deg,#4A90D9,#D4A017)}.ra.ach-complete .ach-fill{box-shadow:0 0 4px #D4A017}.steam .ach-fill{background:linear-gradient(90deg,#2A6DAB,#66C0F4)}.steam.ach-complete .ach-fill{box-shadow:0 0 4px #66C0F4}.xbox .ach-fill{background:linear-gradient(90deg,#107C10,#52B043)}.xbox.ach-complete .ach-fill{box-shadow:0 0 4px #52B043}.psn .ach-fill{background:linear-gradient(90deg,#0070D1,#6CA8D9)}.psn.ach-complete .ach-fill{box-shadow:0 0 4px #6CA8D9}.rpcs3 .ach-fill{background:linear-gradient(90deg,#4A7A8C,#A8D0E0)}.rpcs3.ach-complete .ach-fill{box-shadow:0 0 4px #A8D0E0}.game-card-image{width:100%;height:100%;overflow:hidden;background:var(--bg-medium);display:flex;align-items:center;justify-content:center;position:absolute;top:0;left:0;right:0;bottom:0}.game-boxart-img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;transition:transform var(--transition-normal)}.game-boxart-img.boxart-3d{object-fit:contain;padding:4px}.game-card-new:hover .game-boxart-img{transform:scale(1.05)}.game-boxart-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--bg-medium),var(--bg-dark))}.placeholder-icon{font-size:3rem;opacity:0.3}.games-count-bar{display:flex;justify-content:space-between;align-items:center;gap:var(--spacing-md);min-height:3rem;margin-bottom:var(--spacing-lg);padding:var(--spacing-sm) 0;border-bottom:1px solid var(--card-border)}.showing-text{flex-shrink:0;font-size:0.85rem;color:var(--text-muted);letter-spacing:1px}.showing-text span{color:var(--primary-cyan);font-weight:600}.sort-control{display:flex;align-items:center;gap:var(--spacing-sm)}.sort-label{font-size:0.85rem;color:var(--text-muted);white-space:nowrap}.sort-select{padding:var(--spacing-xs) var(--spacing-sm);background-color:var(--bg-light);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.85rem;cursor:pointer}.sort-select:focus-visible{outline:2px solid var(--primary-cyan);outline-offset:1px}#gamesEmpty .clear-btn{display:inline-flex;margin-top:var(--spacing-md)}.alphabet-nav.is-disabled{opacity:0.35}.bulk-action-bar{position:sticky;bottom:var(--spacing-md);z-index:101;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:var(--spacing-md);margin-top:var(--spacing-lg);border-radius:var(--radius-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.bulk-action-bar[hidden]{display:none}.filters-toggle-btn{display:none}.filter-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:2000;align-items:center;justify-content:center}.filter-modal.active{display:flex}.filter-modal-content{width:90%;max-width:500px;max-height:70vh;border-radius:var(--radius-lg);overflow:hidden;display:flex;flex-direction:column}.filter-modal-header{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);gap:var(--spacing-md)}.filter-modal-header h2{color:var(--primary-cyan);font-family:var(--font-heading);margin:0}.filter-mode-toggle{display:flex;border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden}.filter-mode-btn{padding:4px 12px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;font-size:0.85rem;font-weight:600;transition:all var(--transition-fast)}.filter-mode-btn:hover{color:var(--text-primary)}.filter-mode-btn.active[data-mode="include"]{background:rgba(76,201,240,0.2);color:var(--primary-cyan)}.filter-mode-btn.active[data-mode="exclude"]{background:rgba(239,68,68,0.2);color:var(--neon-red)}.filter-modal-body{padding:var(--spacing-lg);overflow-y:auto;flex:1}.filter-options{display:flex;flex-direction:column;gap:var(--spacing-sm)}.filter-option{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);cursor:pointer;transition:all var(--transition-fast)}.filter-option:hover{border-color:var(--primary-cyan);background:rgba(76,201,240,0.1)}.filter-modal.exclude-mode .filter-option:hover{border-color:var(--neon-red);background:rgba(239,68,68,0.1)}.filter-option-name{color:var(--text-primary)}.filter-option-count{color:var(--text-muted);font-size:0.85rem}button.filter-option{width:100%;font:inherit;color:inherit;text-align:left}.filter-option[aria-checked]::before{content:'';flex:0 0 auto;width:1.1rem;height:1.1rem;margin-right:var(--spacing-sm);border:1px solid var(--card-border);border-radius:var(--radius-sm);line-height:1.1rem;text-align:center}.filter-option[aria-checked] .filter-option-name{flex:1}.filter-option[aria-checked="true"]{border-color:var(--primary-cyan);background:rgba(76,201,240,0.15)}.filter-option[aria-checked="true"]::before{content:'✓';color:var(--primary-cyan);border-color:var(--primary-cyan)}.filter-modal.exclude-mode .filter-option[aria-checked="true"]{border-color:var(--neon-red);background:rgba(239,68,68,0.15)}.filter-modal.exclude-mode .filter-option[aria-checked="true"]::before{content:'✕';color:var(--neon-red);border-color:var(--neon-red)}.filter-modal-footer{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-md);padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border)}.filter-modal-footer[hidden]{display:none}.filter-modal-hint{color:var(--text-muted);font-size:0.85rem}.filter-modal .hint-exclude,.filter-modal.exclude-mode .hint-include{display:none}.filter-modal.exclude-mode .hint-exclude{display:inline}@media (max-width:768px){.games-grid-new{grid-template-columns:1fr;grid-auto-rows:auto;row-gap:var(--spacing-lg)}.game-card-wrapper{grid-row-end:auto}.game-card-new{min-height:180px}.card-boxart-section{width:100px}.meta-grid{grid-template-columns:1fr}.filter-bar-top{flex-wrap:wrap}.filter-buttons{width:100%;justify-content:flex-start}.filters-toggle-btn{display:flex}.filter-bar-new .filter-buttons{display:none;position:absolute;top:100%;left:0;right:0;margin-top:var(--spacing-xs);padding:var(--spacing-md);border:1px solid var(--card-border);border-radius:var(--radius-lg);background:var(--bg-medium);box-shadow:0 8px 24px rgba(0,0,0,0.5)}.filter-bar-new.filters-open .filter-buttons{display:flex}.games-count-bar{flex-wrap:wrap}.games-count-bar .active-filter-tags{order:3;flex-basis:100%;min-height:1.75rem}.filter-btn{flex:1;min-width:80px;justify-content:center}.bulk-controls{width:100%;justify-content:flex-start}.glitch-text::before,.glitch-text::after{display:none}}.game-page-header{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg);position:relative;z-index:1}.game-page-title{font-family:var(--font-display);font-size:2rem;color:var(--primary-cyan);text-shadow:0 0 30px rgba(76,201,240,0.3);margin:0}.header-buttons{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.play-key-hint{align-self:center;font-size:0.8rem;color:var(--text-secondary)}.last-scan-indicator{display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-md);font-size:0.8rem;color:var(--text-secondary)}.last-scan-indicator .scan-icon{font-size:1rem}.last-scan-indicator .scan-time{color:var(--primary-cyan);font-weight:500}.report-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.report-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1.25rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.report-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.report-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.report-tab .tab-count{font-size:0.75rem;padding:0.15rem 0.5rem;background:rgba(0,0,0,0.3);border-radius:var(--radius-full);color:var(--text-secondary)}.report-tab.active .tab-count{background:var(--primary-cyan);color:var(--bg-darkest)}.report-tab .tab-icon{font-size:1.1rem}.report-panel{display:none}.report-panel.active{display:block;animation:fadeIn 0.3s ease}@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.results-table-container{background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden}.settings-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.settings-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.settings-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.settings-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.settings-tab .tab-icon{font-size:1.1rem}.settings-tab-panel{display:none}.settings-tab-panel.active{display:block}.naming-tag-option{padding:0.25rem 0.6rem;border-radius:4px;font-size:0.8rem;font-weight:600;background:rgba(100,116,139,0.15);border:1px solid rgba(100,116,139,0.3);color:var(--text-secondary);cursor:pointer;transition:all 0.15s ease}.naming-tag-option:hover{border-color:var(--neon-green);color:var(--neon-green)}.parent-option{padding:var(--spacing-sm);border-bottom:1px solid var(--card-border);cursor:pointer}.parent-option:hover{background:rgba(76,201,240,0.1)}.tab-subnav{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:rgba(0,0,0,0.75);border-radius:var(--radius-md);position:sticky;top:calc(var(--sticky-top) + 54px);z-index:99;backdrop-filter:blur(10px)}.subnav-link{padding:var(--spacing-xs) var(--spacing-md);color:var(--text-secondary);text-decoration:none;font-size:0.85rem;border-radius:var(--radius-sm);transition:all var(--transition-fast)}.subnav-link:hover{background:rgba(76,201,240,0.1);color:var(--text-primary)}.subnav-link.active{background:rgba(76,201,240,0.15);color:var(--primary-cyan)}.achievement-overview{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg)}.achievement-stat{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md)}.achievement-stat-icon{font-size:2rem}.achievement-stat-info{display:flex;flex-direction:column}.achievement-stat-label{font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.achievement-stat-value{font-size:1.3rem;font-weight:600;color:var(--neon-cyan)}.dashboard-page{--card-min:280px;--card-gap:var(--spacing-lg)}.systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min,280px),100%),1fr));gap:var(--card-gap,var(--spacing-lg))}.system-card{display:flex;flex-direction:column;padding:var(--spacing-lg);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-lg);text-decoration:none;color:inherit;transition:all var(--transition-fast)}.system-card:hover{border-color:var(--neon-cyan);transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,0.3),0 0 15px rgba(76,201,240,0.15)}.system-card-header{margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.system-name{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);margin:0}.system-card-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.system-stat{display:flex;flex-direction:column;align-items:center;text-align:center}.system-stat-value{font-size:1.2rem;font-weight:600;color:var(--neon-cyan)}.system-stat-label{font-size:0.7rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.3px}.system-card-action{margin-top:auto;padding-top:var(--spacing-md);text-align:center;color:var(--neon-cyan);font-size:0.9rem;font-weight:500;opacity:0.7;transition:opacity 0.2s}.system-card:hover .system-card-action{opacity:1}.filter-group{display:flex;align-items:center;gap:0.5rem}.filter-group label{color:var(--text-secondary);font-size:0.85rem;white-space:nowrap}.filter-group select,.filter-group input{padding:0.5rem 0.75rem;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:0.85rem}.filter-group select:focus,.filter-group input:focus{outline:none;border-color:var(--primary-cyan)}.alert{padding:var(--spacing-md) var(--spacing-lg);border-radius:var(--radius-md);margin-bottom:var(--spacing-md)}.alert-warning{background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);color:var(--neon-orange)}.alert-danger{background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);color:var(--neon-red)}.alert-success{background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);color:var(--neon-green)}.alert-info{background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);color:var(--primary-cyan)}.alert a{color:inherit;text-decoration:underline}.field-tip{display:block;margin-top:var(--spacing-xs);font-size:0.8rem;color:var(--text-muted)}.changelog-entry{margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-xl);border-bottom:1px solid var(--card-border)}.changelog-entry:last-child{border-bottom:none}.changelog-version{font-family:var(--font-heading);font-size:1.5rem;color:var(--primary-cyan);margin-bottom:var(--spacing-xs)}.changelog-date{font-size:0.85rem;color:var(--text-muted);margin-bottom:var(--spacing-md)}.changelog-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-xs);margin-bottom:var(--spacing-md)}.changelog-tag{padding:4px 12px;border-radius:var(--radius-full);font-size:0.75rem;font-weight:600;text-transform:uppercase}.tag-feature{background:rgba(76,201,240,0.15);border:1px solid var(--primary-cyan);color:var(--primary-cyan)}.tag-minor{background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);color:var(--neon-purple)}.tag-patch{background:rgba(245,158,11,0.15);border:1px solid var(--neon-orange);color:var(--neon-orange)}.tag-fix{background:rgba(239,68,68,0.15);border:1px solid var(--neon-red);color:var(--neon-red)}.tag-major{background:rgba(247,37,133,0.15);border:1px solid var(--secondary-magenta);color:var(--secondary-magenta)}.tag-ui{background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);color:var(--neon-purple)}.tag-improvement{background:rgba(76,201,240,0.15);border:1px solid var(--primary-cyan);color:var(--primary-cyan)}.tag-enhancement{background:rgba(34,197,94,0.15);border:1px solid var(--neon-green);color:var(--neon-green)}.tag-initial{background:rgba(247,37,133,0.15);border:1px solid var(--secondary-magenta);color:var(--secondary-magenta)}.changelog-list{list-style:none;padding:0;margin:0}.changelog-list li{padding:var(--spacing-xs) 0;padding-left:var(--spacing-lg);position:relative;color:var(--text-secondary)}.changelog-list li::before{content:'•';position:absolute;left:0;color:var(--primary-cyan)}.login-container{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:var(--spacing-lg)}.login-box{width:100%;max-width:400px;padding:var(--spacing-xl);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);box-shadow:0 0 40px rgba(76,201,240,0.1)}.login-header{text-align:center;margin-bottom:var(--spacing-xl)}.login-logo{font-family:var(--font-heading);font-size:2rem;color:var(--primary-cyan);margin-bottom:var(--spacing-xs)}.login-subtitle{color:var(--text-secondary);font-size:0.9rem}.help-page{--help-text-max:calc(75ch + 2 * var(--spacing-lg));--help-nav-width:220px;--page-max:var(--help-text-max)}.help-page > .help-nav{flex-wrap:nowrap;overflow-x:auto}@media (max-width:600px){.help-page code{overflow-wrap:anywhere}}@media (min-width:1300px){.help-page{--page-max:calc(var(--help-nav-width) + var(--spacing-xl) + var(--help-text-max));display:grid;grid-template-columns:var(--help-nav-width) minmax(0,1fr);column-gap:var(--spacing-xl);align-items:start}.help-page > *{grid-column:2}.help-page > .page-header{grid-column:1 / -1}.help-page > .help-nav{grid-column:1;grid-row:2 / span 100;flex-direction:column;flex-wrap:nowrap;margin:0;max-height:100vh;overflow-x:hidden;overflow-y:auto}.help-page .help-nav-item{white-space:normal}}.help-section{margin-bottom:var(--spacing-xl)}.help-section h2{font-family:var(--font-heading);font-size:1.25rem;color:var(--primary-cyan);margin-bottom:var(--spacing-md)}.help-item{padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.help-item h3{color:var(--text-primary);font-size:1rem;margin-bottom:var(--spacing-xs)}.help-item p{color:var(--text-secondary);font-size:0.9rem;margin:0}.tool-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg)}.tool-description{color:var(--text-secondary);font-size:0.95rem;margin-bottom:var(--spacing-lg);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);border-left:3px solid var(--primary-cyan)}.tool-results{margin-top:var(--spacing-lg)}.tool-result-item{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.tool-result-item:hover{border-color:var(--primary-cyan)}.tool-result-icon{font-size:1.5rem}.tool-result-info{flex:1}.tool-result-name{color:var(--text-primary);font-weight:500}.tool-result-detail{color:var(--text-muted);font-size:0.85rem}@media (max-width:768px){.settings-tabs,.report-tabs{gap:var(--spacing-xs);padding:var(--spacing-xs)}.settings-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.settings-tab .tab-text{display:none}.settings-tab .tab-icon{font-size:1.25rem}.report-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.report-tab .tab-label{display:none}.report-tab .tab-icon{font-size:1.25rem}}.game-fanart-bg{position:fixed;top:0;left:0;width:100%;height:100%;background-size:cover;background-position:center;opacity:0;z-index:-1;transition:opacity 0.5s ease}.game-fanart-bg.active{opacity:0.15}.game-fanart-bg::after{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(to bottom,transparent 0%,var(--bg-primary) 100%)}.game-compact-layout{max-width:100%}.game-hero{display:flex;gap:var(--spacing-xl);margin-bottom:var(--spacing-xl);background:color-mix(in srgb,var(--bg-medium) 85%,transparent);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.hero-boxart{position:relative;flex-shrink:0;width:200px}.hero-boxart-frame{position:relative}.upscale-game-note{margin:var(--spacing-sm) 0 0;font-size:0.75rem;color:var(--text-secondary)}.game-boxart-image{width:100%;height:auto;border-radius:var(--radius-md);box-shadow:0 8px 32px rgba(0,0,0,0.4)}.hero-age-ratings{display:flex;flex-wrap:wrap;gap:5px;margin-top:auto;padding-top:var(--spacing-sm)}.hero-info{flex:1;min-width:0;display:flex;flex-direction:column}.quick-tags{display:flex;flex-wrap:wrap;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.tag{padding:4px 10px;border-radius:4px;font-size:0.8rem;font-weight:500;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1)}.quick-tags .tag,.quick-tags .game-badge{height:32px;box-sizing:border-box;line-height:1}.quick-tags .game-badge.age-rating-badge .age-rating-img{height:32px}.tag-system{color:var(--primary-cyan);border-color:var(--primary-cyan)}.hero-description{flex:1;font-size:0.9rem;line-height:1.6;color:var(--text-secondary);margin-bottom:var(--spacing-md);overflow-y:auto;max-height:120px}.key-details{display:flex;flex-wrap:wrap;gap:var(--spacing-lg);padding-top:var(--spacing-md);border-top:1px solid var(--card-border)}.kd-item{display:flex;flex-direction:column;gap:2px}.kd-label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase}.kd-value{font-size:0.9rem;color:var(--text-primary)}.trophy-summary{text-align:center}.trophy-summary-header{display:flex;justify-content:center;align-items:center;gap:var(--spacing-lg);margin-bottom:var(--spacing-md)}.trophy-summary-title{font-size:1.1rem;font-weight:600}.trophy-summary-total{font-size:1.3rem;font-weight:bold;color:var(--primary-cyan)}.trophy-summary-progress{display:flex;align-items:center;gap:var(--spacing-md);max-width:500px;margin:0 auto var(--spacing-lg)}.trophy-progress-bar.large{flex:1;height:12px;background:rgba(255,255,255,0.1);border-radius:6px;overflow:hidden}.trophy-progress-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),#00ddff);border-radius:6px}.trophy-progress-percent{font-size:1.2rem;font-weight:bold;color:var(--primary-cyan);min-width:60px}.trophy-summary-counts{display:flex;justify-content:center;gap:var(--spacing-xl);flex-wrap:wrap}.trophy-count{display:flex;align-items:center;gap:var(--spacing-sm);font-size:1.1rem;font-weight:600}.trophy-count img{width:28px;height:28px}.trophy-count.platinum{color:var(--trophy-display-platinum)}.trophy-count.gold{color:var(--trophy-display-gold)}.trophy-count.silver{color:var(--trophy-display-silver)}.trophy-count.bronze{color:var(--trophy-display-bronze)}.bottom-row{display:flex;gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.bottom-section{flex:1;background:color-mix(in srgb,var(--bg-medium) 85%,transparent);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-md)}.section-title-sm{font-size:0.9rem;color:var(--text-secondary);margin-bottom:var(--spacing-md)}.hltb-section{display:flex;flex-direction:column;gap:var(--spacing-sm)}.more-details{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-sm)}.md-item{display:flex;flex-direction:column}.md-item.md-inline{flex-direction:row;align-items:baseline;gap:var(--spacing-sm);grid-column:span 2}.md-label{font-size:0.7rem;color:var(--text-muted);text-transform:uppercase}.md-value{font-size:0.85rem;color:var(--text-primary)}.trophy-columns{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:650px;gap:var(--spacing-xl)}.trophy-columns > .card{height:100%;display:flex;flex-direction:column;overflow:hidden}.trophy-columns > .card + .card{margin-top:0}.trophy-columns > .card .card-header{flex-shrink:0}.trophy-column .card-body{flex:1;overflow-y:auto;min-height:0}.trophy-list{display:flex;flex-direction:column;gap:var(--spacing-md)}.trophy-item{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.trophy-item.locked{opacity:0.5}.trophy-item.locked .trophy-item-icon img:first-child{filter:grayscale(1) brightness(0.5)}.trophy-item-icon{position:relative;flex-shrink:0}.trophy-item-icon > img:first-child{width:60px;height:60px;border-radius:var(--radius-sm);background:rgba(0,0,0,0.3);object-fit:cover}.trophy-item-icon > img.trophy-type-badge{position:absolute;bottom:-4px;right:-4px;width:24px;height:24px}.trophy-item-icon .badge-placeholder{width:60px;height:60px;border-radius:var(--radius-sm);background:rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:1.5rem}.trophy-item-info{flex:1;min-width:0}.trophy-item-name{font-weight:600;font-size:0.95rem;margin-bottom:4px}.trophy-item-desc{font-size:0.85rem;color:var(--text-secondary);margin-bottom:8px}.trophy-item-meta{display:flex;gap:var(--spacing-md);align-items:center;flex-wrap:wrap;font-size:0.8rem}.trophy-type-label{padding:2px 8px;border-radius:4px;font-weight:600;text-transform:uppercase;font-size:0.7rem}.trophy-type-label.p{background:var(--trophy-display-platinum);color:var(--trophy-display-text)}.trophy-type-label.g{background:var(--trophy-display-gold);color:var(--trophy-display-text)}.trophy-type-label.s{background:var(--trophy-display-silver);color:var(--trophy-display-text)}.trophy-type-label.b{background:var(--trophy-display-bronze);color:var(--trophy-display-text)}.trophy-timestamp{color:var(--primary-cyan)}.trophy-empty{text-align:center;padding:var(--spacing-xl);color:var(--text-muted);font-style:italic}.hltb-search-input{width:100%;padding:10px 14px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;margin-bottom:var(--spacing-sm)}.hltb-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.btn-hltb{width:100%;display:flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:10px 16px;background:rgba(76,201,240,0.1);border:1px solid var(--primary-cyan);border-radius:var(--radius-md);color:var(--primary-cyan);font-family:var(--font-primary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.btn-hltb:hover{background:rgba(76,201,240,0.2)}.btn-hltb:disabled{opacity:0.5;cursor:not-allowed}@media (max-width:900px){.trophy-columns{grid-template-columns:1fr;grid-template-rows:auto}.trophy-columns > .card{height:auto;max-height:500px}.game-hero{flex-direction:column;align-items:center}.hero-boxart{width:150px}.bottom-row{flex-direction:column}}.alphabet-nav{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);position:sticky;top:var(--sticky-top);z-index:99}.alphabet-label{color:var(--text-muted);font-size:0.8rem;white-space:nowrap}.alphabet-letters{display:flex;flex-wrap:wrap;gap:3px}.alphabet-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:rgba(76,201,240,0.08);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-sm);color:var(--text-secondary);font-size:0.75rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast);padding:0}.alphabet-btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.active{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.disabled{opacity:0.25;cursor:not-allowed}.alphabet-btn.disabled:hover{background:rgba(76,201,240,0.08);border-color:rgba(76,201,240,0.2);color:var(--text-secondary)}@media (max-width:768px){.alphabet-nav{padding:var(--spacing-xs) var(--spacing-md)}.alphabet-btn{width:24px;height:24px;font-size:0.65rem}.alphabet-label{display:none}}.warning-banner{background:linear-gradient(135deg,rgba(255,193,7,0.1),rgba(247,37,133,0.1));border:1px solid rgba(255,193,7,0.3);border-radius:var(--radius-md);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;align-items:flex-start;gap:1rem}.warning-banner .warning-icon{font-size:1.5rem}.warning-banner .warning-text h4{color:var(--neon-orange);margin-bottom:0.25rem}.warning-banner .warning-text p{color:var(--text-secondary);font-size:0.9rem;margin:0}.info-banner{background:linear-gradient(135deg,rgba(76,201,240,0.1),rgba(114,9,183,0.1));border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-md);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;align-items:flex-start;gap:1rem}.info-banner .info-icon{font-size:1.5rem}.info-banner .info-text h4{color:var(--text-primary);margin-bottom:0.25rem}.info-banner .info-text p{color:var(--text-secondary);font-size:0.9rem;margin:0}.file-list{background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);max-height:400px;overflow-y:auto}.file-list-header{display:grid;gap:1rem;padding:0.75rem 1rem;background:var(--bg-medium);border-bottom:1px solid var(--border-color);font-size:0.75rem;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);position:sticky;top:0;z-index:10}.file-list-item{display:grid;gap:1rem;padding:0.75rem 1rem;border-bottom:1px solid var(--border-color);font-size:0.9rem;align-items:center}.file-list-item:hover{background:rgba(76,201,240,0.05)}.file-list-item:last-child{border-bottom:none}.file-list-item input[type="checkbox"]{width:18px;height:18px;accent-color:var(--neon-cyan);cursor:pointer}.file-list-item .filename{color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-list-item .filesize{color:var(--text-secondary)}.action-bar{display:flex;gap:1rem;margin-top:1rem;padding:1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md)}.action-btn{padding:0.4rem 0.75rem;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-sm);color:var(--neon-cyan);cursor:pointer;font-size:0.8rem;transition:all var(--transition-fast)}.action-btn:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 10px rgba(76,201,240,0.3)}.theme-selector{display:flex;gap:var(--spacing-md);flex-wrap:wrap}.theme-option{display:flex;flex-direction:column;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-md);background:var(--card-bg);border:2px solid var(--card-border);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--transition-fast),box-shadow var(--transition-fast);min-width:110px}.theme-option:hover{border-color:var(--card-border-hover)}.theme-option.active{border-color:var(--primary-cyan);box-shadow:0 0 12px var(--primary-cyan-glow)}.theme-preview{display:flex;gap:4px;padding:4px;background:rgba(0,0,0,0.3);border-radius:var(--radius-sm)}.theme-swatch{width:24px;height:24px;border-radius:3px}.theme-name{font-family:var(--font-primary);font-size:0.85rem;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px}.theme-option.active .theme-name{color:var(--primary-cyan)}.section-title{font-family:var(--font-display);font-size:1rem;color:var(--text-primary);margin-bottom:1rem;padding-bottom:0.5rem;border-bottom:1px solid var(--border-color);display:flex;align-items:center;gap:0.5rem}.emu-folder-list{list-style:none;margin:0 0 var(--spacing-md);padding:0}.emu-folder-list li{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-sm);padding:var(--spacing-sm) 0;border-bottom:1px solid var(--border-color)}.emu-folder-list code{overflow-wrap:anywhere}.emu-folder-add{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.emu-folder-add .form-input{flex:1 1 16rem}.museum-progress{padding:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.museum-progress-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-sm)}.museum-progress-title{font-family:var(--font-heading);font-size:0.95rem;color:var(--primary-cyan)}.museum-progress-stats{font-size:0.85rem;color:var(--text-secondary);margin-top:var(--spacing-sm)}.museum-progress-stats .text-success{color:var(--status-success)}.museum-progress-stats .text-warning{color:var(--status-warning)}.museum-progress-stats .text-error{color:var(--status-error)}.museum-timeline{display:flex;flex-direction:column;gap:var(--spacing-xl)}.museum-era{position:relative}.museum-era-header{display:flex;align-items:center;gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.museum-era-line{width:4px;height:48px;background:linear-gradient(180deg,var(--primary-cyan),transparent);border-radius:2px;flex-shrink:0}.museum-era-info{display:flex;flex-direction:column;gap:2px}.museum-era-title{font-family:var(--font-heading);font-size:1.3rem;color:var(--primary-cyan);margin:0;text-shadow:0 0 20px rgba(76,201,240,0.3)}.museum-era-date{font-family:var(--font-mono);font-size:0.8rem;color:var(--text-muted)}.museum-era-count{font-size:0.8rem;color:var(--text-secondary)}.museum-page{--card-min:260px;--card-gap:var(--spacing-md)}.museum-systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.museum-system-card{display:flex;flex-direction:column;position:relative;border-radius:var(--radius-lg);overflow:hidden;text-decoration:none;color:inherit;transition:transform var(--transition-normal),box-shadow var(--transition-normal)}.museum-system-card:hover{transform:translateY(-3px);box-shadow:0 0 20px rgba(76,201,240,0.15);border-color:var(--card-border-hover)}.museum-card-image{height:140px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.2);padding:var(--spacing-md)}.museum-card-image img{max-height:100%;max-width:100%;object-fit:contain}.museum-card-placeholder{font-size:3rem;opacity:0.3}.museum-card-body{padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-xs);flex:1}.museum-card-title{font-family:var(--font-heading);font-size:0.9rem;color:var(--text-primary);margin:0;line-height:1.3}.museum-card-summary{font-size:0.78rem;color:var(--text-secondary);margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.4}.museum-card-meta{display:flex;flex-wrap:wrap;gap:4px;margin-top:auto}.museum-meta-chip{display:inline-block;padding:2px 8px;font-size:0.7rem;font-family:var(--font-mono);color:var(--text-secondary);background:rgba(76,201,240,0.08);border:1px solid rgba(76,201,240,0.15);border-radius:var(--radius-full);white-space:nowrap}.museum-content-indicator{position:absolute;top:var(--spacing-sm);right:var(--spacing-sm);width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-full);background:rgba(34,197,94,0.2);color:var(--neon-green);font-size:0.7rem}.museum-system-header{margin-bottom:var(--spacing-xl)}.museum-hero-layout{display:flex;gap:var(--spacing-xl);align-items:flex-start}.museum-hero-image{flex-shrink:0;width:240px;height:200px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.2);border-radius:var(--radius-lg);border:1px solid var(--card-border);padding:var(--spacing-md)}.museum-hero-image img{max-height:100%;max-width:100%;object-fit:contain}.museum-hero-placeholder{font-size:4rem;opacity:0.3}.museum-hero-info{flex:1;display:flex;flex-direction:column;gap:var(--spacing-sm)}.museum-hero-title{font-family:var(--font-heading);font-size:2rem;color:var(--primary-cyan);margin:0;text-shadow:0 0 30px rgba(76,201,240,0.3)}.museum-hero-summary{font-size:1rem;color:var(--text-secondary);line-height:1.5;margin:0}.museum-hero-meta{display:flex;flex-wrap:wrap;gap:var(--spacing-xs)}.museum-hero-actions{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-sm);flex-wrap:wrap}.museum-nav{display:flex;gap:0;border-bottom:2px solid var(--border-color);margin-bottom:var(--spacing-lg);position:sticky;background:var(--bg-darkest);z-index:10;padding-top:var(--spacing-xs)}.museum-nav-item{padding:var(--spacing-sm) var(--spacing-lg);font-family:var(--font-heading);font-size:0.85rem;color:var(--text-secondary);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-2px;transition:color var(--transition-fast),border-color var(--transition-fast);white-space:nowrap}.museum-nav-item:hover{color:var(--text-primary)}.museum-nav-item.active{color:var(--primary-cyan);border-bottom-color:var(--primary-cyan)}.museum-section{margin-bottom:var(--spacing-xl)}.museum-section-actions{display:flex;align-items:center;flex-wrap:wrap;gap:var(--spacing-sm)}.museum-section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md);flex-wrap:wrap;gap:var(--spacing-sm)}.museum-section-title{font-family:var(--font-heading);font-size:1.2rem;color:var(--primary-cyan);margin:0}.museum-history-content{max-width:calc(75ch + 2 * var(--spacing-lg));padding:var(--spacing-lg);line-height:1.75;color:var(--text-primary);font-size:0.95rem}.museum-history-content p{margin:0 0 var(--spacing-md) 0}.museum-history-content p:last-child{margin-bottom:0}.museum-placeholder{padding:var(--spacing-xl);text-align:center}.museum-placeholder .text-muted{color:var(--text-muted)}.museum-generated-badge{font-size:0.75rem;font-family:var(--font-mono);color:var(--text-muted);padding:4px 10px;background:rgba(76,201,240,0.06);border:1px solid rgba(76,201,240,0.12);border-radius:var(--radius-full)}.museum-ai-badge{display:inline-block;padding:2px 8px;font-size:0.7rem;font-family:var(--font-mono);color:var(--neon-purple);background:rgba(168,85,247,0.1);border:1px solid rgba(168,85,247,0.2);border-radius:var(--radius-full)}.museum-system-page{--card-min:280px;--card-gap:var(--spacing-md)}.museum-specs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.museum-spec-item{padding:var(--spacing-md);display:flex;flex-direction:column;gap:4px}.museum-spec-label{font-family:var(--font-heading);font-size:0.75rem;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:0.5px}.museum-spec-value{font-size:0.9rem;color:var(--text-primary);font-family:var(--font-mono);word-break:break-word}.museum-top-games-list{display:flex;flex-direction:column;gap:var(--spacing-sm)}.museum-top-game{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);transition:border-color var(--transition-fast)}.museum-top-game:hover{border-color:var(--card-border-hover)}.museum-rank{font-family:var(--font-heading);font-size:1.3rem;color:var(--primary-cyan);min-width:40px;text-align:center;flex-shrink:0}.museum-top-game-art{width:48px;height:64px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);overflow:hidden;background:rgba(0,0,0,0.2)}.museum-top-game-art img{max-height:100%;max-width:100%;object-fit:cover}.museum-top-game-placeholder{font-size:1.5rem;opacity:0.3}.museum-top-game-info{flex:1;display:flex;flex-direction:column;gap:2px}.museum-top-game-title{font-family:var(--font-primary);font-weight:600;font-size:0.95rem;color:var(--text-primary);text-decoration:none}a.museum-top-game-title:hover{color:var(--primary-cyan)}.museum-top-game-meta{display:flex;gap:var(--spacing-xs);align-items:center}.museum-controllers-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--spacing-md)}.museum-controller-card{display:flex;flex-direction:column;overflow:hidden;border-radius:var(--radius-lg)}.museum-controller-image{height:180px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.15);padding:var(--spacing-md);position:relative}.museum-controller-image img{max-height:100%;max-width:100%;object-fit:contain;cursor:zoom-in}.museum-controller-image-actions{display:flex;gap:4px}.museum-controller-image-actions .btn{padding:2px 6px;font-size:0.65rem;line-height:1.2;letter-spacing:0.5px;backdrop-filter:blur(8px);background:rgba(0,0,0,0.6);border-color:rgba(76,201,240,0.4);color:var(--primary-cyan);box-shadow:none;text-shadow:none}.museum-controller-image-actions .btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan)}.museum-controller-image > .museum-controller-image-actions{position:absolute;bottom:var(--spacing-xs);right:var(--spacing-xs);opacity:0;transition:opacity var(--transition-fast)}.museum-controller-image:hover > .museum-controller-image-actions{opacity:1}.museum-controller-upload-overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--spacing-sm);background:rgba(10,14,23,0.85);z-index:5;border-radius:inherit}.museum-controller-upload-text{font-family:var(--font-primary);font-size:0.75rem;color:var(--text-secondary);letter-spacing:0.5px}.museum-lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.95);z-index:10002;align-items:center;justify-content:center;flex-direction:column}.museum-lightbox.active{display:flex}.museum-lightbox-image{max-width:90%;max-height:80vh;object-fit:contain;border-radius:var(--radius-md);position:relative;z-index:1}.museum-lightbox::before{content:"";position:absolute;top:45%;left:50%;transform:translate(-50%,-50%);width:90vmin;height:90vmin;background:radial-gradient(circle at center,rgba(76,201,240,0.19) 0%,rgba(76,201,240,0.09) 25%,rgba(76,201,240,0.03) 45%,transparent 40%);border-radius:50%;pointer-events:none;z-index:0}.museum-lightbox-close{position:absolute;top:20px;right:35px;font-size:2.5rem;color:var(--text-primary);cursor:pointer;z-index:10003;transition:color var(--transition-fast);background:none;border:none;line-height:1}.museum-lightbox-close:hover{color:var(--primary-cyan)}.museum-lightbox-title{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);color:var(--text-secondary);font-family:var(--font-heading);font-size:0.9rem;white-space:nowrap}.museum-lightbox-hint{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);color:var(--text-muted);font-size:0.75rem;white-space:nowrap}.museum-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.5);border:2px solid var(--card-border);color:var(--text-primary);font-size:3rem;padding:10px 20px;cursor:pointer;border-radius:var(--radius-md);transition:all var(--transition-fast);z-index:10003}.museum-lightbox-nav:hover{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.museum-lightbox-nav.prev{left:20px}.museum-lightbox-nav.next{right:20px}.museum-controller-placeholder{display:flex;flex-direction:column;align-items:center;gap:var(--spacing-sm);color:var(--text-muted);font-size:2.5rem}.museum-controller-info{padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-xs)}.museum-controller-name{font-family:var(--font-heading);font-size:0.85rem;color:var(--text-primary);margin:0}.museum-controller-meta{display:flex;flex-wrap:wrap;gap:4px}.museum-controller-desc{font-size:0.8rem;color:var(--text-secondary);margin:var(--spacing-xs) 0 0 0;line-height:1.4}.museum-controller-buttons{margin-top:var(--spacing-xs);font-size:0.8rem;color:var(--text-secondary)}.museum-controller-buttons summary{cursor:pointer;color:var(--primary-cyan);font-family:var(--font-heading);font-size:0.75rem}.museum-controller-buttons p{margin:var(--spacing-xs) 0 0 0;font-family:var(--font-mono);font-size:0.75rem;line-height:1.5}@media (max-width:768px){.museum-hero-layout{flex-direction:column;align-items:center;text-align:center}.museum-hero-image{width:180px;height:150px}.museum-hero-meta,.museum-hero-actions{justify-content:center}.museum-systems-grid{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}.museum-specs-grid{grid-template-columns:1fr}.museum-controllers-grid{grid-template-columns:1fr}.museum-nav{overflow-x:auto;-webkit-overflow-scrolling:touch}}body::before{content:'';position:fixed;top:0;left:0;width:100%;height:100%;background:linear-gradient( to top right,rgba(76,201,240,0.08) 0%,rgba(114,9,183,0.06) 25%,rgba(247,37,133,0.04) 50%,rgba(20,26,35,0.02) 75%,transparent 100% );pointer-events:none;z-index:-2}body::after{content:'';position:fixed;top:0;left:0;width:100%;height:100%;background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,0,0,0.03) 2px,rgba(0,0,0,0.03) 4px );pointer-events:none;z-index:9999}@media (prefers-reduced-motion:reduce){body::after{display:none}}@keyframes glow-pulse{0%,100%{text-shadow:0 0 10px var(--primary-cyan-glow),0 0 20px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow)}50%{text-shadow:0 0 15px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow),0 0 45px var(--primary-cyan-glow)}}@keyframes pulse-status{0%,100%{opacity:1}50%{opacity:0.5}}@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes highlight-pulse{0%,100%{box-shadow:0 0 0 0 rgba(76,201,240,0)}50%{box-shadow:0 0 20px 5px rgba(76,201,240,0.4)}}.highlight-jump{animation:highlight-pulse 0.75s ease-in-out 2}@keyframes modalSlideIn{from{opacity:0;transform:scale(0.9) translateY(-20px)}to{opacity:1;transform:scale(1) translateY(0)}}@media (prefers-reduced-motion:reduce){.highlight-jump{animation:none}}.mt-0{margin-top:0}.mt-xs{margin-top:var(--spacing-xs)}.mt-sm{margin-top:var(--spacing-sm)}.mt-md{margin-top:var(--spacing-md)}.mt-lg{margin-top:var(--spacing-lg)}.mt-xl{margin-top:var(--spacing-xl)}.mt-2xl{margin-top:var(--spacing-2xl)}.mb-0{margin-bottom:0}.mb-xs{margin-bottom:var(--spacing-xs)}.mb-sm{margin-bottom:var(--spacing-sm)}.mb-md{margin-bottom:var(--spacing-md)}.mb-lg{margin-bottom:var(--spacing-lg)}.mb-xl{margin-bottom:var(--spacing-xl)}.mb-2xl{margin-bottom:var(--spacing-2xl)}.ml-0{margin-left:0}.ml-xs{margin-left:var(--spacing-xs)}.ml-sm{margin-left:var(--spacing-sm)}.ml-md{margin-left:var(--spacing-md)}.ml-lg{margin-left:var(--spacing-lg)}.ml-xl{margin-left:var(--spacing-xl)}.mr-0{margin-right:0}.mr-xs{margin-right:var(--spacing-xs)}.mr-sm{margin-right:var(--spacing-sm)}.mr-md{margin-right:var(--spacing-md)}.mr-lg{margin-right:var(--spacing-lg)}.mr-xl{margin-right:var(--spacing-xl)}.mx-0{margin-left:0;margin-right:0}.mx-sm{margin-left:var(--spacing-sm);margin-right:var(--spacing-sm)}.mx-md{margin-left:var(--spacing-md);margin-right:var(--spacing-md)}.mx-lg{margin-left:var(--spacing-lg);margin-right:var(--spacing-lg)}.mx-auto{margin-left:auto;margin-right:auto}.my-0{margin-top:0;margin-bottom:0}.my-sm{margin-top:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.my-md{margin-top:var(--spacing-md);margin-bottom:var(--spacing-md)}.my-lg{margin-top:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.pt-0{padding-top:0}.pt-xs{padding-top:var(--spacing-xs)}.pt-sm{padding-top:var(--spacing-sm)}.pt-md{padding-top:var(--spacing-md)}.pt-lg{padding-top:var(--spacing-lg)}.pt-xl{padding-top:var(--spacing-xl)}.pb-0{padding-bottom:0}.pb-xs{padding-bottom:var(--spacing-xs)}.pb-sm{padding-bottom:var(--spacing-sm)}.pb-md{padding-bottom:var(--spacing-md)}.pb-lg{padding-bottom:var(--spacing-lg)}.pb-xl{padding-bottom:var(--spacing-xl)}.pl-0{padding-left:0}.pl-sm{padding-left:var(--spacing-sm)}.pl-md{padding-left:var(--spacing-md)}.pl-lg{padding-left:var(--spacing-lg)}.pr-0{padding-right:0}.pr-sm{padding-right:var(--spacing-sm)}.pr-md{padding-right:var(--spacing-md)}.pr-lg{padding-right:var(--spacing-lg)}.px-0{padding-left:0;padding-right:0}.px-sm{padding-left:var(--spacing-sm);padding-right:var(--spacing-sm)}.px-md{padding-left:var(--spacing-md);padding-right:var(--spacing-md)}.px-lg{padding-left:var(--spacing-lg);padding-right:var(--spacing-lg)}.py-0{padding-top:0;padding-bottom:0}.py-sm{padding-top:var(--spacing-sm);padding-bottom:var(--spacing-sm)}.py-md{padding-top:var(--spacing-md);padding-bottom:var(--spacing-md)}.py-lg{padding-top:var(--spacing-lg);padding-bottom:var(--spacing-lg)}.d-none{display:none}.d-block{display:block}.d-inline{display:inline}.d-inline-block{display:inline-block}.d-flex{display:flex}.d-inline-flex{display:inline-flex}.d-grid{display:grid}.flex-1{flex:1}.flex-auto{flex:auto}.flex-none{flex:none}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.flex-column{flex-direction:column}.flex-row{flex-direction:row}.align-start{align-items:flex-start}.align-center{align-items:center}.align-end{align-items:flex-end}.align-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-0{gap:0}.gap-xs{gap:var(--spacing-xs)}.gap-sm{gap:var(--spacing-sm)}.gap-md{gap:var(--spacing-md)}.gap-lg{gap:var(--spacing-lg)}.gap-xl{gap:var(--spacing-xl)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-primary{color:var(--text-primary)}.text-secondary{color:var(--text-secondary)}.text-muted{color:var(--text-muted)}.text-cyan{color:var(--primary-cyan)}.text-magenta{color:var(--secondary-magenta)}.text-success{color:var(--neon-green)}.text-warning{color:var(--neon-orange)}.text-danger{color:var(--neon-red)}.text-purple{color:var(--neon-purple)}.font-heading{font-family:var(--font-heading)}.font-mono{font-family:var(--font-mono)}.font-normal{font-weight:400}.font-medium{font-weight:500}.font-semibold{font-weight:600}.font-bold{font-weight:700}.text-sm{font-size:0.85rem}.text-base{font-size:1rem}.text-lg{font-size:1.125rem}.text-xl{font-size:1.25rem}.text-2xl{font-size:1.5rem}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.leading-tight{line-height:1.25}.leading-normal{line-height:1.6}.leading-loose{line-height:2}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.break-word{word-wrap:break-word;overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}.sticky{position:sticky}.w-full{width:100%}.w-auto{width:auto}.h-full{height:100%}.h-auto{height:auto}.min-h-screen{min-height:100vh}.overflow-hidden{overflow:hidden}.overflow-auto{overflow:auto}.overflow-scroll{overflow:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.border{border:1px solid var(--card-border)}.border-0{border:0}.border-t{border-top:1px solid var(--card-border)}.border-b{border-bottom:1px solid var(--card-border)}.border-l{border-left:1px solid var(--card-border)}.border-r{border-right:1px solid var(--card-border)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-md{border-radius:var(--radius-md)}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-full{border-radius:var(--radius-full)}.cursor-pointer{cursor:pointer}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.opacity-0{opacity:0}.opacity-25{opacity:0.25}.opacity-50{opacity:0.5}.opacity-75{opacity:0.75}.opacity-100{opacity:1}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.select-none{user-select:none}.select-text{user-select:text}.select-all{user-select:all}.mobile-header,.mobile-bottom-bar,.sidebar-scrim{display:none}@media (max-width:992px){:root{--sticky-top:var(--mobile-header-height)}.mobile-header{position:fixed;top:0;left:0;right:0;z-index:1001;height:var(--mobile-header-height);display:flex;align-items:center;gap:var(--spacing-sm);padding:0 var(--spacing-sm);background:var(--bg-medium);border-bottom:1px solid var(--card-border)}.mobile-header-logo{flex:1}.mobile-header-logo .logo-text{font-size:1.25rem}.mobile-menu-toggle,.mobile-search-toggle{display:flex;align-items:center;justify-content:center;width:44px;height:44px;background:none;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer}.mobile-menu-toggle{flex-direction:column;gap:4px}.mobile-menu-toggle span{width:24px;height:3px;background:var(--text-primary);border-radius:2px}.mobile-search-toggle{font-size:1.25rem}.mobile-menu-toggle:focus-visible,.mobile-search-toggle:focus-visible{outline:none;box-shadow:0 0 0 2px var(--primary-cyan)}.sidebar{top:var(--mobile-header-height);height:calc(100dvh - var(--mobile-header-height));width:min(var(--sidebar-width),85vw);background:linear-gradient(180deg,var(--bg-medium) 0%,var(--bg-dark) 100%);backdrop-filter:none;-webkit-backdrop-filter:none;visibility:hidden;transition:transform var(--transition-normal),visibility 0s var(--transition-normal)}.sidebar.open{visibility:visible;transition:transform var(--transition-normal)}.sidebar-header > .logo{display:none}.sidebar-header > .logo + *{margin-top:0}.global-search-list{max-height:50dvh}.sidebar-nav{overflow-y:auto}.sidebar-scrim{display:block;position:fixed;inset:0;z-index:999;background:rgba(0,0,0,0.6);opacity:0;pointer-events:none;transition:opacity var(--transition-normal)}body.mobile-menu-open .sidebar-scrim{opacity:1;pointer-events:auto}body.mobile-menu-open{overflow:hidden}.mobile-bottom-bar{position:fixed;left:0;right:0;bottom:0;z-index:998;display:flex;height:calc(var(--mobile-bar-height) + env(safe-area-inset-bottom, 0px));padding-bottom:env(safe-area-inset-bottom,0px);background:var(--bg-medium);border-top:1px solid var(--card-border)}.mobile-bottom-item{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;min-width:0;color:var(--text-secondary);text-decoration:none;font-size:0.75rem}.mobile-bottom-icon{font-size:1.25rem}.mobile-bottom-label{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mobile-bottom-item.active{color:var(--primary-cyan);box-shadow:inset 0 2px 0 var(--primary-cyan)}.mobile-bottom-item:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--primary-cyan)}.main-content{padding-top:calc(var(--mobile-header-height) + var(--spacing-md));padding-bottom:calc(var(--mobile-bar-height) + env(safe-area-inset-bottom, 0px) + var(--spacing-md))}body:has(.mobile-bottom-bar) .back-to-top-btn{bottom:calc(var(--mobile-bar-height) + 16px)}body:has(.mobile-bottom-bar) .unified-toast-container{bottom:calc(var(--mobile-bar-height) + 12px)}body:has(.mobile-bottom-bar) .launch-indicator{bottom:calc(var(--mobile-bar-height) + var(--spacing-sm))}body:has(.mobile-bottom-bar) .launch-indicator-popover{bottom:calc(var(--mobile-bar-height) + var(--spacing-sm) + 2.5rem)}}@media (max-width:1600px){.content-wrapper{width:80%}}@media (max-width:1200px){.content-wrapper{width:95%;min-width:auto}.game-hero{grid-template-columns:250px 1fr}.game-details-grid{grid-template-columns:1fr}}@media (max-width:992px){.sidebar{transform:translateX(-100%)}.sidebar.open{transform:translateX(0)}.main-content{margin-left:0}.game-hero{grid-template-columns:1fr}.game-boxart-container{max-width:300px;margin:0 auto}.unified-toast-container{left:20px;right:20px;width:auto;max-width:calc(100% - 40px)}.system-controller-grid{grid-template-columns:1fr !important}}@media (max-width:900px){.score-lists-grid{grid-template-columns:1fr}.psn-trophy-lists-row{grid-template-columns:1fr}.psn-info-panels-row{grid-template-columns:1fr}.psn-game-info-card{flex-direction:column}.psn-game-boxart{width:140px}.psn-meta-grid{grid-template-columns:1fr 1fr}.psn-stats-bar{grid-template-columns:repeat(2,1fr)}}@media (max-width:768px){.page-header{flex-direction:column;align-items:flex-start;gap:var(--spacing-md)}.page-title{font-size:1.8rem}.stats-grid{grid-template-columns:1fr}.systems-grid{grid-template-columns:repeat(2,1fr)}.filter-bar{flex-direction:column}.search-box{min-width:100%}.game-meta-badges{flex-direction:column}.game-ratings{flex-direction:column}.game-hero{grid-template-columns:1fr}.hero-boxart{width:200px;margin:0 auto}.tool-tab{padding:0.75rem 0.5rem;font-size:0.8rem}.tool-tab .tab-label{display:none}.tool-tab .tab-icon{font-size:1.25rem}.edit-grid{grid-template-columns:1fr}.psn-games-grid{grid-template-columns:1fr}.psn-search-sort-row{flex-direction:column}.psn-sort-controls{width:100%}.psn-sort-select{flex:1}.psn-profile-bar{flex-direction:column;text-align:center}}@media (max-width:600px){.gdm-header{flex-direction:column;align-items:center;text-align:center}.gdm-boxart-container{width:140px}.gdm-details-grid{grid-template-columns:1fr}.gdm-meta{justify-content:center}.gdm-ratings{justify-content:center}.gdm-actions{flex-direction:column}.gdm-actions .btn{width:100%}.gem-hltb-times{grid-template-columns:1fr}.gem-footer{flex-direction:column}.gem-footer .btn{width:100%}.gem-tabs{flex-wrap:nowrap}.gem-tab{padding:6px 10px;font-size:0.75rem}.gem-grid{grid-template-columns:1fr}.gem-footer-right{flex-direction:column;width:100%}.psn-meta-grid{grid-template-columns:1fr}.psn-trophy-type-breakdown{flex-wrap:wrap;gap:var(--spacing-md)}.platform-filters{justify-content:center}}@media (max-width:480px){.systems-grid{grid-template-columns:1fr}.page-title{font-size:1.5rem;letter-spacing:2px}.stat-value{font-size:2.5rem}} \ No newline at end of file +:root{--primary-cyan:#4cc9f0;--primary-cyan-dark:#3a9fc2;--primary-cyan-light:#7dd8f5;--primary-cyan-glow:rgba(76,201,240,0.5);--secondary-magenta:#f72585;--secondary-magenta-dark:#c41d6a;--secondary-magenta-light:#f95fa3;--secondary-magenta-glow:rgba(247,37,133,0.5);--accent-purple:#7209b7;--accent-blue:#3a0ca3;--accent-violet:#560bad;--bg-darkest:#0a0e17;--bg-dark:#0f1419;--bg-darker:#0d1117;--bg-medium:#151b24;--bg-light:#1a222d;--bg-lighter:#212b38;--card-bg:#141a23;--card-bg-hover:#1a222d;--card-border:#2a3542;--card-border-hover:#3a4552;--text-primary:#e8eaed;--text-secondary:#9aa0a6;--text-muted:#6e7378;--text-accent:var(--primary-cyan);--border-color:#2a3542;--status-success:#00e676;--status-warning:#ffab00;--status-error:#ff5252;--status-info:var(--primary-cyan);--status-online:#00e676;--status-offline:#ff5252;--status-connected:var(--primary-cyan);--shadow-sm:0 2px 4px rgba(0,0,0,0.3);--shadow-md:0 4px 8px rgba(0,0,0,0.4);--shadow-lg:0 8px 16px rgba(0,0,0,0.5);--shadow-xl:0 12px 24px rgba(0,0,0,0.6);--shadow-glow-cyan:0 0 20px rgba(76,201,240,0.3);--shadow-glow-magenta:0 0 20px rgba(247,37,133,0.3);--radius-sm:4px;--radius-md:8px;--radius-lg:12px;--radius-xl:16px;--radius-full:9999px;--transition-fast:0.15s ease;--transition-normal:0.25s ease;--transition-slow:0.4s ease;--font-primary:'Rajdhani','Orbitron','Share Tech Mono','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif;--font-heading:'Orbitron','Rajdhani','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif;--font-mono:'Share Tech Mono','JetBrains Mono','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',monospace;--spacing-xs:4px;--spacing-sm:8px;--spacing-md:16px;--spacing-lg:24px;--spacing-xl:32px;--spacing-2xl:48px;--sidebar-width:260px;--sidebar-collapsed-width:70px;--mobile-header-height:56px;--mobile-bar-height:60px;--sticky-top:0px;--neon-cyan:#4cc9f0;--neon-green:#22c55e;--neon-orange:#f59e0b;--neon-red:#ef4444;--neon-purple:#a855f7;--neon-gray:#64748b;--neon-blue:#3b82f6;--text-on-color:#fff;--font-display:var(--font-heading);--accent-cyan:var(--primary-cyan);--cyan-bg:rgba(76,201,240,0.15);--cyan-bg-light:rgba(76,201,240,0.1);--cyan-bg-strong:rgba(76,201,240,0.2);--green-bg:rgba(34,197,94,0.15);--red-bg:rgba(239,68,68,0.15);--orange-bg:rgba(245,158,11,0.15);--purple-bg:rgba(168,85,247,0.15);--magenta-bg:rgba(247,37,133,0.15);--trophy-platinum:#7bb8d4;--trophy-gold:#cd9a2a;--trophy-silver:#a8a8a8;--trophy-bronze:#cd7f32;--trophy-display-platinum:#a8c8dc;--trophy-display-gold:#ffd700;--trophy-display-silver:#c0c0c0;--trophy-display-bronze:#cd7f32;--trophy-display-text:#111;--rating-esrb:#64b5f6;--rating-esrb-border:rgba(25,118,210,0.3);--rating-esrb-bg:rgba(25,118,210,0.1);--rating-pegi:#ce93d8;--rating-pegi-border:rgba(123,31,162,0.3);--rating-pegi-bg:rgba(123,31,162,0.1);--completion-gold:#fbbf24}*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html{font-size:16px;scroll-behavior:smooth}body{font-family:var(--font-primary);background-color:var(--bg-darkest);color:var(--text-primary);line-height:1.6;min-height:100vh;overflow-x:hidden;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}ul,ol{list-style:none}img,picture,video,canvas,svg{display:block;max-width:100%}button{font:inherit;cursor:pointer}input,textarea,select{font:inherit}input,button,textarea,select{font-family:inherit}a{color:inherit;text-decoration:none}@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:0.01ms !important;animation-iteration-count:1 !important;transition-duration:0.01ms !important;scroll-behavior:auto !important}}@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap');h1,.h1{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);letter-spacing:1px;text-transform:uppercase}h2,.h2{font-family:var(--font-heading);font-size:1.5rem;font-weight:600;color:var(--text-primary);letter-spacing:0.5px}h3,.h3{font-family:var(--font-heading);font-size:1.25rem;font-weight:600;color:var(--text-primary)}h4,.h4{font-family:var(--font-primary);font-size:1.1rem;font-weight:600;color:var(--text-primary)}h5,.h5{font-family:var(--font-primary);font-size:1rem;font-weight:600;color:var(--text-secondary)}h6,.h6{font-family:var(--font-primary);font-size:0.9rem;font-weight:500;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px}.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.page-header h1{margin-bottom:var(--spacing-sm)}.page-header p{color:var(--text-secondary);font-size:1rem}.page-title{font-family:var(--font-heading);font-size:2.5rem;font-weight:700;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:4px;text-shadow:0 0 10px var(--primary-cyan-glow),0 0 20px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow);animation:glow-pulse 3s ease-in-out infinite}.page-subtitle{color:var(--text-secondary);font-size:1rem;margin-top:var(--spacing-xs)}.page-actions{display:flex;gap:var(--spacing-md)}.section-title{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:2px;display:flex;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.section-title::before{content:'';width:4px;height:20px;background:var(--primary-cyan);border-radius:2px}.breadcrumb{display:flex;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);font-size:0.9rem}.breadcrumb a{color:var(--text-secondary);text-decoration:none;transition:color var(--transition-fast)}.breadcrumb a:hover{color:var(--primary-cyan)}.breadcrumb-separator{color:var(--text-muted)}.breadcrumb-current{color:var(--text-primary)}code,pre,.mono{font-family:var(--font-mono)}code{padding:0.2em 0.4em;background:rgba(76,201,240,0.1);border-radius:var(--radius-sm);font-size:0.9em;color:var(--primary-cyan)}pre{padding:var(--spacing-md);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow-x:auto}pre code{padding:0;background:none}[data-theme="matrix"]{--primary-cyan:#00ff41;--primary-cyan-dark:#00cc33;--primary-cyan-light:#66ff88;--primary-cyan-glow:rgba(0,255,65,0.5);--secondary-magenta:#008f11;--secondary-magenta-dark:#006b0d;--secondary-magenta-light:#33b833;--secondary-magenta-glow:rgba(0,143,17,0.5);--accent-purple:#00aa22;--accent-blue:#004d00;--accent-violet:#009a1a;--bg-darkest:#000000;--bg-dark:#050505;--bg-darker:#0a0a0a;--bg-medium:#111111;--bg-light:#161616;--bg-lighter:#1a1a1a;--card-bg:#0a0a0a;--card-bg-hover:#111111;--card-border:#1a3a1a;--card-border-hover:#2a5a2a;--text-primary:#c0ffc0;--text-secondary:#80c080;--text-muted:#508050;--text-accent:var(--primary-cyan);--border-color:#1a3a1a;--status-success:#00ff41;--status-warning:#a0e000;--status-error:#ff3333;--status-info:var(--primary-cyan);--status-online:#00ff41;--status-offline:#ff3333;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(0,255,65,0.3);--shadow-glow-magenta:0 0 20px rgba(0,143,17,0.3);--neon-cyan:#00ff41;--neon-green:#00ff41;--neon-orange:#a0e000;--neon-red:#ff3333;--neon-purple:#33cc66;--neon-gray:#4a6a4a;--trophy-platinum:#66ffaa;--trophy-gold:#a0e060;--trophy-silver:#88bb88;--trophy-bronze:#77aa55;--toast-bg-from:rgba(0,10,0,0.95);--toast-bg-to:rgba(0,20,5,0.95);--toast-queued-bg-from:rgba(0,12,0,0.90);--toast-queued-bg-to:rgba(0,22,5,0.90)}[data-theme="matrix"] body::before{background:linear-gradient( to top right,rgba(0,255,65,0.06) 0%,rgba(0,143,17,0.04) 25%,rgba(0,80,0,0.03) 50%,rgba(0,0,0,0.02) 75%,transparent 100% )}[data-theme="matrix"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,255,65,0.02) 2px,rgba(0,255,65,0.02) 4px )}[data-theme="amber"]{--primary-cyan:#ffb000;--primary-cyan-dark:#cc8c00;--primary-cyan-light:#ffc944;--primary-cyan-glow:rgba(255,176,0,0.5);--secondary-magenta:#ff6600;--secondary-magenta-dark:#cc5200;--secondary-magenta-light:#ff8833;--secondary-magenta-glow:rgba(255,102,0,0.5);--accent-purple:#cc7700;--accent-blue:#995500;--accent-violet:#bb6600;--bg-darkest:#0a0800;--bg-dark:#0d0a02;--bg-darker:#0f0c04;--bg-medium:#151008;--bg-light:#1a150c;--bg-lighter:#211c12;--card-bg:#120e04;--card-bg-hover:#181208;--card-border:#3a2a10;--card-border-hover:#4a3a1a;--text-primary:#ffe0a0;--text-secondary:#c09050;--text-muted:#806030;--text-accent:var(--primary-cyan);--border-color:#3a2a10;--status-success:#ffcc00;--status-warning:#ff9900;--status-error:#ff4422;--status-info:var(--primary-cyan);--status-online:#ffcc00;--status-offline:#ff4422;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(255,176,0,0.3);--shadow-glow-magenta:0 0 20px rgba(255,102,0,0.3);--neon-cyan:#ffb000;--neon-green:#ddaa00;--neon-orange:#ff7700;--neon-red:#ff4422;--neon-purple:#ff8844;--neon-gray:#7a6a4a;--trophy-platinum:#ffe8aa;--trophy-gold:#ffcc44;--trophy-silver:#ccaa77;--trophy-bronze:#aa7733;--toast-bg-from:rgba(18,14,4,0.95);--toast-bg-to:rgba(28,20,8,0.95);--toast-queued-bg-from:rgba(20,16,4,0.90);--toast-queued-bg-to:rgba(30,22,8,0.90)}[data-theme="amber"] body::before{background:radial-gradient(ellipse 30% 20% at 50% 100%,rgba(255,230,100,0.2) 0%,rgba(255,200,50,0.12) 40%,transparent 100%),radial-gradient(ellipse 60% 40% at 50% 100%,rgba(255,160,0,0.1) 0%,rgba(255,100,0,0.05) 50%,transparent 100%),linear-gradient(to top,rgba(255,120,0,0.08) 0%,rgba(255,80,0,0.04) 20%,rgba(120,50,0,0.02) 45%,transparent 70%)}[data-theme="amber"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(255,176,0,0.03) 2px,rgba(255,176,0,0.03) 4px )}[data-theme="ocean"]{--primary-cyan:#4dabf7;--primary-cyan-dark:#3d8bd4;--primary-cyan-light:#74c0fa;--primary-cyan-glow:rgba(77,171,247,0.5);--secondary-magenta:#20c997;--secondary-magenta-dark:#1aaa7f;--secondary-magenta-light:#4dd8ad;--secondary-magenta-glow:rgba(32,201,151,0.5);--accent-purple:#5c7cfa;--accent-blue:#3b5bdb;--accent-violet:#4c6ef5;--bg-darkest:#040810;--bg-dark:#050a14;--bg-darker:#060c18;--bg-medium:#0a1220;--bg-light:#0e1828;--bg-lighter:#142030;--card-bg:#081018;--card-bg-hover:#0c1620;--card-border:#1a2a40;--card-border-hover:#2a3a55;--text-primary:#dce4f0;--text-secondary:#8899b0;--text-muted:#5a6a80;--text-accent:var(--primary-cyan);--border-color:#1a2a40;--status-success:#40c057;--status-warning:#fab005;--status-error:#fa5252;--status-info:var(--primary-cyan);--status-online:#40c057;--status-offline:#fa5252;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(77,171,247,0.3);--shadow-glow-magenta:0 0 20px rgba(32,201,151,0.3);--neon-cyan:#4dabf7;--neon-green:#40c057;--neon-orange:#fab005;--neon-red:#fa5252;--neon-purple:#7950f2;--neon-gray:#5a6a80;--trophy-platinum:#a5d8ff;--trophy-gold:#ffe066;--trophy-silver:#adb5bd;--trophy-bronze:#e8a44a;--toast-bg-from:rgba(6,12,22,0.95);--toast-bg-to:rgba(10,18,32,0.95);--toast-queued-bg-from:rgba(8,14,24,0.90);--toast-queued-bg-to:rgba(12,20,34,0.90)}[data-theme="ocean"] body::before{background:radial-gradient(circle at 70% 10%,rgba(220,235,255,0.4) 0%,rgba(200,220,250,0.2) 1.5%,rgba(150,190,240,0.08) 4%,transparent 10%),radial-gradient(circle at 70% 10%,rgba(77,171,247,0.08) 0%,transparent 18%),radial-gradient(ellipse 80% 50% at 50% 90%,rgba(32,201,151,0.04) 0%,rgba(77,171,247,0.05) 40%,transparent 100%),linear-gradient(to bottom,rgba(10,20,40,0.02) 0%,rgba(77,171,247,0.03) 55%,rgba(32,201,151,0.02) 100%)}[data-theme="ocean"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(77,171,247,0.02) 2px,rgba(77,171,247,0.02) 4px )}[data-theme="christian"]{--primary-cyan:#d4a843;--primary-cyan-dark:#b08a30;--primary-cyan-light:#e8c86a;--primary-cyan-glow:rgba(212,168,67,0.5);--secondary-magenta:#7b4db0;--secondary-magenta-dark:#5f3a8a;--secondary-magenta-light:#9b6dd0;--secondary-magenta-glow:rgba(123,77,176,0.5);--accent-purple:#6a3d9a;--accent-blue:#4a2d7a;--accent-violet:#7b4db0;--bg-darkest:#0c0810;--bg-dark:#0e0a14;--bg-darker:#110d18;--bg-medium:#16101f;--bg-light:#1c1528;--bg-lighter:#231c30;--card-bg:#120e1a;--card-bg-hover:#181222;--card-border:#2a1f3a;--card-border-hover:#3a2d4e;--text-primary:#f0e8d8;--text-secondary:#b0a090;--text-muted:#706050;--text-accent:var(--primary-cyan);--border-color:#2a1f3a;--status-success:#d4a843;--status-warning:#e8a030;--status-error:#c04040;--status-info:var(--primary-cyan);--status-online:#d4a843;--status-offline:#c04040;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(212,168,67,0.3);--shadow-glow-magenta:0 0 20px rgba(123,77,176,0.3);--neon-cyan:#d4a843;--neon-green:#c0a040;--neon-orange:#e8a030;--neon-red:#c04040;--neon-purple:#9b6dd0;--neon-gray:#6a5a50;--trophy-platinum:#f0e0c0;--trophy-gold:#d4a843;--trophy-silver:#b0a090;--trophy-bronze:#8a6a40;--toast-bg-from:rgba(14,10,20,0.95);--toast-bg-to:rgba(22,16,30,0.95);--toast-queued-bg-from:rgba(16,12,22,0.90);--toast-queued-bg-to:rgba(24,18,32,0.90)}[data-theme="christian"] body::before{background:radial-gradient(ellipse 40% 60% at 50% 0%,rgba(212,168,67,0.15) 0%,rgba(212,168,67,0.06) 30%,transparent 70%),radial-gradient(ellipse 70% 50% at 50% 10%,rgba(180,140,50,0.06) 0%,rgba(123,77,176,0.03) 50%,transparent 100%),linear-gradient(to top,rgba(100,60,20,0.04) 0%,rgba(80,40,120,0.02) 40%,transparent 70%)}[data-theme="christian"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(212,168,67,0.015) 2px,rgba(212,168,67,0.015) 4px )}[data-theme="bladerunner"]{--primary-cyan:#1a9fff;--primary-cyan-dark:#1480d4;--primary-cyan-light:#55b8ff;--primary-cyan-glow:rgba(26,159,255,0.5);--secondary-magenta:#ff2d7c;--secondary-magenta-dark:#d42060;--secondary-magenta-light:#ff5a9a;--secondary-magenta-glow:rgba(255,45,124,0.5);--accent-purple:#b040e0;--accent-blue:#1060c0;--accent-violet:#8830c0;--bg-darkest:#06080e;--bg-dark:#080a12;--bg-darker:#0a0d16;--bg-medium:#0e121c;--bg-light:#131824;--bg-lighter:#1a1f2e;--card-bg:#0a0e16;--card-bg-hover:#10141e;--card-border:#1a2236;--card-border-hover:#2a3248;--text-primary:#d8dce8;--text-secondary:#8892a8;--text-muted:#78809a;--text-accent:var(--primary-cyan);--border-color:#1a2236;--status-success:#22cc88;--status-warning:#ff8c30;--status-error:#ff2d5e;--status-info:var(--primary-cyan);--status-online:#22cc88;--status-offline:#ff2d5e;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(26,159,255,0.3);--shadow-glow-magenta:0 0 20px rgba(255,45,124,0.3);--neon-cyan:#1a9fff;--neon-green:#22cc88;--neon-orange:#ff8c30;--neon-red:#ff2d5e;--neon-purple:#b040e0;--neon-gray:#4a5268;--trophy-platinum:#c0d8ff;--trophy-gold:#ffd066;--trophy-silver:#a0aab8;--trophy-bronze:#d08a40;--toast-bg-from:rgba(8,10,18,0.95);--toast-bg-to:rgba(14,18,28,0.95);--toast-queued-bg-from:rgba(10,12,20,0.90);--toast-queued-bg-to:rgba(16,20,30,0.90)}[data-theme="bladerunner"] body::before{background:radial-gradient(ellipse 40% 30% at 20% 95%,rgba(255,45,124,0.08) 0%,rgba(255,45,124,0.03) 50%,transparent 100%),radial-gradient(ellipse 40% 30% at 80% 90%,rgba(26,159,255,0.08) 0%,rgba(26,159,255,0.03) 50%,transparent 100%),radial-gradient(ellipse 60% 40% at 50% 70%,rgba(130,40,180,0.04) 0%,transparent 80%),linear-gradient(to bottom,rgba(6,8,14,0.02) 0%,rgba(20,15,30,0.04) 60%,rgba(255,45,124,0.02) 100%)}[data-theme="bladerunner"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(26,159,255,0.02) 2px,rgba(26,159,255,0.02) 4px )}[data-theme="elite"]{--primary-cyan:#00ff00;--primary-cyan-dark:#00cc00;--primary-cyan-light:#66ff66;--primary-cyan-glow:rgba(0,255,0,0.5);--secondary-magenta:#cccccc;--secondary-magenta-dark:#999999;--secondary-magenta-light:#e0e0e0;--secondary-magenta-glow:rgba(204,204,204,0.5);--accent-purple:#00cc66;--accent-blue:#008844;--accent-violet:#00aa55;--bg-darkest:#000000;--bg-dark:#020202;--bg-darker:#040404;--bg-medium:#080808;--bg-light:#0c0c0c;--bg-lighter:#111111;--card-bg:#050505;--card-bg-hover:#0a0a0a;--card-border:#1a331a;--card-border-hover:#2a552a;--text-primary:#d0ffd0;--text-secondary:#80cc80;--text-muted:#508050;--text-accent:var(--primary-cyan);--border-color:#1a331a;--status-success:#00ff00;--status-warning:#ffff00;--status-error:#ff0000;--status-info:var(--primary-cyan);--status-online:#00ff00;--status-offline:#ff0000;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(0,255,0,0.3);--shadow-glow-magenta:0 0 20px rgba(204,204,204,0.3);--neon-cyan:#00ff00;--neon-green:#00ff00;--neon-orange:#ffff00;--neon-red:#ff0000;--neon-purple:#00cc66;--neon-gray:#4a6a4a;--trophy-platinum:#ccffcc;--trophy-gold:#ffff66;--trophy-silver:#aaccaa;--trophy-bronze:#88aa44;--toast-bg-from:rgba(0,5,0,0.95);--toast-bg-to:rgba(0,12,0,0.95);--toast-queued-bg-from:rgba(0,8,0,0.90);--toast-queued-bg-to:rgba(0,15,0,0.90)}[data-theme="elite"] body::before{background:radial-gradient(circle at 30% 20%,rgba(255,255,255,0.03) 0%,transparent 8%),radial-gradient(circle at 70% 15%,rgba(255,255,255,0.02) 0%,transparent 5%),radial-gradient(ellipse 40% 25% at 50% 95%,rgba(0,255,0,0.04) 0%,transparent 80%),linear-gradient(to bottom,rgba(0,0,0,0) 0%,rgba(0,20,0,0.02) 100%)}[data-theme="elite"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,255,0,0.025) 2px,rgba(0,255,0,0.025) 4px )}.app-container{display:flex;min-height:100vh;position:relative;z-index:2}.main-content{flex:1;margin-left:var(--sidebar-width);padding:var(--spacing-xl);transition:margin-left var(--transition-normal);display:flex;justify-content:center;position:relative}.main-content.sidebar-collapsed{margin-left:var(--sidebar-collapsed-width)}.content-wrapper{width:65%;max-width:1400px;min-width:600px;position:relative;z-index:1}.content-wrapper.adaptive{width:100%;max-width:none;min-width:0}.main-content.adaptive{min-width:0}.page-box{max-width:var(--page-max,none);margin-inline:auto}.page-box.grid-4{--page-max:calc(5 * var(--card-min) + 4 * var(--card-gap) - 1px)}.page-box.data-page{--page-max:1400px}.page-box.form-page{--page-max:1100px}.page-box.detail-page{--page-max:1600px}.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg)}.section-link{color:var(--primary-cyan);text-decoration:none;font-size:0.85rem;font-weight:500;text-transform:uppercase;letter-spacing:1px;transition:all var(--transition-fast)}.section-link:hover{text-shadow:0 0 10px var(--primary-cyan-glow)}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--spacing-lg)}.systems-section{margin-bottom:var(--spacing-xl)}.sidebar{position:fixed;left:0;top:0;width:var(--sidebar-width);height:100vh;background:linear-gradient(180deg,color-mix(in srgb,var(--bg-medium) 88%,transparent) 0%,color-mix(in srgb,var(--bg-dark) 88%,transparent) 100% );backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-right:1px solid var(--card-border);display:flex;flex-direction:column;z-index:1000;transition:width var(--transition-normal)}.sidebar-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.logo{display:flex;align-items:center;gap:var(--spacing-md);text-decoration:none}.logo-icon{width:40px;height:40px;background:linear-gradient(135deg,var(--primary-cyan) 0%,var(--secondary-magenta) 100%);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:var(--shadow-glow-cyan)}.logo-text{font-family:var(--font-heading);font-size:1.5rem;font-weight:700;color:var(--text-primary);letter-spacing:2px}.logo-text span{color:var(--primary-cyan);text-shadow:0 0 10px var(--primary-cyan-glow)}.sidebar-nav{flex:1;padding:var(--spacing-md);overflow-y:auto}.nav-section{margin-bottom:var(--spacing-lg)}.nav-section-title{font-size:0.7rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;padding:var(--spacing-sm) var(--spacing-md);margin-bottom:var(--spacing-xs)}.nav-item{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);border-radius:var(--radius-md);color:var(--text-secondary);text-decoration:none;transition:all var(--transition-fast);margin-bottom:var(--spacing-xs);position:relative;overflow:hidden}.nav-item::before{content:'';position:absolute;left:0;top:0;height:100%;width:3px;background:var(--primary-cyan);transform:scaleY(0);transition:transform var(--transition-fast)}.nav-item:hover{background:var(--card-bg-hover);color:var(--text-primary)}.nav-item:hover::before{transform:scaleY(1)}.nav-item.active{background:linear-gradient(90deg,rgba(76,201,240,0.15) 0%,transparent 100%);color:var(--primary-cyan);border-left:3px solid var(--primary-cyan)}.nav-item.active::before{transform:scaleY(1)}.nav-icon{width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:1.1rem}.nav-text{font-weight:500;font-size:0.95rem}.sidebar-footer{padding:var(--spacing-md);border-top:1px solid var(--card-border)}.version-info{font-size:0.75rem;color:var(--primary-cyan);text-align:center;font-family:var(--font-mono);opacity:0.8;text-shadow:0 0 8px rgba(76,201,240,0.4);display:flex;align-items:center;justify-content:center;gap:0.5rem;transition:all var(--transition-fast);padding:0.5rem;border-radius:var(--radius-md);background:transparent;border:none;width:100%;cursor:pointer}.version-info:hover{background:rgba(76,201,240,0.1);color:var(--primary-cyan)}.version-icon{font-size:1rem}.upscale-status{display:flex;align-items:center;gap:0.5rem;margin-bottom:var(--spacing-sm);padding:0.5rem;font-size:0.75rem;color:var(--text-secondary);border:1px solid var(--card-border);border-radius:var(--radius-md)}.upscale-status[hidden]{display:none}.upscale-status-icon{font-size:1rem;color:var(--primary-cyan)}.user-indicator{display:flex;align-items:center;justify-content:space-between;gap:0.5rem;padding:0.75rem;background:rgba(76,201,240,0.05);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-top:var(--spacing-md)}.user-indicator-info{display:flex;align-items:center;gap:0.75rem;min-width:0;flex:1}.user-indicator-avatar{font-size:1.25rem;flex-shrink:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center}.user-avatar-img{width:100%;height:100%;border-radius:50%;object-fit:cover}.user-indicator-details{display:flex;flex-direction:column;min-width:0}.user-indicator-name{font-size:0.85rem;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.user-indicator-role{font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:0.1rem 0.4rem;border-radius:var(--radius-full);width:fit-content}.user-indicator-role.admin{background:rgba(168,85,247,0.2);color:var(--neon-purple)}.user-indicator-role.editor{background:rgba(34,197,94,0.2);color:var(--neon-green)}.user-indicator-role.viewer{background:rgba(245,158,11,0.2);color:var(--neon-orange)}.user-indicator-meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:2px}.user-indicator-rank{font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.4px;padding:2px 8px;border-radius:var(--radius-full);text-decoration:none;white-space:nowrap;border:1px solid transparent;transition:all var(--transition-fast);line-height:1;display:inline-flex;align-items:center;gap:3px}.user-indicator-rank:hover{transform:translateY(-1px);filter:brightness(1.15)}.user-indicator-rank.rank-tier-0{background:rgba(110,115,120,0.18);color:var(--text-muted);border-color:rgba(110,115,120,0.35)}.user-indicator-rank.rank-tier-1{background:rgba(154,160,166,0.18);color:var(--text-secondary);border-color:rgba(154,160,166,0.4)}.user-indicator-rank.rank-tier-2{background:rgba(76,201,240,0.12);color:var(--primary-cyan);border-color:rgba(76,201,240,0.3)}.user-indicator-rank.rank-tier-3{background:rgba(76,201,240,0.18);color:var(--primary-cyan);border-color:rgba(76,201,240,0.45)}.user-indicator-rank.rank-tier-4{background:rgba(34,197,94,0.15);color:var(--neon-green);border-color:rgba(34,197,94,0.4)}.user-indicator-rank.rank-tier-5{background:rgba(245,158,11,0.15);color:var(--neon-orange);border-color:rgba(245,158,11,0.45)}.user-indicator-rank.rank-tier-6{background:rgba(168,85,247,0.15);color:var(--neon-purple);border-color:rgba(168,85,247,0.4)}.user-indicator-rank.rank-tier-7{background:rgba(168,85,247,0.22);color:var(--neon-purple);border-color:rgba(168,85,247,0.55)}.user-indicator-rank.rank-tier-8{background:rgba(247,37,133,0.18);color:var(--secondary-magenta);border-color:rgba(247,37,133,0.5)}.user-indicator-rank.rank-tier-9{background:linear-gradient(135deg,rgba(247,37,133,0.3),rgba(168,85,247,0.3));color:var(--text-primary);border-color:var(--secondary-magenta);box-shadow:0 0 8px rgba(247,37,133,0.35)}.user-logout-btn{font-size:1.1rem;padding:0.25rem;opacity:0.6;transition:all var(--transition-fast);text-decoration:none;flex-shrink:0}.user-logout-btn:hover{opacity:1;transform:scale(1.1)}.skip-link{position:absolute;top:0;left:0;padding:0.75rem 1.25rem;background:var(--primary-cyan);color:var(--bg-darkest);font-family:var(--font-heading);font-weight:700;text-decoration:none;border-radius:0 0 6px 0;z-index:10000;transform:translateY(-150%);transition:transform 0.15s ease-out}.skip-link:focus{transform:translateY(0);outline:2px solid var(--secondary-magenta);outline-offset:2px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:var(--spacing-md) var(--spacing-lg);font-family:var(--font-primary);font-size:0.9rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;border:2px solid;border-radius:var(--radius-md);cursor:pointer;transition:all 0.3s ease;text-decoration:none;position:relative;overflow:hidden}.btn-primary{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan);box-shadow:0 0 15px rgba(76,201,240,0.5),0 0 30px rgba(76,201,240,0.3),inset 0 0 20px rgba(76,201,240,0.1);text-shadow:0 0 10px rgba(76,201,240,0.8)}.btn-primary:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 20px rgba(76,201,240,0.7),0 0 40px rgba(76,201,240,0.4),inset 0 0 25px rgba(76,201,240,0.15);transform:translateY(-2px)}.btn-secondary{background:rgba(148,163,184,0.1);border-color:var(--neon-gray);color:#94a3b8;box-shadow:0 0 10px rgba(100,116,139,0.3),0 0 20px rgba(100,116,139,0.2)}.btn-secondary:hover{background:rgba(148,163,184,0.2);border-color:#94a3b8;color:#cbd5e1;box-shadow:0 0 15px rgba(148,163,184,0.5),0 0 30px rgba(148,163,184,0.3)}.btn-success{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green);box-shadow:0 0 15px rgba(34,197,94,0.5),0 0 30px rgba(34,197,94,0.3),inset 0 0 20px rgba(34,197,94,0.1);text-shadow:0 0 10px rgba(34,197,94,0.8)}.btn-success:hover{background:rgba(34,197,94,0.25);box-shadow:0 0 20px rgba(34,197,94,0.7),0 0 40px rgba(34,197,94,0.4),inset 0 0 25px rgba(34,197,94,0.15);transform:translateY(-2px)}.btn-warning{background:rgba(245,158,11,0.15);border-color:var(--neon-orange);color:var(--neon-orange);box-shadow:0 0 15px rgba(245,158,11,0.5),0 0 30px rgba(245,158,11,0.3),inset 0 0 20px rgba(245,158,11,0.1);text-shadow:0 0 10px rgba(245,158,11,0.8)}.btn-warning:hover{background:rgba(245,158,11,0.25);box-shadow:0 0 20px rgba(245,158,11,0.7),0 0 40px rgba(245,158,11,0.4),inset 0 0 25px rgba(245,158,11,0.15);transform:translateY(-2px)}.btn-danger{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red);box-shadow:0 0 15px rgba(239,68,68,0.5),0 0 30px rgba(239,68,68,0.3),inset 0 0 20px rgba(239,68,68,0.1);text-shadow:0 0 10px rgba(239,68,68,0.8)}.btn-danger:hover{background:rgba(239,68,68,0.25);box-shadow:0 0 20px rgba(239,68,68,0.7),0 0 40px rgba(239,68,68,0.4),inset 0 0 25px rgba(239,68,68,0.15);transform:translateY(-2px)}.btn-sm{padding:var(--spacing-sm) var(--spacing-md);font-size:0.8rem}.btn-xs{padding:3px 8px;font-size:0.7rem;line-height:1.2}.btn-lg{padding:var(--spacing-lg) var(--spacing-xl);font-size:1rem}.btn-icon{font-size:1.1em}.btn:focus-visible{outline:none;box-shadow:0 0 0 2px rgba(76,201,240,0.5),0 0 15px rgba(76,201,240,0.3)}.btn:disabled,.btn[disabled]{opacity:0.4;cursor:not-allowed;pointer-events:none;box-shadow:none}input[type="file"]::file-selector-button{padding:8px 16px;margin-right:12px;background:rgba(76,201,240,0.15);border:2px solid var(--neon-cyan);border-radius:var(--radius-md);color:var(--neon-cyan);cursor:pointer;font-weight:600;font-family:var(--font-primary);text-transform:uppercase;letter-spacing:0.5px;font-size:0.85rem;box-shadow:0 0 15px rgba(76,201,240,0.5),0 0 30px rgba(76,201,240,0.3),inset 0 0 20px rgba(76,201,240,0.1);text-shadow:0 0 10px rgba(76,201,240,0.8);transition:all 0.3s ease}input[type="file"]::file-selector-button:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 20px rgba(76,201,240,0.7),0 0 40px rgba(76,201,240,0.4),inset 0 0 25px rgba(76,201,240,0.15)}.pagination{display:flex;justify-content:center;align-items:center;gap:var(--spacing-sm);margin-top:var(--spacing-xl)}.pagination-btn{padding:var(--spacing-sm) var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);cursor:pointer;transition:all var(--transition-fast)}.pagination-btn:hover{border-color:var(--primary-cyan);color:var(--primary-cyan)}.pagination-btn.active{background:var(--primary-cyan);border-color:var(--primary-cyan);color:var(--bg-darkest)}.pagination-btn:disabled{opacity:0.5;cursor:not-allowed}.back-to-top-btn{position:fixed;bottom:30px;right:30px;width:50px;height:50px;border-radius:50%;background:rgba(20,26,35,0.95);border:2px solid var(--neon-cyan);color:var(--neon-cyan);font-size:1.5rem;cursor:pointer;opacity:0;visibility:hidden;transform:translateY(20px);transition:opacity 0.3s ease,visibility 0.3s ease,transform 0.3s ease,background 0.3s ease,box-shadow 0.3s ease;z-index:9998;display:flex;align-items:center;justify-content:center;box-shadow:0 0 15px rgba(76,201,240,0.3),inset 0 0 10px rgba(76,201,240,0.1)}.back-to-top-btn.visible{opacity:1;visibility:visible;transform:translateY(0)}.back-to-top-btn:hover{background:rgba(76,201,240,0.2);box-shadow:0 0 25px rgba(76,201,240,0.5),inset 0 0 15px rgba(76,201,240,0.2);transform:translateY(-3px)}.back-to-top-btn:active{transform:translateY(0)}.back-to-top-icon{font-weight:bold;text-shadow:0 0 10px rgba(76,201,240,0.8)}.btn-group{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-md)}.btn-danger-outline{background:rgba(239,68,68,0.1);border:2px solid var(--neon-red);color:var(--neon-red);box-shadow:0 0 10px rgba(239,68,68,0.3),0 0 20px rgba(239,68,68,0.2);text-shadow:0 0 8px rgba(239,68,68,0.6)}.btn-danger-outline:hover{background:rgba(239,68,68,0.2);box-shadow:0 0 15px rgba(239,68,68,0.5),0 0 30px rgba(239,68,68,0.3)}.form-input,input[type="text"],input[type="number"],input[type="email"],input[type="password"],input[type="search"],input[type="date"],textarea{width:100%;padding:0.75rem 1rem;background:var(--bg-darker,#0d1117);border:1px solid var(--border-color,#2a3542);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:border-color 0.2s ease,box-shadow 0.2s ease}.form-input:focus,input[type="text"]:focus,input[type="number"]:focus,input[type="email"]:focus,input[type="password"]:focus,input[type="search"]:focus,input[type="date"]:focus,textarea:focus{outline:none;border-color:var(--neon-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15),0 0 15px rgba(76,201,240,0.2)}.form-input::placeholder,input::placeholder,textarea::placeholder{color:var(--text-muted)}select,.form-select{width:100%;padding:0.75rem 2.5rem 0.75rem 1rem;background:var(--bg-darker,#0d1117);border:1px solid var(--border-color,#2a3542);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 1rem center;transition:border-color 0.2s ease,box-shadow 0.2s ease}select:focus,.form-select:focus{outline:none;border-color:var(--neon-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15),0 0 15px rgba(76,201,240,0.2)}select:hover,.form-select:hover{border-color:var(--neon-cyan)}select option{background:var(--bg-medium);color:var(--text-primary);padding:0.5rem}.styled-select{appearance:none;-webkit-appearance:none;-moz-appearance:none;padding:0.75rem 2.5rem 0.75rem 1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 1rem center;transition:border-color 0.2s ease,box-shadow 0.2s ease}.styled-select:hover{border-color:var(--primary-cyan)}.styled-select:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.toggle-switch{position:relative;display:inline-block;width:52px;height:28px;flex-shrink:0}.toggle-switch input{opacity:0;width:0;height:0}.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--neon-red);border-radius:28px;transition:all 0.3s ease;box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(239,68,68,0.3)}.toggle-slider:before{position:absolute;content:"";height:22px;width:22px;left:3px;bottom:3px;background:white;border-radius:50%;transition:all 0.3s ease;box-shadow:0 2px 4px rgba(0,0,0,0.3)}.toggle-switch input:checked + .toggle-slider{background:var(--neon-green);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 15px rgba(34,197,94,0.5)}.toggle-switch input:checked + .toggle-slider:before{transform:translateX(24px)}.toggle-switch input:focus + .toggle-slider{box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 0 2px rgba(76,201,240,0.3)}.toggle-item{display:flex;align-items:center;gap:0.75rem}.toggle-label{color:var(--text-secondary);font-size:0.9rem;cursor:pointer;user-select:none;transition:color 0.2s ease}.toggle-label.active{color:var(--text-primary)}.toggle-field{display:flex;align-items:center;gap:var(--spacing-md)}.toggle-switch-wrapper{display:flex;align-items:center;gap:var(--spacing-sm)}.toggle-switch-wrapper .toggle-label-text{font-size:0.85rem;color:var(--text-secondary)}.form-group{margin-bottom:var(--spacing-md)}.form-label{display:block;font-size:0.85rem;font-weight:600;color:var(--text-secondary);margin-bottom:var(--spacing-xs)}.filter-select{padding:var(--spacing-md) var(--spacing-lg);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;transition:all var(--transition-fast);min-width:150px}.filter-select:focus{outline:none;border-color:var(--primary-cyan)}.locked-path-wrapper{display:flex;align-items:center;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);overflow:hidden;flex:1}.locked-path-wrapper:focus-within{border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.locked-path-prefix{padding:0.75rem 0 0.75rem 1rem;color:var(--text-muted);font-family:var(--font-mono);font-size:0.9rem;background:rgba(76,201,240,0.05);border-right:1px solid var(--border-color);white-space:nowrap;user-select:none}.locked-path-wrapper input{flex:1;padding:0.75rem 1rem;background:transparent;border:none;color:var(--text-primary);font-family:var(--font-mono);font-size:0.9rem;outline:none}.locked-path-wrapper input::placeholder{color:var(--text-muted)}.edit-section{margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.edit-section:last-child{border-bottom:none;margin-bottom:0}.edit-section-title{color:var(--primary-cyan);font-family:var(--font-heading);font-size:1rem;font-weight:600;margin-bottom:var(--spacing-lg);padding-bottom:var(--spacing-sm);border-bottom:1px solid rgba(76,201,240,0.3)}.edit-field{margin-bottom:var(--spacing-md)}.edit-field label{display:block;color:var(--text-primary);font-weight:500;font-size:0.9rem;margin-bottom:6px}.edit-field .field-tip{display:block;font-size:0.75rem;color:var(--text-muted);margin-top:4px;font-style:italic}.edit-field .title-lock-check,.gem-field .title-lock-check{display:flex;align-items:center;gap:var(--spacing-xs);margin:var(--spacing-xs) 0 0;font-size:0.8rem;font-weight:normal;color:var(--text-muted);text-transform:none;letter-spacing:normal;cursor:pointer}.edit-field .title-lock-check input,.gem-field .title-lock-check input{width:auto;padding:0;margin:0}.edit-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md) var(--spacing-lg)}.edit-grid .full-width{grid-column:1 / -1}.form-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-md)}.form-help{font-size:0.8rem;color:var(--text-muted);margin-top:var(--spacing-xs)}.toggle-grid{display:flex;flex-wrap:wrap;gap:1.5rem}.card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);transition:all var(--transition-normal)}.card:hover{border-color:var(--card-border-hover);box-shadow:var(--shadow-lg)}.card + .card{margin-top:1.5rem}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-md);border-bottom:1px solid var(--card-border)}.card-title{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:0.5rem;text-transform:uppercase;letter-spacing:1px}.card-body{color:var(--text-secondary)}.glass-card,.card.glass,.glass-panel{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(76,201,240,0.2)}.glass-light,.glass-panel-light{background:rgba(20,26,35,0.6) !important;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}.glass-medium,.glass-panel-medium{background:rgba(20,26,35,0.8) !important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}.glass-dark,.glass-panel-dark{background:rgba(20,26,35,0.9) !important;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.glass-panel-hover:hover{background:rgba(20,26,35,0.85);border-color:var(--card-border-hover);box-shadow:var(--shadow-lg),0 0 20px rgba(76,201,240,0.1)}.system-card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);text-decoration:none;transition:all var(--transition-normal);display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;overflow:hidden}.system-card::before{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(180deg,transparent 60%,rgba(76,201,240,0.05) 100%);opacity:0;transition:opacity var(--transition-normal)}.system-card:hover{border-color:var(--primary-cyan);transform:translateY(-4px);box-shadow:var(--shadow-lg),0 0 20px rgba(76,201,240,0.1)}.system-card:hover::before{opacity:1}.system-logo{width:80px;height:80px;margin-bottom:var(--spacing-md);display:flex;align-items:center;justify-content:center}.system-logo img{max-width:100%;max-height:100%;object-fit:contain;filter:brightness(0.9);transition:all var(--transition-normal)}.system-card:hover .system-logo img{filter:brightness(1) drop-shadow(0 0 8px var(--primary-cyan-glow));transform:scale(1.05)}.system-logo-placeholder{width:80px;height:80px;background:var(--bg-lighter);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:2.5rem;color:var(--text-muted)}.system-name{font-family:var(--font-heading);font-size:1rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-xs);text-transform:uppercase;letter-spacing:1px}.system-info{display:flex;align-items:center;gap:var(--spacing-xs);font-size:0.8rem;color:var(--text-secondary)}.system-info-icon{color:var(--primary-cyan)}.section{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.status-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.status-item{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md) 0;border-bottom:1px solid var(--card-border)}.status-item:last-child{border-bottom:none}.status-info{display:flex;align-items:center;gap:var(--spacing-md)}.status-indicator{width:10px;height:10px;border-radius:50%;animation:pulse-status 2s ease-in-out infinite}.status-indicator.online{background:var(--status-online);box-shadow:0 0 10px var(--status-online)}.status-indicator.offline{background:var(--status-offline);box-shadow:0 0 10px var(--status-offline);animation:none}.status-name{color:var(--text-primary);font-weight:500}.status-badge{padding:var(--spacing-xs) var(--spacing-md);border-radius:var(--radius-full);font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:1px}.status-badge.online{background:rgba(0,230,118,0.15);color:var(--status-online);border:1px solid rgba(0,230,118,0.3)}.status-badge.connected{background:rgba(76,201,240,0.15);color:var(--primary-cyan);border:1px solid rgba(76,201,240,0.3)}.status-badge.offline{background:rgba(255,82,82,0.15);color:var(--status-offline);border:1px solid rgba(255,82,82,0.3)}.scraper-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-xl);margin-bottom:var(--spacing-xl)}.scraper-section h2{font-family:var(--font-heading);font-size:1.2rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-lg);padding-bottom:var(--spacing-md);border-bottom:1px solid var(--card-border)}.scraper-form{margin-bottom:var(--spacing-lg)}.system-controller-card{transition:border-color 0.2s ease,box-shadow 0.2s ease}.system-controller-card:hover{border-color:rgba(76,201,240,0.3);box-shadow:0 0 10px rgba(76,201,240,0.1)}.controller-item{transition:background 0.2s ease,transform 0.15s ease}.controller-item:hover{background:rgba(76,201,240,0.1) !important;transform:translateX(4px)}.controller-details{border-left:2px solid var(--neon-cyan)}.add-controller-form{border:1px solid rgba(34,197,94,0.2)}.add-controller-form:hover{border-color:rgba(34,197,94,0.3)}.badge{display:inline-flex;align-items:center;gap:0.5rem;padding:0.35rem 0.75rem;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-full);font-size:0.8rem;font-weight:500;color:var(--neon-cyan);white-space:nowrap}.badge-success{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green)}.badge-warning{background:rgba(245,158,11,0.15);border-color:var(--neon-orange);color:var(--neon-orange)}.badge-danger{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red)}.badge-purple{background:rgba(168,85,247,0.15);border-color:var(--neon-purple);color:var(--neon-purple)}.badge-secondary{background:rgba(100,116,139,0.15);border-color:var(--neon-gray);color:#94a3b8}.badge-removable{padding-right:0.5rem}.badge-remove{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;margin-left:0.25rem;background:transparent;border:none;border-radius:50%;color:inherit;cursor:pointer;font-size:1rem;line-height:1;opacity:0.7;transition:all 0.2s ease}.badge-remove:hover{opacity:1;background:rgba(255,255,255,0.1)}.game-badge{display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 10px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;border:1px solid;white-space:nowrap;line-height:1;height:28px;box-sizing:border-box}.game-badge.esrb-e{background:rgba(32,178,170,0.15);border-color:#20b2aa;color:#20b2aa}.game-badge.esrb-e10{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.esrb-t{background:rgba(234,179,8,0.15);border-color:#eab308;color:#eab308}.game-badge.esrb-m{background:rgba(249,115,22,0.15);border-color:#f97316;color:#f97316}.game-badge.esrb-ao{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}.game-badge.esrb-rp{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.pegi-3{background:rgba(32,178,170,0.15);border-color:#20b2aa;color:#20b2aa}.game-badge.pegi-7{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.pegi-12{background:rgba(234,179,8,0.15);border-color:#eab308;color:#eab308}.game-badge.pegi-16{background:rgba(249,115,22,0.15);border-color:#f97316;color:#f97316}.game-badge.pegi-18{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}.game-badge.age-rating-badge{background:transparent;border-color:transparent;color:var(--text-secondary);padding:0}.game-badge.age-rating-badge .age-rating-img{height:28px;width:auto;display:block;filter:drop-shadow(0 1px 3px rgba(0,0,0,0.4))}.game-badge.age-rating-badge.crossmapped{opacity:0.75}.game-badge.genre{background:rgba(168,85,247,0.15);border-color:#a855f7;color:#a855f7}.game-badge.year{background:rgba(76,201,240,0.15);border-color:#4cc9f0;color:#4cc9f0}.game-badge.platform{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.region{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.players{background:rgba(59,130,246,0.15);border-color:#3b82f6;color:#3b82f6}.game-badge.completion{font-size:0.7rem}.game-badge.completion.not-started{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.completion.in-progress{background:rgba(245,158,11,0.15);border-color:#f59e0b;color:#f59e0b}.game-badge.completion.played{background:rgba(59,130,246,0.15);border-color:#3b82f6;color:#3b82f6}.game-badge.completion.completed{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.completion.\31 00-percent,.game-badge.completion.hundred-percent{background:linear-gradient(135deg,rgba(255,215,0,0.25) 0%,rgba(255,170,0,0.25) 100%);border-color:#ffd700;color:#ffd700;text-shadow:0 0 8px rgba(255,215,0,0.5);box-shadow:0 0 10px rgba(255,215,0,0.3),inset 0 0 10px rgba(255,215,0,0.1)}.game-badge.score-badge{font-weight:700;min-width:50px}.game-badge.score-badge.score-good{background:rgba(34,197,94,0.2);border-color:#22c55e;color:#22c55e}.game-badge.score-badge.score-mixed{background:rgba(234,179,8,0.2);border-color:#eab308;color:#fcd34d}.game-badge.score-badge.score-bad{background:rgba(239,68,68,0.2);border-color:#ef4444;color:#f87171}.meta-badge{padding:var(--spacing-xs) var(--spacing-md);border-radius:var(--radius-full);font-size:0.8rem;font-weight:600}.meta-badge.system{background:linear-gradient(135deg,var(--primary-cyan),var(--primary-cyan-dark));color:var(--bg-darkest)}.meta-badge.year{background:var(--bg-lighter);color:var(--text-primary);border:1px solid var(--card-border)}.meta-badge.genre{background:rgba(114,9,183,0.2);color:#b388ff;border:1px solid rgba(114,9,183,0.3)}.metadata-status{display:inline-flex;align-items:center;gap:var(--spacing-xs);padding:var(--spacing-xs) var(--spacing-sm);border-radius:var(--radius-full);font-size:0.7rem;font-weight:600;text-transform:uppercase}.metadata-status.complete{background:rgba(0,230,118,0.15);color:var(--status-success)}.metadata-status.missing{background:rgba(255,171,0,0.15);color:var(--status-warning)}.source-badge{font-weight:600;text-transform:uppercase}.source-badge.thegamesdb{background:rgba(76,175,80,0.2);color:#81c784}.source-badge.igdb{background:rgba(33,150,243,0.2);color:#64b5f6}.source-badge.primary-source{border:2px solid currentColor;position:relative}.source-badge.primary-source::before{content:"★ ";font-size:0.8em}.source-badge.fallback-source{opacity:0.75;border:1px dashed currentColor}.psn-platform-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:var(--radius-sm);font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;border:1px solid;width:fit-content}.platform-ps3{background:rgba(205,154,42,0.15);color:#cd9a2a;border-color:rgba(205,154,42,0.3)}.platform-ps4{background:rgba(0,112,209,0.15);color:#4da6ff;border-color:rgba(0,112,209,0.3)}.platform-ps5{background:rgba(76,201,240,0.15);color:#4cc9f0;border-color:rgba(76,201,240,0.3)}.platform-psvita,.platform-vita{background:rgba(168,85,247,0.15);color:#a855f7;border-color:rgba(168,85,247,0.3)}.data-table{width:100%;border-collapse:collapse}@media (max-width:600px){.page-box .data-table{display:block;overflow-x:auto}}@media (max-width:600px){.page-box .data-table.stack-narrow,.data-table.stack-narrow tbody,.data-table.stack-narrow tr,.data-table.stack-narrow td{display:block;overflow-x:visible}.data-table.stack-narrow thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}.data-table.stack-narrow tr{margin-bottom:var(--spacing-sm);padding:var(--spacing-xs) 0;border:1px solid var(--card-border);border-radius:var(--radius-md)}.data-table.stack-narrow tr[hidden]{display:none}.data-table.stack-narrow td{padding:var(--spacing-xs) var(--spacing-sm);border-bottom:none;overflow-wrap:anywhere}.data-table.stack-narrow td[data-label]{display:flex;gap:var(--spacing-sm)}.data-table.stack-narrow td[data-label]::before{content:attr(data-label);flex:0 0 35%;color:var(--text-secondary);font-size:0.8rem;font-weight:600}}.data-table th{background:var(--bg-darker,#0d1117);color:var(--text-secondary);font-weight:600;text-align:left;padding:0.75rem 1rem;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid var(--card-border);position:sticky;top:0;z-index:10}.data-table td{padding:0.75rem 1rem;border-bottom:1px solid var(--card-border);color:var(--text-primary);font-size:0.9rem}.data-table tbody tr:hover{background:rgba(76,201,240,0.05)}.games-table-container{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);overflow:hidden}.games-table{width:100%;border-collapse:collapse}.games-table thead{background:linear-gradient(90deg,var(--bg-medium) 0%,var(--bg-light) 100%)}.games-table th{padding:var(--spacing-md) var(--spacing-lg);text-align:left;font-family:var(--font-heading);font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;border-bottom:1px solid var(--card-border)}.games-table tbody tr{border-bottom:1px solid var(--card-border);transition:background-color var(--transition-fast)}.games-table tbody tr:hover{background:var(--card-bg-hover)}.games-table tbody tr:last-child{border-bottom:none}.games-table td{padding:var(--spacing-md) var(--spacing-lg);vertical-align:middle}.game-row-boxart{width:60px;height:80px;object-fit:cover;border-radius:var(--radius-sm);border:1px solid var(--card-border)}.game-row-boxart.boxart-3d{object-fit:contain;background:var(--bg-medium)}.game-row-boxart-placeholder{width:60px;height:80px;background:var(--bg-lighter);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:1.5rem}.game-row-title{color:var(--text-primary);font-weight:600;text-decoration:none;transition:color var(--transition-fast)}.game-row-title:hover{color:var(--primary-cyan)}.game-row-system{color:var(--text-secondary);font-size:0.85rem}.game-row-genre{color:var(--text-secondary);font-size:0.85rem}.game-row-year{color:var(--text-secondary);font-size:0.85rem;font-family:var(--font-mono)}.score-list-section{background:rgba(0,0,0,0.2);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.score-list-section h4{color:var(--text-primary);font-family:var(--font-heading);font-size:1.1rem;margin-bottom:var(--spacing-md)}.score-list{display:flex;flex-direction:column;gap:var(--spacing-sm)}.score-list-item{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);text-decoration:none;transition:var(--transition-fast)}.score-list-item:hover{border-color:var(--primary-cyan);background:var(--card-bg-hover)}.score-game-info{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}.score-game-title{color:var(--text-primary);font-weight:600;font-size:0.95rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.score-game-system{color:var(--text-muted);font-size:0.8rem}.score-game-scores{display:flex;gap:var(--spacing-sm);flex-shrink:0}.score-lists-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-xl);margin-top:var(--spacing-xl)}.bulk-filter-section{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.bulk-action-section{padding:var(--spacing-lg);background:var(--bg-dark);border-radius:var(--radius-md);border:1px solid var(--card-border)}.bulk-preview-count{font-size:1.5rem;font-weight:700;color:var(--primary-cyan);margin-bottom:var(--spacing-sm)}.bulk-game-list{max-height:300px;overflow-y:auto;margin-top:var(--spacing-md)}.bulk-game-item{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.bulk-game-item:last-child{border-bottom:none}.tool-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.tool-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.tool-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.tool-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.tool-tab .tab-icon{font-size:1.1rem}.tool-tab .tab-label{}.system-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.system-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.625rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-align:center;white-space:nowrap}.system-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.system-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan);font-weight:600}.system-tab .tab-count{font-size:0.75rem;padding:0.15rem 0.5rem;background:rgba(0,0,0,0.3);border-radius:var(--radius-full);color:var(--text-secondary)}.system-tab.active .tab-count{background:var(--primary-cyan);color:var(--bg-darkest)}@media (max-width:768px){.tool-tabs,.system-tabs{gap:var(--spacing-xs);padding:var(--spacing-xs)}.tool-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.tool-tab .tab-label{display:none}.tool-tab .tab-icon{font-size:1.25rem}.system-tab{padding:0.5rem 0.75rem;font-size:0.8rem}}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:1000;opacity:0;visibility:hidden;transition:all 0.3s ease}.modal-overlay.active{opacity:1;visibility:visible}.modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);max-width:90%;max-height:90vh;overflow:auto;box-shadow:0 0 50px rgba(76,201,240,0.2)}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.modal-header h2{font-family:var(--font-heading);font-size:1.25rem;color:var(--neon-cyan)}.modal-close,.custom-modal-close,.about-modal-close,.game-detail-modal-close,.game-edit-modal-close,.modal-close-btn,.archive-modal-close,.modal .modal-close{background:none;border:none;font-size:1.75rem;font-weight:normal;color:var(--text-secondary);cursor:pointer;padding:0;line-height:1;transition:color 0.2s}.modal-close:hover,.custom-modal-close:hover,.about-modal-close:hover,.game-detail-modal-close:hover,.game-edit-modal-close:hover,.modal-close-btn:hover,.archive-modal-close:hover,.modal .modal-close:hover{color:var(--neon-red)}.modal-close:focus-visible,.custom-modal-close:focus-visible,.about-modal-close:focus-visible,.game-detail-modal-close:focus-visible,.game-edit-modal-close:focus-visible,.modal-close-btn:focus-visible,.archive-modal-close:focus-visible,.modal .modal-close:focus-visible{outline:2px solid rgba(76,201,240,0.5);outline-offset:2px}.modal-body{padding:var(--spacing-lg)}.modal-footer{display:flex;justify-content:flex-end;gap:var(--spacing-md);padding:var(--spacing-lg);border-top:1px solid var(--card-border)}.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:10000;align-items:center;justify-content:center}.modal.active{display:flex}.modal-content{max-width:90%;max-height:90%;border-radius:var(--radius-lg)}.modal-nav{position:absolute;top:50%;transform:translateY(-50%);width:50px;height:50px;background:var(--card-bg);border:none;border-radius:50%;color:var(--text-primary);font-size:1.5rem;cursor:pointer;transition:all var(--transition-fast)}.modal-nav:hover{background:var(--primary-cyan);color:var(--bg-darkest)}.modal-nav.prev{left:var(--spacing-lg)}.modal-nav.next{right:var(--spacing-lg)}.custom-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:10100;align-items:center;justify-content:center}.custom-modal.active{display:flex}.custom-modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);width:90%;max-width:500px;box-shadow:0 20px 60px rgba(0,0,0,0.5)}.custom-modal-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);display:flex;justify-content:space-between;align-items:center}.custom-modal-header h3{margin:0;font-family:var(--font-heading);color:var(--primary-cyan);font-size:1.1rem}.custom-modal-body{padding:var(--spacing-lg);color:var(--text-secondary);line-height:1.6}.custom-modal-body code{word-break:break-all;overflow-wrap:break-word;display:inline-block;max-width:100%}.custom-modal-body ul{max-width:100%;overflow:hidden}.custom-modal-body li{word-break:break-word;overflow-wrap:break-word}.custom-modal-footer{padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border);display:flex;justify-content:flex-end;gap:var(--spacing-sm)}.about-modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);z-index:10000;align-items:center;justify-content:center;backdrop-filter:blur(4px)}.about-modal-overlay.active{display:flex}.about-modal{background:linear-gradient(135deg,rgba(20,26,35,0.98),rgba(15,20,25,0.98));border:1px solid var(--primary-cyan);border-radius:var(--radius-lg);max-width:560px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:0 0 40px rgba(76,201,240,0.3),0 20px 40px rgba(0,0,0,0.5);animation:modalSlideIn 0.3s ease}.about-modal-header{display:flex;justify-content:space-between;align-items:center;padding:1.25rem 1.5rem;border-bottom:1px solid var(--card-border)}.about-modal-header h2{margin:0;font-size:1.25rem;font-family:var(--font-heading);color:var(--primary-cyan)}.about-modal-body{padding:1.5rem}.about-logo{display:flex;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.5rem;padding-bottom:1.5rem;border-bottom:1px solid var(--card-border)}.about-logo-icon{font-size:3rem;filter:drop-shadow(0 0 10px rgba(76,201,240,0.5))}.about-logo-text{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);letter-spacing:2px}.about-logo-text span{color:var(--primary-cyan);text-shadow:0 0 10px var(--primary-cyan-glow)}.about-details{display:flex;flex-direction:column;gap:0.75rem;margin-bottom:1.5rem}.about-item{display:flex;justify-content:space-between;align-items:center;padding:0.75rem;background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.about-item.about-description{flex-direction:column;align-items:flex-start;gap:0.5rem}.about-label{color:var(--text-secondary);font-size:0.85rem;font-weight:500}.about-value{color:var(--text-primary);font-weight:600}.about-description .about-value{font-weight:400;font-size:0.9rem;line-height:1.5}.about-links{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0.75rem}.folder-item{display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;border-radius:var(--radius-md);cursor:pointer;transition:background 0.15s ease;background:transparent;border:none;width:100%;text-align:left;font:inherit;color:inherit;appearance:none;-webkit-appearance:none}.folder-item:hover{background:rgba(76,201,240,0.1)}.folder-item.folder-parent{border-bottom:1px solid var(--border-color);margin-bottom:0.5rem;padding-bottom:0.75rem;border-radius:0}.folder-item .folder-icon{font-size:1.25rem}.folder-item .folder-name{flex:1;color:var(--text-primary);font-weight:500}.folder-item .folder-info{color:var(--text-muted);font-size:0.8rem}@keyframes toast-slide-in{from{transform:translateX(-100%);opacity:0}to{transform:translateX(0);opacity:1}}@keyframes toast-slide-out{from{transform:translateX(0);opacity:1}to{transform:translateX(-100%);opacity:0}}.unified-toast-container{position:fixed;bottom:24px;left:280px;z-index:10000;display:flex;flex-direction:column-reverse;gap:10px;width:360px}.sidebar-collapsed ~ .main-content .unified-toast-container,body:has(.sidebar.collapsed) .unified-toast-container{left:calc(var(--sidebar-collapsed-width, 70px) + 20px)}@media (max-width:768px){.unified-toast-container{left:16px;right:16px;width:auto;max-width:none}}.unified-toast{animation:toast-slide-in 0.3s ease-out forwards;animation-iteration-count:1;width:100%;max-width:360px;border-radius:var(--radius-lg);overflow:hidden;transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.unified-toast.removing{animation:toast-slide-out 0.3s ease-out forwards}.toast-content{display:flex;align-items:stretch;background:linear-gradient(135deg,var(--toast-bg-from,rgba(20,26,35,0.95)),var(--toast-bg-to,rgba(30,40,55,0.95)));border:2px solid var(--toast-color,var(--primary-cyan));border-radius:var(--radius-lg);box-shadow:0 0 20px rgba(var(--toast-color-rgb,76,201,240),0.3),0 4px 20px rgba(0,0,0,0.4);backdrop-filter:blur(10px)}.active-toast .toast-content{background:linear-gradient(135deg,var(--toast-bg-from,rgba(20,26,35,0.95)),var(--toast-bg-to,rgba(30,40,55,0.95)))}.queued-toast .toast-content{background:linear-gradient(135deg,var(--toast-queued-bg-from,rgba(25,30,40,0.90)),var(--toast-queued-bg-to,rgba(35,45,60,0.90)));opacity:0.9}.toast-main{flex:1;display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;transition:background var(--transition-fast);min-width:0}.toast-main:hover{background:rgba(var(--toast-color-rgb,76,201,240),0.08)}.toast-icon{font-size:1.5rem;flex-shrink:0}.toast-icon:not(.paused){animation:pulse 1.5s ease-in-out infinite}.toast-icon.paused{animation:none;opacity:0.7}.toast-info{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}.toast-title{font-weight:600;font-size:0.9rem;color:var(--toast-color,var(--primary-cyan))}.toast-title.paused{color:var(--neon-orange)}.toast-system-name{color:var(--neon-purple);font-size:0.75rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.toast-subtitle{color:var(--text-primary);font-size:0.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.toast-progress-bar{height:4px;background:rgba(var(--toast-color-rgb,76,201,240),0.2);border-radius:2px;overflow:hidden;margin:4px 0}.toast-progress-fill{height:100%;background:linear-gradient(90deg,var(--toast-color,var(--primary-cyan)),var(--secondary-magenta,#f72585));border-radius:2px;transition:width var(--transition-normal)}.toast-progress-text{display:flex;justify-content:space-between;font-size:0.75rem;color:var(--text-secondary);font-family:var(--font-mono)}.toast-stats{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:0.85rem;font-family:var(--font-mono);margin-top:2px}.toast-stats .stat{display:flex;align-items:center;gap:3px;white-space:nowrap}.toast-stats .stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700;color:var(--text-primary)}.toast-stats .stat.success,.toast-stats .stat-success{color:var(--neon-green)}.toast-stats .stat.failed,.toast-stats .stat-failed{color:var(--neon-red)}.toast-stats .stat.skipped,.toast-stats .stat-skipped{color:var(--neon-orange)}.toast-meta{font-size:0.75rem;color:var(--text-muted)}.toast-queued-info{font-size:0.75rem;color:var(--text-muted)}.toast-controls{display:flex;flex-direction:column;border-left:1px solid rgba(var(--toast-color-rgb,76,201,240),0.2);flex-shrink:0}.toast-btn,.toast-ctrl-btn{flex:1;padding:8px 10px;border:none;background:transparent;cursor:pointer;font-size:0.9rem;transition:all var(--transition-fast);color:var(--text-secondary);display:flex;align-items:center;justify-content:center;min-width:36px}.toast-btn:hover,.toast-ctrl-btn:hover{background:rgba(var(--toast-color-rgb,76,201,240),0.15);color:var(--toast-color,var(--primary-cyan))}.toast-btn.pause:hover,.toast-btn.pause.is-paused,.toast-ctrl-btn.pause:hover{background:rgba(245,158,11,0.2);color:var(--neon-orange)}.toast-btn.pause.is-paused{background:rgba(34,197,94,0.15);color:var(--neon-green)}.toast-btn.cancel:hover,.toast-ctrl-btn.cancel:hover{background:rgba(239,68,68,0.2);color:var(--neon-red)}.bulk-scrape-toast{--toast-color:var(--primary-cyan);--toast-color-rgb:76,201,240}.ra-sync-toast{--toast-color:var(--neon-orange);--toast-color-rgb:245,158,11}.ra-sync-toast .toast-main:hover{background:rgba(245,158,11,0.08)}.ra-refresh-toast{--toast-color:var(--neon-purple);--toast-color-rgb:168,85,247}.ra-refresh-toast .toast-main:hover{background:rgba(168,85,247,0.08)}.bulk-scrape-queued-toast{--toast-color:var(--primary-cyan-dark);--toast-color-rgb:58,159,194}.ra-sync-queued-toast{--toast-color:#c47d09;--toast-color-rgb:196,125,9}.ra-refresh-queued-toast{--toast-color:#8b46d4;--toast-color-rgb:139,70,212}.image-resize-toast{--toast-color:var(--neon-green);--toast-color-rgb:34,197,94}.image-resize-toast .toast-main:hover{background:rgba(34,197,94,0.08)}.toast-completed{--toast-color:var(--neon-green);--toast-color-rgb:34,197,94}.queued-toast{width:360px}.queued-toast .toast-content{padding:0}.queued-toast .toast-main{padding:10px 14px}.queued-toast .toast-icon{font-size:1.2rem}.queued-toast .toast-title{font-size:0.85rem}.queued-toast .toast-controls{flex-direction:row;min-width:36px}.queued-toast .toast-btn{min-width:36px;min-height:36px}.notification-container{position:fixed;top:20px;right:20px;z-index:10002;display:flex;flex-direction:column;gap:10px;max-width:400px}.notification{display:flex;align-items:center;gap:12px;padding:14px 18px;background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);box-shadow:var(--shadow-lg),0 0 20px rgba(0,0,0,0.3);transform:translateX(120%);opacity:0;transition:all var(--transition-normal)}.notification.show{transform:translateX(0);opacity:1}.notification-icon{font-size:1.2rem;flex-shrink:0}.notification-message{flex:1;color:var(--text-primary);font-size:0.9rem;line-height:1.4}.notification-close{background:none;border:none;color:var(--text-muted);font-size:1.2rem;cursor:pointer;padding:0 4px;opacity:0.6;transition:opacity 0.2s}.notification-close:hover{opacity:1;color:var(--text-primary)}.notification-success{border-color:var(--neon-green);box-shadow:var(--shadow-lg),0 0 15px rgba(34,197,94,0.2)}.notification-error{border-color:var(--neon-red);box-shadow:var(--shadow-lg),0 0 15px rgba(239,68,68,0.2)}.notification-warning{border-color:var(--neon-orange);box-shadow:var(--shadow-lg),0 0 15px rgba(245,158,11,0.2)}.notification-info{border-color:var(--primary-cyan);box-shadow:var(--shadow-lg),0 0 15px rgba(76,201,240,0.2)}.progress-container{background:#1a1a3e;border:2px solid var(--neon-cyan);border-radius:var(--radius-md);height:36px;overflow:hidden;position:relative;box-shadow:0 0 15px rgba(76,201,240,0.3)}.progress-bar{height:100%;background:linear-gradient(90deg,var(--neon-cyan),#7209b3);border-radius:calc(var(--radius-md) - 2px);display:flex;align-items:center;justify-content:center;color:white;font-size:0.9rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7);transition:width 0.3s ease;min-width:0}.progress-bar[data-percent="0"]{min-width:0;width:0%}.progress-bar:not([data-percent="0"]){min-width:60px}.progress-stats{display:flex;justify-content:space-between;color:var(--text-secondary);font-size:0.85rem;margin-top:0.5rem}.progress-container-sm{height:24px;border-width:1px}.progress-container-sm .progress-bar{font-size:0.75rem}.loading{display:flex;align-items:center;justify-content:center;padding:var(--spacing-2xl)}.loading-spinner{width:40px;height:40px;border:3px solid var(--card-border);border-top-color:var(--primary-cyan);border-radius:50%;animation:spin 1s linear infinite}.loading-spinner-small{width:20px;height:20px;border:2px solid var(--card-border);border-top-color:var(--primary-cyan);border-radius:50%;animation:spin 1s linear infinite}.loading-text{margin-left:var(--spacing-md);color:var(--text-secondary)}.loading-overlay{position:fixed;inset:0;background:rgba(10,14,23,0.85);display:flex;align-items:center;justify-content:center;z-index:10001;opacity:0;visibility:hidden;transition:all 0.3s ease}.loading-overlay.active{opacity:1;visibility:visible}.loading-content{display:flex;flex-direction:column;align-items:center;gap:16px}.loading-content .loading-text{color:var(--text-primary);font-size:1rem;font-family:var(--font-primary);margin-left:0}.empty-state{text-align:center;padding:var(--spacing-2xl);color:var(--text-secondary)}.empty-state-icon{font-size:4rem;margin-bottom:var(--spacing-md);opacity:0.5}.empty-state-title{font-size:1.2rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-sm)}.empty-state-text{margin-bottom:var(--spacing-lg)}.scan-log-container{background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);margin-top:var(--spacing-lg)}.scan-log-header{display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:var(--bg-dark);border-bottom:1px solid var(--border-color)}.scan-log-header h4{margin:0;font-size:0.9rem;color:var(--text-secondary);display:flex;align-items:center;gap:0.5rem}.scan-log-content{height:200px;overflow-y:auto;padding:0.75rem 1rem;font-family:var(--font-mono);font-size:0.8rem;line-height:1.6}.scan-log-content .log-entry{color:var(--text-secondary);padding:0.15rem 0}.scan-log-content .log-entry.info{color:var(--primary-cyan)}.scan-log-content .log-entry.warning{color:var(--neon-orange)}.scan-log-content .log-entry.error{color:var(--neon-red)}.rate-limits-container{background:var(--bg-darker,#0d1117);border:1px solid var(--card-border);border-radius:var(--radius-md);padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-md)}.rate-limit-header{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-muted);font-size:0.8rem;padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.rate-limit-item{display:flex;flex-direction:column;gap:var(--spacing-xs);padding:var(--spacing-md);background:rgba(255,255,255,0.02);border-radius:var(--radius-sm);border-left:3px solid var(--text-muted)}.rate-limit-item.status-green{border-left-color:var(--neon-green)}.rate-limit-item.status-yellow{border-left-color:var(--neon-orange)}.rate-limit-item.status-red{border-left-color:var(--neon-red)}.rate-limit-item.status-gray{border-left-color:var(--text-muted)}.rate-limit-item strong{color:var(--text-primary);font-size:0.95rem}.rate-limit-value{color:var(--text-secondary);font-size:0.9rem}.rate-limit-bar{width:100%;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden;margin:4px 0}.rate-limit-bar-fill{height:100%;border-radius:3px;transition:width 0.6s ease}.rate-limit-detail{color:var(--text-muted);font-size:0.8rem;line-height:1.5}.progress-bar-container{background:#1a1a3e;border:2px solid var(--neon-cyan);border-radius:var(--radius-md);height:36px;overflow:hidden;margin-bottom:0.5rem;box-shadow:0 0 15px rgba(76,201,240,0.3)}.progress-bar-fill{height:100%;background:linear-gradient(90deg,var(--neon-cyan),#7209b3);width:0%;transition:width 0.3s ease;display:flex;align-items:center;justify-content:center;color:white;font-size:0.9rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7);border-radius:calc(var(--radius-md) - 2px)}.progress-details{display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);margin-top:0.75rem}.progress-status{color:var(--text-secondary);font-size:0.9rem}.progress-status strong{color:var(--primary-cyan)}.scanner-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);padding:1.5rem;margin-bottom:1.5rem}.scanner-section h3{color:var(--text-primary);font-size:1rem;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem}.scanner-controls{display:flex;flex-wrap:wrap;gap:1rem;align-items:center}.quick-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.tag{display:inline-flex;align-items:center;gap:4px;padding:6px 12px;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-full);font-size:0.85rem;color:var(--text-secondary);white-space:nowrap}.tag.tag-system{background:rgba(76,201,240,0.15);border-color:rgba(76,201,240,0.4);color:var(--primary-cyan)}.tag.tag-date{background:rgba(168,85,247,0.15);border-color:rgba(168,85,247,0.4);color:#c084fc}.tag.tag-genre{background:rgba(34,197,94,0.15);border-color:rgba(34,197,94,0.4);color:#4ade80}.genre-select-container{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);min-height:44px}.genre-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);border-radius:var(--radius-full);color:var(--neon-purple);font-size:0.8rem;font-weight:500}.genre-tag .remove-genre{cursor:pointer;opacity:0.7;transition:opacity 0.15s}.genre-tag .remove-genre:hover{opacity:1}.genre-dropdown{position:relative}.genre-dropdown-menu{position:absolute;top:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);z-index:100;display:none}.genre-dropdown-menu.active{display:block}.genre-option{padding:8px 12px;cursor:pointer;transition:background 0.15s}.genre-option:hover{background:rgba(76,201,240,0.1)}.genre-option.selected{background:rgba(168,85,247,0.2);color:var(--neon-purple)}.controller-select-container{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);min-height:44px}.controller-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-full);color:var(--neon-cyan);font-size:0.8rem;font-weight:500}.controller-tag .remove-controller{cursor:pointer;opacity:0.7;transition:opacity 0.15s}.controller-tag .remove-controller:hover{opacity:1}.controller-dropdown{position:relative}.active-filter-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.active-filter-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(76,201,240,0.15);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-full);color:var(--primary-cyan);font-size:0.8rem;font-weight:500}.active-filter-tag .remove-filter{background:none;border:none;color:var(--primary-cyan);font-size:1rem;line-height:1;cursor:pointer;padding:0;opacity:0.7;transition:opacity 0.2s}.active-filter-tag .remove-filter:hover{opacity:1}.gem-tag-container{display:flex;flex-wrap:wrap;gap:var(--spacing-xs);min-height:36px;padding:var(--spacing-sm);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-xs)}.gem-tag-container:empty::before{content:'None selected';color:var(--text-muted);font-style:italic;font-size:0.85rem}.gem-tag-dropdown{width:100%;padding:8px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.9rem;cursor:pointer}.gem-tag-dropdown:hover,.gem-tag-dropdown:focus{border-color:var(--primary-cyan);outline:none}.gem-controller-inputs{display:flex;gap:var(--spacing-xs)}.gem-controller-inputs select{flex:1}.gem-custom-input{flex:1;padding:8px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.9rem}.gem-custom-input:hover,.gem-custom-input:focus{border-color:var(--primary-cyan);outline:none}#queueManagerModal .queue-item{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:10px;transition:all 0.2s ease}#queueManagerModal .queue-item:hover{border-color:var(--primary-cyan)}#queueManagerModal .queue-item.running{border-color:var(--primary-cyan);border-width:2px;background:rgba(76,201,240,0.05)}#queueManagerModal .queue-item.queued{border-color:var(--neon-orange)}#queueManagerModal .queue-item-icon{font-size:1.5rem;flex-shrink:0}#queueManagerModal .queue-item-info{flex:1;min-width:0}#queueManagerModal .queue-item-title{font-weight:600;color:var(--text-primary);margin-bottom:2px}#queueManagerModal .queue-item-system{font-size:0.85rem;color:var(--neon-purple)}#queueManagerModal .queue-item.queued .queue-item-system{color:var(--neon-orange)}#queueManagerModal .queue-item-games{font-size:0.8rem;color:var(--text-secondary)}#queueManagerModal .queue-item-progress{font-size:0.75rem;color:var(--primary-cyan);margin-top:4px}#queueManagerModal .queue-item-controls{display:flex;flex-direction:column;gap:4px}#queueManagerModal .queue-btn{padding:6px 10px;border:1px solid var(--card-border);border-radius:var(--radius-sm);background:var(--bg-medium);color:var(--text-secondary);cursor:pointer;font-size:0.8rem;transition:all 0.15s ease;white-space:nowrap}#queueManagerModal .queue-btn:hover{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}#queueManagerModal .queue-btn.danger:hover{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}#queueManagerModal .queue-btn:disabled{opacity:0.3;cursor:not-allowed}#queueManagerModal .queue-empty{text-align:center;color:var(--text-muted);padding:20px;font-style:italic}.launch-indicator{position:fixed;bottom:var(--spacing-lg);right:var(--spacing-lg);z-index:1000;display:inline-flex;align-items:center;gap:0.5rem;padding:0.4rem 0.9rem;background:var(--card-bg);border:1px solid var(--primary-cyan);border-radius:999px;box-shadow:0 0 12px rgba(76,201,240,0.35);color:var(--text-primary);font-family:var(--font-mono,monospace);font-size:0.85rem;cursor:pointer;transition:opacity 0.2s ease,transform 0.2s ease}.launch-indicator.hidden{opacity:0;pointer-events:none;transform:translateY(8px)}.launch-indicator-icon{color:var(--neon-green,#44ff66);font-weight:bold}.launch-indicator-count{font-weight:bold;color:var(--primary-cyan)}.launch-indicator-popover{position:fixed;bottom:calc(var(--spacing-lg) + 2.5rem);right:var(--spacing-lg);z-index:1001;min-width:280px;padding:var(--spacing-md);background:var(--card-bg);border:1px solid var(--primary-cyan);border-radius:var(--radius-md);box-shadow:0 0 20px rgba(76,201,240,0.4);color:var(--text-primary)}.launch-indicator-popover-title{font-family:var(--font-heading);font-size:0.95rem;color:var(--primary-cyan);margin-bottom:var(--spacing-sm);text-transform:uppercase;letter-spacing:0.05em}.launch-indicator-popover-row{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-sm);padding:0.4rem 0;border-top:1px solid var(--border-color,rgba(255,255,255,0.08));font-size:0.85rem}.launch-indicator-popover-row:first-of-type{border-top:none}.global-search{position:relative;margin-top:var(--spacing-md)}.global-search-input{width:100%;padding:var(--spacing-sm) var(--spacing-md);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.global-search-input::placeholder{color:var(--text-muted)}.global-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:var(--shadow-glow-cyan)}.global-search-list{position:absolute;top:calc(100% + var(--spacing-xs));left:0;right:0;z-index:1001;max-height:60vh;overflow-y:auto;background:var(--card-bg);border:1px solid var(--card-border-hover);border-radius:var(--radius-md);box-shadow:var(--shadow-lg);padding:var(--spacing-xs) 0}.global-search-group{padding:var(--spacing-xs) var(--spacing-sm);color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em}.global-search-option{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-xs) var(--spacing-sm);color:var(--text-primary);text-decoration:none;min-height:36px}.global-search-option:hover,.global-search-option[aria-selected="true"]{background:var(--card-bg-hover);color:var(--text-accent)}.global-search-thumb{flex:0 0 24px;height:32px;display:flex;align-items:center;justify-content:center}.global-search-thumb img{max-width:24px;max-height:32px;object-fit:contain}.global-search-text{flex:1;min-width:0;display:flex;flex-direction:column}.global-search-label{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.25}.global-search-meta{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:0.8rem}.global-search-empty{padding:var(--spacing-sm);color:var(--text-muted)}.game-card-wrapper{background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden;transition:all var(--transition-normal)}.game-card-wrapper:hover{border-color:var(--primary-cyan);box-shadow:0 0 20px rgba(76,201,240,0.2);transform:translateY(-2px)}.game-hero{display:grid;grid-template-columns:300px 1fr;gap:var(--spacing-xl);margin-bottom:var(--spacing-xl);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-xl);position:relative;overflow:hidden}.game-hero::before{content:'';position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta),var(--primary-cyan))}.hero-boxart{width:300px;flex-shrink:0;position:relative}.hero-boxart .game-boxart-image{width:100%;height:auto;border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);border:2px solid var(--card-border)}.hero-info{display:flex;flex-direction:column;min-width:0;flex:1}.game-boxart-container{position:relative}.game-boxart{width:100%;border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);border:2px solid var(--card-border)}.game-boxart-placeholder{width:100%;aspect-ratio:3/4;background:var(--bg-lighter);border-radius:var(--radius-lg);display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text-muted);font-size:4rem;border:2px dashed var(--card-border)}.game-boxart-placeholder span{font-size:1rem;margin-top:var(--spacing-md)}.game-info{display:flex;flex-direction:column}.game-title-section{margin-bottom:var(--spacing-lg)}.game-title{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);margin-bottom:var(--spacing-sm);line-height:1.2}.game-meta-badges{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.game-ratings{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.rating-display{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm) var(--spacing-md);background:var(--bg-lighter);border-radius:var(--radius-md);border:1px solid var(--card-border)}.rating-display img{height:32px}.rating-display .rating-text{font-weight:600;font-size:0.9rem}.rating-display.esrb{border-color:var(--rating-esrb-border);background:var(--rating-esrb-bg)}.rating-display.esrb .rating-text{color:var(--rating-esrb)}.rating-display.pegi{border-color:var(--rating-pegi-border);background:var(--rating-pegi-bg)}.rating-display.pegi .rating-text{color:var(--rating-pegi)}.game-details-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.detail-item{display:flex;flex-direction:column;gap:var(--spacing-xs)}.detail-label{font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px}.detail-value{color:var(--text-primary);font-size:0.95rem}.game-description{margin-bottom:var(--spacing-lg)}.game-description h3{font-family:var(--font-heading);font-size:1rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-sm)}.game-description p{color:var(--text-secondary);line-height:1.7}.screenshots-section{margin-bottom:var(--spacing-xl)}.screenshots-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:var(--spacing-md)}.screenshot-item{position:relative;aspect-ratio:16/9;border-radius:var(--radius-md);overflow:hidden;cursor:pointer;border:2px solid var(--card-border);transition:all var(--transition-fast)}.screenshot-item:hover{border-color:var(--primary-cyan);transform:scale(1.02)}.screenshot-item img{width:100%;height:100%;object-fit:cover}.screenshot-item::after{content:'🔍';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:2rem;opacity:0;transition:opacity var(--transition-fast)}.screenshot-item:hover::after{opacity:1}.search-results{margin-top:var(--spacing-lg)}.search-result{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm);transition:all var(--transition-fast)}.search-result:hover{border-color:var(--primary-cyan);background:var(--bg-light)}.search-result-info h4{color:var(--text-primary);font-weight:600;margin-bottom:var(--spacing-xs)}.search-result-meta{display:flex;gap:var(--spacing-sm);font-size:0.8rem}.search-result-meta span{padding:2px 8px;background:var(--bg-lighter);border-radius:var(--radius-sm);color:var(--text-secondary)}.search-result-alts{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:var(--spacing-xs);font-size:0.78rem}.search-result-alts-label{color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;font-size:0.7rem;font-weight:600}.game-detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(10,14,23,0.9);backdrop-filter:blur(4px);z-index:10000;align-items:center;justify-content:center;padding:var(--spacing-lg)}.game-detail-modal.active{display:flex}.game-detail-modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);width:100%;max-width:800px;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 0 40px rgba(0,0,0,0.5),0 0 80px rgba(76,201,240,0.1)}.game-detail-modal-close{position:absolute;top:var(--spacing-md);right:var(--spacing-md);z-index:10}.game-detail-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:var(--spacing-xl);min-height:300px;color:var(--text-secondary)}.game-detail-content{padding:var(--spacing-lg)}.gdm-header{display:flex;gap:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.gdm-boxart-container{flex-shrink:0;width:180px}.gdm-boxart{width:100%;border-radius:var(--radius-md);box-shadow:0 4px 20px rgba(0,0,0,0.3)}.gdm-info{flex:1}.gdm-title{font-family:var(--font-heading);font-size:1.5rem;color:var(--primary-cyan);margin:0 0 var(--spacing-sm) 0}.gdm-meta{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.gdm-system-badge{background:var(--primary-cyan);color:var(--bg-darkest);padding:2px 8px;border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600}.gdm-year,.gdm-players{background:rgba(var(--toast-color-rgb,76,201,240),0.15);color:var(--text-primary);padding:2px 8px;border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600;border:1px solid rgba(var(--toast-color-rgb,76,201,240),0.25)}.gdm-ratings{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.gdm-ratings .rating-badge{display:inline-block;padding:4px 8px;border-radius:var(--radius-sm);font-size:0.75rem;font-weight:600}.gdm-ratings .rating-badge.esrb{background:rgba(168,85,247,0.2);color:var(--neon-purple);border:1px solid rgba(168,85,247,0.3)}.gdm-ratings .rating-badge.score{background:rgba(34,197,94,0.2);color:var(--neon-green);border:1px solid rgba(34,197,94,0.3)}.gdm-completion .completion-badge{display:inline-block;padding:4px 10px;border-radius:var(--radius-sm);font-size:0.75rem;font-weight:600}.gdm-completion .status-in_progress,.gdm-completion .status-in-progress{background:rgba(76,201,240,0.2);color:var(--primary-cyan);border:1px solid rgba(76,201,240,0.3)}.gdm-completion .status-completed{background:rgba(34,197,94,0.2);color:var(--neon-green);border:1px solid rgba(34,197,94,0.3)}.gdm-completion .status-played{background:rgba(59,130,246,0.2);color:#3b82f6;border:1px solid rgba(59,130,246,0.3)}.gdm-completion .status-100_percent,.gdm-completion .status-100-percent{background:linear-gradient(135deg,rgba(245,158,11,0.2),rgba(247,37,133,0.2));color:var(--completion-gold);border:1px solid rgba(245,158,11,0.3)}.gdm-description{color:var(--text-secondary);line-height:1.6;margin-bottom:var(--spacing-lg);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.gdm-details-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.gdm-detail{display:flex;flex-direction:column}.gdm-detail .label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px}.gdm-detail span:last-child{color:var(--text-primary)}.gdm-achievements{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.ra-badge-modal{background:linear-gradient(135deg,var(--neon-orange),#d97706);color:white;padding:6px 12px;border-radius:var(--radius-md);font-weight:600}.gdm-achievements .game-badge.achievement-progress{font-size:0.75rem;padding:4px 10px}.gdm-achievements .ach-icon{width:16px;height:16px;vertical-align:middle}.gdm-achievements .ach-bar{display:inline-block;width:40px;height:4px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;vertical-align:middle;margin-left:4px}.gdm-achievements .ach-fill{display:block;height:100%;border-radius:2px;transition:width 0.3s ease}.gdm-screenshots{display:flex;gap:var(--spacing-sm);overflow-x:auto;padding-bottom:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.gdm-screenshot{height:80px;border-radius:var(--radius-sm);cursor:pointer;transition:transform var(--transition-fast)}.gdm-screenshot:hover{transform:scale(1.05)}.gdm-video{margin-bottom:var(--spacing-lg)}.gdm-video video{width:100%;max-height:300px;border-radius:var(--radius-md);background:#000}.gdm-lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.95);z-index:10002;align-items:center;justify-content:center;flex-direction:column}.gdm-lightbox.active{display:flex}.gdm-lightbox-image{max-width:90%;max-height:80vh;object-fit:contain;border-radius:var(--radius-md)}.gdm-lightbox-close{position:absolute;top:20px;right:35px;font-size:2.5rem;color:var(--text-primary);cursor:pointer;z-index:10003;transition:color var(--transition-fast);background:none;border:none;line-height:1}.gdm-lightbox-close:hover{color:var(--primary-cyan)}.gdm-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.5);border:2px solid var(--card-border);color:var(--text-primary);font-size:3rem;padding:10px 20px;cursor:pointer;border-radius:var(--radius-md);transition:all var(--transition-fast);z-index:10003}.gdm-lightbox-nav:hover{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.gdm-lightbox-nav.prev{left:20px}.gdm-lightbox-nav.next{right:20px}.gdm-lightbox-counter{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);color:var(--text-secondary);font-size:0.9rem;font-family:var(--font-mono)}.gdm-lightbox-hint{position:absolute;bottom:45px;left:50%;transform:translateX(-50%);color:var(--text-muted);font-size:0.75rem;white-space:nowrap}.gdm-actions{display:flex;gap:var(--spacing-md);padding-top:var(--spacing-md);border-top:1px solid var(--card-border)}.game-edit-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(10,14,23,0.95);backdrop-filter:blur(4px);z-index:10001;align-items:center;justify-content:center;padding:var(--spacing-lg)}.game-edit-modal.active{display:flex}.game-edit-modal-content{background:var(--bg-medium);border:1px solid var(--primary-cyan);border-radius:var(--radius-lg);width:100%;max-width:700px;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 0 40px rgba(0,0,0,0.5),0 0 60px rgba(76,201,240,0.15)}.game-edit-modal-close{position:absolute;top:var(--spacing-md);right:var(--spacing-md);z-index:10}.gem-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.gem-title{font-family:var(--font-heading);font-size:1.25rem;color:var(--primary-cyan);margin:0;padding-right:2rem}.gem-body{padding:var(--spacing-lg);display:grid}.gem-section{margin-bottom:var(--spacing-lg)}.gem-section:last-child{margin-bottom:0}.gem-label{display:block;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-sm)}.gem-select{width:100%;padding:10px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:1rem;cursor:pointer;transition:border-color var(--transition-fast)}.gem-select:hover,.gem-select:focus{border-color:var(--primary-cyan);outline:none}.gem-hltb-display{padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.gem-hltb-empty{color:var(--text-muted);font-style:italic}.gem-hltb-current{color:var(--text-primary);display:block}.gem-hltb-match-info{display:block;font-size:0.8rem;color:var(--text-muted);margin-top:var(--spacing-xs)}.gem-hltb-actions{display:flex;gap:var(--spacing-sm)}.gem-hltb-results{margin-top:var(--spacing-md);padding:var(--spacing-md);background:rgba(76,201,240,0.05);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-md)}.gem-hltb-searching{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-secondary)}.gem-hltb-match{margin-bottom:var(--spacing-md)}.gem-hltb-match-title{display:block;color:var(--text-primary);font-weight:600}.gem-hltb-match-confidence{font-size:0.8rem;color:var(--neon-green)}.gem-hltb-match-platform{display:block;font-size:0.8rem;color:var(--neon-purple);margin-top:2px}.gem-hltb-times{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.gem-hltb-time{text-align:center;padding:var(--spacing-sm);background:rgba(0,0,0,0.2);border-radius:var(--radius-sm)}.gem-hltb-time-label{display:block;font-size:0.7rem;color:var(--text-muted);text-transform:uppercase;margin-bottom:2px}.gem-hltb-time-value{display:block;color:var(--primary-cyan);font-weight:600}.gem-hltb-error{color:var(--neon-red);font-size:0.9rem}.gem-footer{display:flex;justify-content:space-between;gap:var(--spacing-md);padding:var(--spacing-lg);border-top:1px solid var(--card-border)}.gem-footer-right{display:flex;gap:var(--spacing-sm)}.gem-tabs{display:flex;gap:var(--spacing-xs);margin-top:var(--spacing-md);overflow-x:auto;padding-bottom:var(--spacing-xs)}.gem-tab{padding:8px 12px;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-size:0.85rem;cursor:pointer;transition:all var(--transition-fast);white-space:nowrap}.gem-tab:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.gem-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.gem-tab-content{grid-area:1 / 1;visibility:hidden;pointer-events:none}.gem-tab-content.active{visibility:visible;pointer-events:auto}.gem-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md)}.gem-field{display:flex;flex-direction:column}.gem-field label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-xs)}.gem-field input,.gem-field select,.gem-field textarea{padding:10px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.95rem;transition:border-color var(--transition-fast)}.gem-field input:hover,.gem-field input:focus,.gem-field select:hover,.gem-field select:focus,.gem-field textarea:hover,.gem-field textarea:focus{border-color:var(--primary-cyan);outline:none}.gem-field input::placeholder,.gem-field textarea::placeholder{color:var(--text-muted)}.gem-field-full{grid-column:1 / -1}.gem-field textarea{resize:vertical;min-height:120px}.gem-field-row{display:flex;gap:var(--spacing-xs)}.gem-field-row input{flex:1}.gem-field-row .btn{flex-shrink:0}.gem-toggle-row{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) 0}.gem-toggle-label{color:var(--text-secondary);font-size:0.9rem}.psn-profile-bar{display:flex;align-items:center;gap:var(--spacing-lg);padding:var(--spacing-md) var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-avatar{width:48px;height:48px;border-radius:50%;border:2px solid var(--primary-cyan);object-fit:cover}.psn-avatar-placeholder{width:48px;height:48px;border-radius:50%;border:2px solid var(--card-border);background:var(--bg-medium);display:flex;align-items:center;justify-content:center;font-size:1.5rem}.psn-profile-info{flex:1}.psn-username{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary)}.psn-level{font-size:0.8rem;color:var(--text-secondary)}.psn-level strong{color:var(--primary-cyan)}.psn-sync-status{font-size:0.75rem;color:var(--text-muted);display:flex;align-items:center;gap:6px}.psn-stats-bar{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.psn-stat-box{padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);text-align:center}.psn-stat-icon{font-size:1.5rem;margin-bottom:var(--spacing-xs)}.psn-stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700}.psn-stat-value.platinum{color:var(--trophy-platinum)}.psn-stat-value.gold{color:var(--trophy-gold)}.psn-stat-value.silver{color:var(--trophy-silver)}.psn-stat-value.bronze{color:var(--trophy-bronze)}.psn-stat-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.platform-filters{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);flex-wrap:wrap}.platform-filter-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast)}.platform-filter-btn:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.platform-filter-btn.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.filter-count{font-size:0.7rem;padding:2px 6px;background:rgba(255,255,255,0.1);border-radius:var(--radius-full)}.platform-filter-btn.active .filter-count{background:rgba(76,201,240,0.2)}.psn-search-sort-row{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg);align-items:center}.psn-search-wrapper{flex:1;position:relative}.psn-search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-muted);pointer-events:none}.psn-search-input{width:100%;padding:10px 12px 10px 36px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem}.psn-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.psn-search-input::placeholder{color:var(--text-muted)}.psn-sort-controls{display:flex;align-items:center;gap:var(--spacing-sm)}.psn-sort-label{font-size:0.8rem;color:var(--text-muted);white-space:nowrap}.psn-sort-select{padding:8px 32px 8px 12px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.85rem;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}.psn-sort-select:focus{outline:none;border-color:var(--primary-cyan)}.psn-games-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-md)}.psn-trophy-game-card{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);transition:all var(--transition-fast);text-decoration:none;color:inherit}.psn-trophy-game-card:hover{border-color:var(--primary-cyan);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}.psn-trophy-game-thumb{width:64px;height:64px;border-radius:var(--radius-sm);object-fit:cover;border:1px solid var(--card-border);flex-shrink:0}.psn-trophy-game-thumb-placeholder{width:64px;height:64px;border-radius:var(--radius-sm);background:var(--bg-medium);border:1px solid var(--card-border);display:flex;align-items:center;justify-content:center;font-size:1.5rem;flex-shrink:0}.psn-trophy-game-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.psn-trophy-game-name{font-size:0.9rem;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.psn-trophy-game-npwr{font-family:var(--font-mono);font-size:0.65rem;color:var(--text-muted)}.psn-trophy-game-progress{display:flex;align-items:center;gap:var(--spacing-sm)}.psn-progress-track{flex:1;height:6px;background:var(--bg-dark);border-radius:3px;overflow:hidden}.psn-progress-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--neon-purple));border-radius:3px;transition:width var(--transition-normal)}.psn-progress-pct{font-family:var(--font-mono);font-size:0.7rem;color:var(--primary-cyan);min-width:32px;text-align:right}.psn-trophy-mini-badges{display:flex;gap:6px}.trophy-mini-badge{display:inline-flex;align-items:center;gap:3px;font-size:0.65rem;font-weight:600}.tmb-platinum{color:var(--trophy-platinum)}.tmb-gold{color:var(--trophy-gold)}.tmb-silver{color:var(--trophy-silver)}.tmb-bronze{color:var(--trophy-bronze)}.psn-game-info-card{display:flex;gap:var(--spacing-lg);padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-game-boxart{width:180px;flex-shrink:0}.psn-game-boxart img{width:100%;border-radius:var(--radius-md);border:1px solid var(--card-border)}.psn-game-boxart-placeholder{width:100%;aspect-ratio:1;background:var(--bg-medium);border-radius:var(--radius-md);border:1px solid var(--card-border);display:flex;align-items:center;justify-content:center;font-size:4rem}.psn-game-meta{flex:1}.psn-game-badges{display:flex;gap:var(--spacing-sm);flex-wrap:wrap;margin-bottom:var(--spacing-md)}.psn-game-description{font-size:0.85rem;color:var(--text-secondary);line-height:1.6;margin-bottom:var(--spacing-md);max-height:100px;overflow:hidden}.psn-meta-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-md)}.psn-meta-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin-bottom:2px}.psn-meta-value{font-size:0.85rem;color:var(--text-secondary)}.psn-trophy-progress-card{padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-trophy-progress-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--spacing-md)}.psn-trophy-progress-title{font-family:var(--font-heading);font-size:1rem;font-weight:600;display:flex;align-items:center;gap:8px}.psn-trophy-progress-fraction{font-family:var(--font-mono);font-size:1rem;color:var(--text-secondary)}.psn-trophy-progress-pct{font-family:var(--font-heading);font-size:1.2rem;font-weight:700;color:var(--primary-cyan)}.psn-trophy-progress-bar-container{height:28px;background:rgba(26,26,62,0.8);border:2px solid var(--primary-cyan);border-radius:var(--radius-md);overflow:hidden;margin-bottom:var(--spacing-md);box-shadow:0 0 15px rgba(76,201,240,0.2)}.psn-trophy-progress-bar-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--neon-purple));border-radius:var(--radius-md);transition:width 0.8s ease;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7)}.psn-trophy-type-breakdown{display:flex;justify-content:center;gap:var(--spacing-xl)}.psn-trophy-type-item{display:flex;align-items:center;gap:6px;font-size:0.85rem}.psn-trophy-type-icon{font-size:1.1rem}.psn-trophy-type-count{font-family:var(--font-mono)}.type-platinum .psn-trophy-type-count{color:var(--trophy-platinum)}.type-gold .psn-trophy-type-count{color:var(--trophy-gold)}.type-silver .psn-trophy-type-count{color:var(--trophy-silver)}.type-bronze .psn-trophy-type-count{color:var(--trophy-bronze)}.psn-info-panels-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.psn-info-panel{padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg)}.psn-info-panel-title{font-family:var(--font-heading);font-size:0.9rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-md);display:flex;align-items:center;gap:8px}.psn-detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-sm) var(--spacing-lg)}.psn-detail-label{font-size:0.75rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.psn-detail-value{font-size:0.85rem;color:var(--text-secondary)}.psn-mono{font-family:var(--font-mono);font-size:0.8rem}.psn-hltb-input{width:100%;padding:8px 12px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.85rem;margin-bottom:var(--spacing-sm)}.psn-hltb-input:focus{outline:none;border-color:var(--primary-cyan)}.psn-trophy-lists-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-lg)}.psn-trophy-list-panel{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);overflow:hidden}.psn-trophy-list-header{padding:var(--spacing-md) var(--spacing-lg);border-bottom:1px solid var(--card-border);font-family:var(--font-heading);font-size:0.9rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;display:flex;align-items:center;gap:8px}.psn-trophy-list-header.header-unlocked{color:var(--neon-green)}.psn-trophy-list-header.header-locked{color:var(--neon-gray)}.psn-trophy-list-body{max-height:600px;overflow-y:auto;padding:var(--spacing-sm)}.psn-trophy-list-body::-webkit-scrollbar{width:6px}.psn-trophy-list-body::-webkit-scrollbar-track{background:var(--bg-dark)}.psn-trophy-list-body::-webkit-scrollbar-thumb{background:var(--card-border);border-radius:3px}.psn-trophy-list-body::-webkit-scrollbar-thumb:hover{background:var(--primary-cyan)}.psn-trophy-entry{display:flex;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);border-radius:var(--radius-md);transition:background var(--transition-fast)}.psn-trophy-entry:hover{background:rgba(76,201,240,0.03)}.psn-trophy-entry + .psn-trophy-entry{border-top:1px solid rgba(42,53,66,0.5)}.psn-trophy-icon-box{width:48px;height:48px;border-radius:var(--radius-sm);background:var(--bg-medium);display:flex;align-items:center;justify-content:center;font-size:1.4rem;flex-shrink:0;border:1px solid var(--card-border);overflow:hidden}.psn-trophy-icon-box.locked{opacity:0.5;filter:grayscale(0.5)}.psn-trophy-icon-img{width:100%;height:100%;object-fit:cover}.psn-trophy-details{flex:1;min-width:0}.psn-trophy-name{font-size:0.85rem;font-weight:600;color:var(--text-primary);margin-bottom:2px}.psn-trophy-description{font-size:0.75rem;color:var(--text-muted);line-height:1.4}.psn-trophy-earned-info{display:flex;align-items:center;gap:var(--spacing-sm);margin-top:4px;flex-wrap:wrap}.psn-trophy-type-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:3px;font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.5px}.psn-trophy-type-badge.ttb-platinum{background:rgba(123,184,212,0.2);color:var(--trophy-platinum);border:1px solid rgba(123,184,212,0.3)}.psn-trophy-type-badge.ttb-gold{background:rgba(205,154,42,0.2);color:var(--trophy-gold);border:1px solid rgba(205,154,42,0.3)}.psn-trophy-type-badge.ttb-silver{background:rgba(168,168,168,0.2);color:var(--trophy-silver);border:1px solid rgba(168,168,168,0.3)}.psn-trophy-type-badge.ttb-bronze{background:rgba(205,127,50,0.2);color:var(--trophy-bronze);border:1px solid rgba(205,127,50,0.3)}.psn-trophy-rarity{display:inline-flex;align-items:center;gap:3px;font-size:0.65rem;color:var(--text-muted);padding:2px 6px;border-radius:3px;background:rgba(255,255,255,0.03)}.psn-trophy-rarity.rarity-ultra-rare{color:var(--neon-cyan)}.psn-trophy-rarity.rarity-very-rare{color:var(--neon-purple)}.psn-trophy-rarity.rarity-rare{color:var(--neon-orange)}.psn-trophy-rarity.rarity-common{color:var(--neon-gray)}.psn-trophy-earned-date{font-family:var(--font-mono);font-size:0.65rem;color:var(--text-muted);display:flex;align-items:center;gap:4px}.psn-trophy-earned-date .date-icon{font-size:0.6rem}.psn-dlc-separator{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);margin-top:var(--spacing-sm)}.psn-dlc-separator-line{flex:1;height:1px;background:var(--card-border)}.psn-dlc-separator-label{font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--neon-purple);white-space:nowrap}.psn-trophy-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:var(--spacing-xl);color:var(--text-muted);text-align:center}.psn-trophy-empty-icon{font-size:2rem;margin-bottom:var(--spacing-sm)}.psn-trophy-complete{color:var(--neon-green)}.psn-not-configured{text-align:center;padding:var(--spacing-2xl)}.psn-not-configured-icon{font-size:4rem;margin-bottom:var(--spacing-lg)}.psn-not-configured-title{font-family:var(--font-heading);font-size:1.5rem;color:var(--text-primary);margin-bottom:var(--spacing-md)}.psn-not-configured-text{color:var(--text-secondary);margin-bottom:var(--spacing-lg);max-width:500px;margin-left:auto;margin-right:auto}.psn-no-games{text-align:center;padding:var(--spacing-2xl);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-lg)}.psn-no-games-icon{font-size:3rem;margin-bottom:var(--spacing-md)}.psn-no-games-title{font-family:var(--font-heading);font-size:1.2rem;color:var(--text-primary);margin-bottom:var(--spacing-sm)}.psn-no-games-text{color:var(--text-muted)}.psn-visible-count{text-align:center;padding:var(--spacing-md);color:var(--text-muted);font-size:0.85rem}.achievement-overview{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg)}.achievement-stat{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md)}.achievement-stat-icon{font-size:2rem}.achievement-stat-info{display:flex;flex-direction:column}.achievement-stat-label{font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.achievement-stat-value{font-size:1.3rem;font-weight:600;color:var(--neon-cyan)}.achievements-page{--card-min:280px;--card-gap:var(--spacing-md)}.achievement-games-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.achievement-game-card{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md);text-decoration:none;color:inherit;transition:all 0.2s ease}.achievement-game-card:hover{border-color:var(--neon-cyan);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}.achievement-game-icon{flex-shrink:0;width:80px;height:80px;border-radius:var(--radius-sm);overflow:hidden;background:var(--bg-darker)}.achievement-game-icon img{width:100%;height:100%;object-fit:cover}.achievement-game-card .no-boxart{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2rem;background:var(--bg-darker)}.achievement-game-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.achievement-game-title{font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.achievement-game-system{font-size:0.8rem;color:var(--text-muted)}.achievement-game-progress{margin-top:auto}.achievement-progress-bar{height:6px;background:var(--bg-darker);border-radius:3px;overflow:hidden;margin-bottom:4px}.achievement-progress-fill{height:100%;background:linear-gradient(90deg,var(--neon-cyan),var(--neon-purple));border-radius:3px;transition:width 0.3s ease}.achievement-progress-fill.xbox-fill{background:linear-gradient(90deg,var(--neon-green),#14b873)}.achievement-progress-text{font-size:0.75rem;color:var(--text-muted)}.no-boxart-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:4rem;background:var(--bg-darker);color:var(--text-muted)}.trophy-type-label.gamerscore-earned{background:#ffd700;color:#111}.trophy-type-label.gamerscore-locked{background:var(--bg-lighter);color:var(--text-muted)}.stat-box{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);text-align:center;transition:all var(--transition-normal)}.stat-box:hover{border-color:var(--neon-cyan);box-shadow:0 0 20px rgba(76,201,240,0.2)}.stat-value{font-family:var(--font-heading);font-size:2.5rem;font-weight:700;color:var(--neon-cyan);line-height:1.2;text-shadow:0 0 20px rgba(76,201,240,0.5)}.stat-label{font-size:0.9rem;color:var(--text-secondary);margin-top:0.5rem;text-transform:uppercase;letter-spacing:0.5px}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.stat-card{background:linear-gradient(135deg,var(--card-bg) 0%,var(--bg-medium) 100%);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);position:relative;overflow:hidden;transition:all var(--transition-normal)}.stat-card::before{content:'';position:absolute;top:0;left:0;width:100%;height:3px;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta))}.stat-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-xl);border-color:var(--primary-cyan)}.stat-card-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:var(--spacing-md)}.stat-card .stat-label{font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;margin-top:0}.stat-icon{width:40px;height:40px;background:linear-gradient(135deg,rgba(76,201,240,0.2) 0%,rgba(247,37,133,0.2) 100%);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:1.2rem;color:var(--primary-cyan)}.stat-card .stat-value{font-family:var(--font-heading);font-size:3rem;font-weight:700;color:var(--text-primary);line-height:1;margin-bottom:var(--spacing-xs);text-shadow:none}.stat-subtitle{font-size:0.85rem;color:var(--text-secondary)}.stat-card.warning .stat-icon{background:linear-gradient(135deg,rgba(255,171,0,0.2) 0%,rgba(255,143,0,0.2) 100%);color:var(--status-warning)}.stat-card.warning::before{background:var(--status-warning)}.stat-card.success .stat-icon{background:linear-gradient(135deg,rgba(0,230,118,0.2) 0%,rgba(0,200,83,0.2) 100%);color:var(--status-success)}.stat-card.success::before{background:var(--status-success)}.psn-stats-bar{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.psn-stat-box{padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);text-align:center}.psn-stat-icon{font-size:1.5rem;margin-bottom:var(--spacing-xs)}.psn-stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700}.psn-stat-value.platinum{color:var(--trophy-platinum)}.psn-stat-value.gold{color:var(--trophy-gold)}.psn-stat-value.silver{color:var(--trophy-silver)}.psn-stat-value.bronze{color:var(--trophy-bronze)}.psn-stat-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.stat-box.warning .stat-value{color:var(--neon-orange)}.stat-box.danger .stat-value{color:var(--neon-red)}.stat-box.success .stat-value{color:var(--neon-green)}.filter-bar{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-xl);flex-wrap:wrap}.search-box{--search-icon-offset:var(--spacing-md);--search-icon-size:1.15rem;--search-icon-width:2rem;--search-icon-gap:var(--spacing-sm);flex:1;min-width:250px;position:relative}.search-icon{position:absolute;left:var(--search-icon-offset);top:50%;transform:translateY(-50%);color:var(--text-muted);font-size:var(--search-icon-size)}input.search-input{width:100%;padding:var(--spacing-md) var(--spacing-lg);padding-left:calc(var(--search-icon-offset) + var(--search-icon-width) + var(--search-icon-gap));background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:all var(--transition-fast)}input.search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 3px rgba(76,201,240,0.1)}input.search-input::placeholder{color:var(--text-muted)}.filter-select{padding:var(--spacing-md) var(--spacing-lg);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;transition:all var(--transition-fast);min-width:150px}.filter-select:focus{outline:none;border-color:var(--primary-cyan)}.platform-filters{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);flex-wrap:wrap}.platform-filter-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast)}.platform-filter-btn:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.platform-filter-btn.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.filter-count{font-size:0.7rem;padding:2px 6px;background:rgba(255,255,255,0.1);border-radius:var(--radius-full)}.platform-filter-btn.active .filter-count{background:rgba(76,201,240,0.2)}.bulk-filter-section{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.bulk-action-section{padding:var(--spacing-lg);background:var(--bg-dark);border-radius:var(--radius-md);border:1px solid var(--card-border)}.bulk-preview-count{font-size:1.5rem;font-weight:700;color:var(--primary-cyan);margin-bottom:var(--spacing-sm)}.bulk-game-list{max-height:300px;overflow-y:auto;margin-top:var(--spacing-md)}.bulk-game-item{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.bulk-game-item:last-child{border-bottom:none}.psn-search-sort-row{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg);align-items:center}.psn-search-wrapper{flex:1;position:relative}.psn-search-wrapper .search-input{width:100%;padding-left:calc(var(--search-icon-offset, var(--spacing-md)) + var(--search-icon-width, 2rem) + var(--search-icon-gap, var(--spacing-sm)))}.psn-sort-select{padding:var(--spacing-sm) var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;cursor:pointer}.psn-sort-select:focus{outline:none;border-color:var(--primary-cyan)}.hltb-card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-md)}.hltb-card-title{display:flex;align-items:center;gap:var(--spacing-sm);font-family:var(--font-primary);font-size:1rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-md)}.hltb-search-input{width:100%;padding:10px 14px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;margin-bottom:var(--spacing-sm)}.hltb-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.btn-hltb{width:100%;display:flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:10px 16px;background:rgba(76,201,240,0.1);border:1px solid var(--primary-cyan);border-radius:var(--radius-md);color:var(--primary-cyan);font-family:var(--font-primary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.btn-hltb:hover{background:rgba(76,201,240,0.2)}.btn-hltb:disabled{opacity:0.5;cursor:not-allowed}.hltb-results{margin-top:var(--spacing-md)}.hltb-match-header{display:flex;flex-wrap:wrap;align-items:center;gap:var(--spacing-xs);padding-bottom:var(--spacing-sm);margin-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.hltb-match-title{color:var(--primary-cyan);font-weight:600;font-size:0.9rem}.hltb-match-platform{color:var(--text-secondary);font-size:0.8rem}.hltb-match-year{color:var(--neon-orange);font-size:0.8rem;font-weight:500}.hltb-match-developer{color:var(--text-secondary);font-size:0.8rem;font-style:italic}.hltb-match-warning{color:var(--neon-orange);font-size:0.8rem;font-weight:600}.hltb-match-confidence{color:var(--neon-green);font-size:0.8rem;font-weight:500}.hltb-match-alt-notice{display:inline-flex;align-items:center;gap:0.25rem;color:var(--neon-orange);background:rgba(245,158,11,0.12);border:1px solid rgba(245,158,11,0.4);padding:2px 8px;border-radius:var(--radius-full);font-size:0.75rem;font-weight:600;cursor:help;white-space:nowrap}.hltb-time-row{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-sm) 0;border-bottom:1px solid var(--card-border)}.hltb-time-row:last-child{border-bottom:none}.hltb-label{color:var(--text-secondary);font-size:0.85rem}.hltb-value{color:var(--text-primary);font-size:0.85rem;font-weight:500}.hltb-value.highlight{color:var(--primary-cyan)}.hltb-actions{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-md)}.hltb-actions .btn{flex:1}.hltb-empty{color:var(--text-muted);font-size:0.9rem;padding:var(--spacing-sm) 0}.hltb-loading{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-secondary);font-size:0.9rem;padding:var(--spacing-sm) 0}.hltb-compact .hltb-time-row{padding:var(--spacing-xs) 0}.hltb-compact .hltb-label,.hltb-compact .hltb-value{font-size:0.8rem}.manual-reader{display:flex;flex-direction:column;width:min(96vw,1400px);height:94vh;background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl)}.manual-reader-header{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);border-bottom:1px solid var(--card-border)}.manual-reader-header h2{flex:1;min-width:0;margin:0;font-family:var(--font-heading);font-size:1.1rem;color:var(--neon-cyan);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.manual-reader-stage{flex:1;min-height:0;display:grid;place-items:center;padding:var(--spacing-sm);overflow:hidden;perspective:2400px;touch-action:pan-y;user-select:none}.manual-reader-stage:focus-visible{outline:2px solid var(--primary-cyan);outline-offset:-2px}.manual-reader-sheet{grid-area:1 / 1;max-width:100%;max-height:100%;box-shadow:var(--shadow-lg);transform-origin:left center;backface-visibility:hidden}.manual-reader-sheet.turn-out{z-index:1;animation:manual-turn-out 0.6s ease-in-out forwards}.manual-reader-sheet.turn-in{z-index:1;animation:manual-turn-in 0.6s ease-in-out forwards}@keyframes manual-turn-out{from{transform:rotateY(0deg)}to{transform:rotateY(-180deg)}}@keyframes manual-turn-in{from{transform:rotateY(-180deg)}to{transform:rotateY(0deg)}}@media (prefers-reduced-motion:reduce){.manual-reader-sheet.turn-out,.manual-reader-sheet.turn-in{animation:none}}.manual-reader-message{grid-area:1 / 1;color:var(--text-secondary);text-align:center}.manual-reader-controls{display:flex;align-items:center;justify-content:center;gap:var(--spacing-lg);padding:var(--spacing-sm);border-top:1px solid var(--card-border)}.manual-reader-controls .btn{white-space:nowrap}.manual-reader-page{min-width:8em;text-align:center;color:var(--text-primary);font-family:var(--font-mono)}@media (max-width:600px){.manual-reader{width:100vw;height:100dvh;border-radius:0}.manual-reader-header .btn{font-size:0.75rem}}.score-badge.score-good{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green)}.score-badge.score-mixed{background:rgba(234,179,8,0.15);border-color:var(--neon-orange);color:var(--neon-orange)}.score-badge.score-bad{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red)}.bulk-option-label{display:flex;align-items:center;gap:6px;padding:6px 12px;background:rgba(168,85,247,0.1);border:1px solid var(--neon-purple);border-radius:var(--radius-md);color:var(--neon-purple);font-size:0.85rem;cursor:pointer;transition:all var(--transition-fast)}.bulk-option-label:hover{background:rgba(168,85,247,0.2)}.bulk-option-label input[type="checkbox"]{width:16px;height:16px;accent-color:var(--neon-purple);cursor:pointer}.bulk-mode-selector{display:flex;gap:8px;align-items:center}.scrape-mode-toggle{display:flex;align-items:center;gap:12px;padding:6px 16px;background:rgba(100,116,139,0.1);border:1px solid var(--neon-gray);border-radius:var(--radius-md)}.scrape-mode-toggle .mode-label{font-size:0.85rem;font-weight:500;transition:all var(--transition-fast)}.scrape-mode-toggle .mode-label.mode-off{color:var(--text-muted)}.scrape-mode-toggle .mode-label.mode-on{color:var(--text-muted)}.scrape-mode-toggle:has(input:not(:checked)) .mode-off{color:var(--neon-orange);text-shadow:0 0 8px rgba(245,158,11,0.5)}.scrape-mode-toggle:has(input:checked) .mode-on{color:var(--neon-cyan);text-shadow:0 0 8px rgba(76,201,240,0.5)}.keep-media-option{display:flex;align-items:center;gap:6px;font-size:0.85rem;color:var(--text-muted);cursor:pointer}.bulk-mode-selector:has(#scrapeModeToggle:checked) .keep-media-option{display:none}.scrape-mode-toggle .toggle-switch{position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0}.scrape-mode-toggle .toggle-switch input{opacity:0;width:0;height:0}.scrape-mode-toggle .toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--neon-orange);border-radius:24px;transition:all var(--transition-normal);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(245,158,11,0.3)}.scrape-mode-toggle .toggle-slider:before{position:absolute;content:"";height:18px;width:18px;left:3px;bottom:3px;background:white;border-radius:50%;transition:all var(--transition-normal);box-shadow:0 2px 4px rgba(0,0,0,0.3)}.scrape-mode-toggle .toggle-switch input:checked + .toggle-slider{background:var(--neon-cyan);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(76,201,240,0.3)}.scrape-mode-toggle .toggle-switch input:checked + .toggle-slider:before{transform:translateX(20px)}.fanart-background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;background-size:cover;background-position:center;background-repeat:no-repeat;opacity:0;transition:opacity 0.5s ease,background-image 0.5s ease;pointer-events:none}.fanart-background::after{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient( to bottom,rgba(10,14,20,0.7) 0%,rgba(10,14,20,0.85) 50%,rgba(10,14,20,0.95) 100% )}.fanart-background.active{opacity:1}.glass-panel{background:rgba(20,28,40,0.7);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(76,201,240,0.2)}.glass-card{background:rgba(20,28,40,0.5);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px)}.glitch-text{position:relative}.glitch-text::before,.glitch-text::after{content:attr(data-text);position:absolute;top:0;left:0;width:100%;height:100%}.glitch-text::before{left:2px;text-shadow:-2px 0 var(--secondary-magenta);clip:rect(24px,550px,90px,0);animation:glitch-anim 3s infinite linear alternate-reverse}.glitch-text::after{left:-2px;text-shadow:-2px 0 var(--primary-cyan);clip:rect(85px,550px,140px,0);animation:glitch-anim2 3s infinite linear alternate-reverse}@keyframes glitch-anim{0%{clip:rect(20px,9999px,40px,0)}25%{clip:rect(60px,9999px,80px,0)}50%{clip:rect(10px,9999px,30px,0)}75%{clip:rect(70px,9999px,90px,0)}100%{clip:rect(30px,9999px,50px,0)}}@keyframes glitch-anim2{0%{clip:rect(80px,9999px,100px,0)}25%{clip:rect(20px,9999px,40px,0)}50%{clip:rect(50px,9999px,70px,0)}75%{clip:rect(10px,9999px,30px,0)}100%{clip:rect(60px,9999px,80px,0)}}.scan-lines{position:absolute;top:0;left:0;width:100%;height:100%;background:repeating-linear-gradient( 0deg,rgba(0,0,0,0.1),rgba(0,0,0,0.1) 1px,transparent 1px,transparent 2px );pointer-events:none;opacity:0;transition:opacity var(--transition-normal)}.game-card-new:hover .scan-lines{opacity:0.3}.card-glow{position:absolute;top:-2px;left:-2px;right:-2px;bottom:-2px;background:linear-gradient(45deg,var(--primary-cyan),var(--secondary-magenta),var(--primary-cyan));border-radius:calc(var(--radius-lg) + 2px);z-index:-1;opacity:0;filter:blur(8px);transition:opacity var(--transition-normal)}.game-card-new:hover .card-glow{opacity:0.5;animation:glow-pulse 2s ease-in-out infinite}@keyframes glow-pulse{0%,100%{opacity:0.3}50%{opacity:0.6}}.filter-bar-new{display:flex;gap:var(--spacing-md);align-items:center;flex-wrap:wrap;padding:var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);position:sticky;top:var(--sticky-top);z-index:100;background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.search-box-integrated{--search-icon-offset:12px;--search-icon-size:1rem;--search-icon-width:1.75rem;--search-icon-gap:var(--spacing-sm);position:relative;min-width:200px;max-width:350px;flex:1}.search-icon-inside{position:absolute;left:var(--search-icon-offset);top:50%;transform:translateY(-50%);font-size:var(--search-icon-size);pointer-events:none;z-index:1}.search-input-integrated{width:100%;padding:var(--spacing-sm) var(--spacing-md);padding-left:calc(var(--search-icon-offset) + var(--search-icon-width) + var(--search-icon-gap));padding-right:32px;background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.95rem;outline:none;transition:border-color var(--transition-fast)}.search-clear-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text-muted);font-size:1.2rem;cursor:pointer;padding:0 4px;line-height:1;opacity:0;transition:opacity 0.2s,color 0.2s;z-index:1}.search-clear-btn:hover{color:var(--primary-cyan)}.search-box-integrated:has(.search-input-integrated:not(:placeholder-shown)) .search-clear-btn{opacity:1}.search-input-integrated:focus{border-color:var(--primary-cyan)}.search-input-integrated::placeholder{color:var(--text-muted)}.filter-buttons{display:flex;gap:var(--spacing-sm);flex-wrap:wrap}.filter-btn{display:flex;align-items:center;gap:var(--spacing-xs);padding:var(--spacing-sm) var(--spacing-md);background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);cursor:pointer;transition:all var(--transition-fast);font-size:0.85rem}.filter-btn:hover{border-color:var(--primary-cyan);color:var(--primary-cyan);background:rgba(76,201,240,0.1)}.filter-btn:disabled{opacity:0.35;cursor:default;pointer-events:none}.filter-btn.active{border-color:var(--primary-cyan);background:rgba(76,201,240,0.2);color:var(--primary-cyan)}.filter-btn.clear-btn{border-color:var(--status-error);color:var(--status-error)}.filter-btn.clear-btn:hover{background:rgba(255,107,107,0.2)}.filter-btn.ra-filter-btn{border-color:rgba(255,215,0,0.4);color:var(--trophy-display-gold)}.filter-btn.ra-filter-btn:hover{border-color:var(--trophy-display-gold);background:rgba(255,215,0,0.15)}.filter-btn.ra-filter-btn.active{border-color:var(--trophy-display-gold);background:rgba(255,215,0,0.25);color:var(--trophy-display-gold);box-shadow:0 0 10px rgba(255,215,0,0.2)}.filter-btn.bonus-filter-btn{border-color:rgba(245,158,11,0.4);color:var(--neon-orange)}.filter-btn.bonus-filter-btn:hover{border-color:var(--neon-orange);background:rgba(245,158,11,0.15)}.filter-btn.bonus-filter-btn.active{border-color:var(--neon-orange);background:rgba(245,158,11,0.25);color:var(--neon-orange);box-shadow:0 0 10px rgba(245,158,11,0.2)}.filter-btn.refresh-ra-btn{border-color:rgba(76,201,240,0.4);color:var(--primary-cyan)}.filter-btn.refresh-ra-btn:hover{border-color:var(--primary-cyan);background:rgba(76,201,240,0.15)}.filter-btn.refresh-ra-btn:disabled{opacity:0.6;cursor:not-allowed}.filter-tag.ra-tag{background:rgba(255,215,0,0.2);border-color:rgba(255,215,0,0.4);color:var(--trophy-display-gold)}.filter-bar-top{display:flex;align-items:center;gap:var(--spacing-md);width:100%}.bulk-controls{display:flex;gap:var(--spacing-sm);align-items:center;margin-left:auto;flex-wrap:wrap;justify-content:flex-end}.bulk-btn{border-color:rgba(147,51,234,0.4);color:var(--neon-purple)}.bulk-btn:hover{border-color:var(--neon-purple);background:rgba(147,51,234,0.15)}.bulk-btn.active{border-color:var(--neon-purple);background:rgba(147,51,234,0.25);color:var(--neon-purple)}.bulk-action-btn{border-color:rgba(59,130,246,0.4);color:var(--neon-blue)}.bulk-action-btn:hover{border-color:var(--neon-blue);background:rgba(59,130,246,0.15)}.bulk-scrape-btn{border-color:rgba(34,197,94,0.4);color:var(--neon-green)}.bulk-scrape-btn:hover{border-color:var(--neon-green);background:rgba(34,197,94,0.15)}.game-card-wrapper{position:relative}.bulk-select-checkbox{position:absolute;top:8px;right:8px;z-index:20}.bulk-select-checkbox input[type="checkbox"]{width:22px;height:22px;cursor:pointer;accent-color:var(--primary-cyan)}.game-card-wrapper.selected .game-card-new{border-color:var(--primary-cyan);box-shadow:0 0 15px rgba(76,201,240,0.4)}.bulk-scrape-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:3000;align-items:center;justify-content:center}.bulk-scrape-modal.active{display:flex}.bulk-scrape-content{width:90%;max-width:500px;border-radius:var(--radius-lg);overflow:hidden}.bulk-scrape-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);display:flex;justify-content:space-between;align-items:center}.bulk-scrape-header h2{margin:0;color:var(--primary-cyan)}.bulk-scrape-body{padding:var(--spacing-lg)}.bulk-progress-info{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md)}.bulk-progress-text{flex:1;overflow:hidden}#bulkCurrentGame{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text-primary);font-weight:500}.bulk-progress-numbers{color:var(--primary-cyan);font-family:var(--font-mono);font-size:1.1rem;margin-left:var(--spacing-md)}.bulk-progress-bar{width:100%;height:20px;background:var(--bg-medium);border-radius:10px;margin-bottom:var(--spacing-md);border:2px solid rgba(76,201,240,0.5);box-shadow:inset 0 2px 4px rgba(0,0,0,0.5);overflow:hidden;padding:2px}.bulk-progress-fill{display:block;height:100%;width:var(--progress,0%);min-width:0;max-width:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta));border-radius:8px;box-shadow:0 0 10px rgba(76,201,240,0.5)}.bulk-status{text-align:center;color:var(--text-secondary);font-size:0.9rem;margin-bottom:var(--spacing-md)}.bulk-controls-row{display:flex;justify-content:center;gap:var(--spacing-md);margin-top:var(--spacing-lg);margin-bottom:var(--spacing-md);padding-top:var(--spacing-sm)}.bulk-results{display:flex;justify-content:space-around;padding:var(--spacing-md);background:rgba(0,0,0,0.3);border-radius:var(--radius-md);margin-top:var(--spacing-md)}.bulk-result-item{display:flex;align-items:center;gap:var(--spacing-xs);font-size:0.9rem}.bulk-result-item.success{color:var(--neon-green)}.bulk-result-item.failed{color:var(--neon-red)}.bulk-result-item.skipped{color:var(--neon-orange)}.bulk-scrape-footer{padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border);display:flex;justify-content:center}.active-filter-tags{display:flex;gap:var(--spacing-sm);flex:1;min-width:0;overflow-x:auto;scrollbar-width:thin}.active-filter-tags .filter-tag{flex-shrink:0;white-space:nowrap}.filter-tag{display:flex;align-items:center;gap:var(--spacing-xs);padding:4px 10px;background:var(--primary-cyan);color:var(--bg-dark);border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600}.filter-tag-exclude{background:rgba(239,68,68,0.2);border:1px solid rgba(239,68,68,0.5);color:var(--neon-red)}.filter-tag-remove{cursor:pointer;opacity:0.7;transition:opacity 0.2s}.filter-tag-remove:hover{opacity:1}.alphabet-nav{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md) var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);position:sticky;top:calc(var(--sticky-top) + 80px);z-index:99}.alphabet-label{color:var(--text-muted);font-size:0.85rem;white-space:nowrap}.alphabet-letters{display:flex;flex-wrap:wrap;gap:4px}.alphabet-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-sm);color:var(--text-secondary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.alphabet-btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.active{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.disabled{opacity:0.3;cursor:not-allowed}.alphabet-btn.disabled:hover{background:rgba(76,201,240,0.1);border-color:rgba(76,201,240,0.3);color:var(--text-secondary)}.cards-page{--card-min:380px;--card-gap:var(--spacing-lg);max-width:calc(4 * 480px + 3 * var(--card-gap));margin-inline:auto}.games-grid-new{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card-min,380px),1fr));grid-auto-rows:1px;column-gap:var(--spacing-lg)}.game-card-wrapper{grid-row-end:span 296;align-self:start;transition:transform var(--transition-normal);contain:layout style paint;content-visibility:auto;contain-intrinsic-size:auto 380px 296px}.game-card-new{position:relative;display:flex;flex-direction:row;border-radius:var(--radius-lg);overflow:hidden;text-decoration:none;transition:all var(--transition-normal);border:1px solid transparent;min-height:240px}.game-card-wrapper:hover{transform:translateY(-4px)}.game-card-new:hover{box-shadow:0 8px 30px rgba(76,201,240,0.2)}.game-card-wrapper.hidden{display:none}.game-card-new.hidden{display:none}.card-boxart-section{flex-shrink:0;width:135px;align-self:stretch;background:var(--bg-medium);display:flex;align-items:center;justify-content:center;position:relative}.card-play-btn{position:absolute;top:var(--spacing-sm);right:var(--spacing-sm);z-index:5;width:28px;height:28px;border-radius:var(--radius-full);border:1px solid var(--primary-cyan);background:var(--bg-darkest);color:var(--primary-cyan);font-size:0.8rem;line-height:1;cursor:pointer;box-shadow:var(--shadow-md);transition:background var(--transition-fast),color var(--transition-fast),box-shadow var(--transition-fast)}.card-play-btn{display:flex;align-items:center;justify-content:center;padding:0}.card-play-btn svg{width:60%;height:60%;fill:currentColor}.card-play-btn:hover,.card-play-btn:focus-visible{background:var(--primary-cyan);color:var(--bg-darkest);box-shadow:var(--shadow-glow-cyan)}.card-play-btn:disabled{cursor:progress}.games-grid-new.bulk-mode .card-play-btn{display:none}.game-card-new.has-play .card-info-section >:first-child{padding-right:calc(28px + var(--spacing-sm))}.card-info-section{flex:1;padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-sm);background:rgba(0,0,0,0.4)}.title-row{display:flex;align-items:flex-start;gap:var(--spacing-sm)}.title-row .game-card-title{flex:1}.game-card-ra-badge{position:absolute;top:8px;left:8px;width:28px;height:28px;background:linear-gradient(135deg,var(--trophy-display-gold) 0%,#ffaa00 100%);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.9rem;z-index:10;box-shadow:0 2px 8px rgba(255,215,0,0.4);border:2px solid rgba(255,255,255,0.3)}.game-card-rating-badge{position:absolute;top:8px;right:8px;z-index:10}.rating-text-badge{font-size:0.65rem;padding:2px 6px;border-radius:var(--radius-sm);font-weight:700;text-transform:uppercase}.rating-text-badge.esrb{background:rgba(0,0,0,0.7);color:var(--text-on-color);border:1px solid var(--text-on-color)}.rating-text-badge.pegi{background:rgba(141,198,63,0.9);color:var(--text-on-color);border:1px solid var(--text-on-color)}.system-badge-row{display:flex;align-items:center;gap:var(--spacing-sm)}.system-badge{padding:3px 8px;background:rgba(76,201,240,0.2);border:1px solid var(--primary-cyan);border-radius:var(--radius-sm);font-size:0.65rem;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:0.5px}.system-badge.nes{background:rgba(198,40,40,0.25);border-color:#e53935;color:#e53935}.system-badge.snes{background:rgba(229,57,53,0.2);border-color:#ef5350;color:#ef5350}.system-badge.n64{background:rgba(244,67,54,0.2);border-color:#f44336;color:#f44336}.system-badge.gc,.system-badge.gamecube{background:rgba(103,58,183,0.2);border-color:#9575cd;color:#9575cd}.system-badge.wii{background:rgba(255,255,255,0.15);border-color:#e0e0e0;color:#e0e0e0}.system-badge.wiiu{background:rgba(33,150,243,0.2);border-color:#42a5f5;color:#42a5f5}.system-badge.switch{background:rgba(229,57,53,0.2);border-color:#ef5350;color:#ef5350}.system-badge.gb{background:rgba(139,195,74,0.25);border-color:#9ccc65;color:#9ccc65}.system-badge.gbc{background:rgba(156,39,176,0.2);border-color:#ba68c8;color:#ba68c8}.system-badge.gba{background:rgba(103,58,183,0.2);border-color:#7e57c2;color:#7e57c2}.system-badge.nds{background:rgba(158,158,158,0.2);border-color:#bdbdbd;color:#bdbdbd}.system-badge.3ds{background:rgba(229,57,53,0.2);border-color:#e57373;color:#e57373}.system-badge.virtualboy,.system-badge.vb{background:rgba(183,28,28,0.3);border-color:#c62828;color:#ef5350}.system-badge.fds{background:rgba(255,193,7,0.2);border-color:#ffc107;color:#ffc107}.system-badge.psx,.system-badge.ps1{background:rgba(144,202,249,0.25);border-color:#90caf9;color:#90caf9}.system-badge.ps2{background:rgba(33,150,243,0.2);border-color:#42a5f5;color:#42a5f5}.system-badge.ps3{background:rgba(21,101,192,0.25);border-color:#1976d2;color:#64b5f6}.system-badge.ps4{background:rgba(13,71,161,0.3);border-color:#1565c0;color:#42a5f5}.system-badge.psp{background:rgba(100,181,246,0.2);border-color:#64b5f6;color:#64b5f6}.system-badge.psvita,.system-badge.vita{background:rgba(30,136,229,0.2);border-color:#1e88e5;color:#42a5f5}.system-badge.sg-1000,.system-badge.sg1000{background:rgba(0,77,64,0.3);border-color:#00897b;color:#4db6ac}.system-badge.mastersystem,.system-badge.sms{background:rgba(0,121,107,0.25);border-color:#00897b;color:#4db6ac}.system-badge.genesis,.system-badge.megadrive{background:rgba(0,150,136,0.2);border-color:#26a69a;color:#26a69a}.system-badge.segacd,.system-badge.megacd{background:rgba(0,137,123,0.25);border-color:#00897b;color:#4db6ac}.system-badge.sega32x,.system-badge.32x{background:rgba(0,105,92,0.3);border-color:#00796b;color:#4db6ac}.system-badge.saturn{background:rgba(128,203,196,0.2);border-color:#80cbc4;color:#80cbc4}.system-badge.dreamcast,.system-badge.dc{background:rgba(255,152,0,0.2);border-color:#ffb74d;color:#ffb74d}.system-badge.gamegear,.system-badge.gg{background:rgba(0,188,212,0.2);border-color:#4dd0e1;color:#4dd0e1}.system-badge.atari2600{background:rgba(230,81,0,0.25);border-color:#ff6d00;color:#ffab40}.system-badge.atari5200{background:rgba(255,111,0,0.2);border-color:#ff9100;color:#ffab40}.system-badge.atari7800{background:rgba(255,143,0,0.2);border-color:#ffa000;color:#ffca28}.system-badge.atarilynx,.system-badge.lynx{background:rgba(255,193,7,0.2);border-color:#ffc107;color:#ffc107}.system-badge.atarijaguar,.system-badge.jaguar{background:rgba(255,87,34,0.2);border-color:#ff7043;color:#ff7043}.system-badge.atarist,.system-badge.st{background:rgba(121,85,72,0.25);border-color:#a1887f;color:#bcaaa4}.system-badge.arcade{background:rgba(233,30,99,0.2);border-color:#ec407a;color:#ec407a}.system-badge.mame{background:rgba(233,30,99,0.2);border-color:#ec407a;color:#ec407a}.system-badge.fbneo{background:rgba(233,30,99,0.2);border-color:#f06292;color:#f06292}.system-badge.cps1,.system-badge.cps2,.system-badge.cps3{background:rgba(216,27,96,0.2);border-color:#e91e63;color:#f48fb1}.system-badge.naomi{background:rgba(194,24,91,0.25);border-color:#d81b60;color:#f48fb1}.system-badge.neogeo{background:rgba(255,193,7,0.25);border-color:#ffc107;color:#ffc107}.system-badge.neogeocd{background:rgba(255,179,0,0.2);border-color:#ffb300;color:#ffc107}.system-badge.ngp{background:rgba(255,213,79,0.2);border-color:#ffd54f;color:#ffd54f}.system-badge.ngpc{background:rgba(255,224,130,0.2);border-color:#ffe082;color:#ffe082}.system-badge.pcengine,.system-badge.tg16{background:rgba(156,39,176,0.2);border-color:#ab47bc;color:#ce93d8}.system-badge.pcenginecd,.system-badge.tg-cd{background:rgba(142,36,170,0.2);border-color:#9c27b0;color:#ce93d8}.system-badge.supergrafx{background:rgba(171,71,188,0.2);border-color:#ba68c8;color:#ce93d8}.system-badge.pcfx{background:rgba(123,31,162,0.25);border-color:#8e24aa;color:#ce93d8}.system-badge.c64{background:rgba(121,85,72,0.25);border-color:#8d6e63;color:#bcaaa4}.system-badge.amiga{background:rgba(141,110,99,0.2);border-color:#a1887f;color:#d7ccc8}.system-badge.vic20{background:rgba(93,64,55,0.3);border-color:#6d4c41;color:#a1887f}.system-badge.xbox{background:rgba(76,175,80,0.2);border-color:#66bb6a;color:#81c784}.system-badge.xbox360{background:rgba(56,142,60,0.25);border-color:#43a047;color:#81c784}.system-badge.3do{background:rgba(255,215,0,0.2);border-color:#ffd700;color:#ffd700}.system-badge.philipscdi,.system-badge.cdi{background:rgba(255,193,7,0.15);border-color:#ffca28;color:#ffca28}.system-badge.dos{background:rgba(96,125,139,0.2);border-color:#78909c;color:#90a4ae}.system-badge.pc{background:rgba(69,90,100,0.25);border-color:#607d8b;color:#90a4ae}.system-badge.pc88,.system-badge.pc98{background:rgba(84,110,122,0.2);border-color:#78909c;color:#b0bec5}.system-badge.msx{background:rgba(0,188,212,0.2);border-color:#00bcd4;color:#4dd0e1}.system-badge.msx2{background:rgba(0,172,193,0.2);border-color:#00acc1;color:#4dd0e1}.system-badge.wonderswan,.system-badge.ws{background:rgba(63,81,181,0.2);border-color:#5c6bc0;color:#7986cb}.system-badge.wonderswancolor,.system-badge.wsc{background:rgba(48,63,159,0.2);border-color:#3f51b5;color:#7986cb}.system-badge.pokemini{background:rgba(255,235,59,0.25);border-color:#ffee58;color:#fff176}.system-badge.nggage,.system-badge.ngage{background:rgba(255,112,67,0.2);border-color:#ff7043;color:#ff8a65}.system-badge.colecovision{background:rgba(183,28,28,0.2);border-color:#c62828;color:#ef5350}.system-badge.intellivision{background:rgba(255,152,0,0.2);border-color:#ff9800;color:#ffb74d}.system-badge.vectrex{background:rgba(255,255,255,0.2);border-color:#eeeeee;color:#eeeeee}.system-badge.scummvm{background:rgba(139,195,74,0.2);border-color:#8bc34a;color:#aed581}.system-badge.daphne{background:rgba(103,58,183,0.2);border-color:#7e57c2;color:#b39ddb}.title-badges{display:flex;gap:4px;flex-shrink:0}.title-badges .ra-badge{width:14px;height:14px;flex-shrink:0;object-fit:contain;filter:drop-shadow(0 0 4px rgba(255,193,7,0.5))}.import-badge.clz-badge{filter:drop-shadow(0 0 4px rgba(168,85,247,0.5))}.import-badge{width:14px;height:14px;flex-shrink:0;object-fit:contain}.import-badge.steam-badge{filter:drop-shadow(0 0 4px rgba(102,192,244,0.5))}.import-badge.xbox-badge{filter:drop-shadow(0 0 4px rgba(16,124,16,0.5))}.import-badge.psn-badge{filter:drop-shadow(0 0 4px rgba(0,55,145,0.5))}.bonus-badge{font-size:0.9rem;filter:drop-shadow(0 0 4px rgba(245,158,11,0.5));flex-shrink:0}.has-bonus-badge{font-size:0.75rem;background:rgba(168,85,247,0.2);border:1px solid var(--neon-purple);color:var(--neon-purple);padding:1px 4px;border-radius:4px;filter:drop-shadow(0 0 4px rgba(168,85,247,0.4));flex-shrink:0}.versions-badge{font-size:0.75rem;background:color-mix(in srgb,var(--neon-cyan) 20%,transparent);border:1px solid var(--neon-cyan);color:var(--neon-cyan);padding:1px 4px;border-radius:4px;flex-shrink:0}.game-card-wrapper.bonus-disc .game-card-new{border-left:3px solid var(--neon-orange);opacity:0.85}.game-card-wrapper.bonus-disc .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(245,158,11,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.clz-import .game-card-new{border-left:3px solid var(--neon-purple)}.game-card-wrapper.clz-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(168,85,247,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.steam-import .game-card-new{border-left:3px solid #66c0f4}.game-card-wrapper.steam-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(102,192,244,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.xbox-import .game-card-new{border-left:3px solid #107c10}.game-card-wrapper.xbox-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(16,124,16,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.psn-import .game-card-new{border-left:3px solid #0070d1}.game-card-wrapper.psn-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(0,112,209,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-title{font-size:0.95rem;font-weight:600;color:var(--text-primary);margin:0;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 12px;flex:1}.meta-item{display:flex;flex-direction:column;gap:1px}.meta-label{font-size:0.6rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.meta-value{font-size:0.75rem;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta-value.meta-clickable{cursor:pointer;padding:2px 6px;margin:-2px -6px;border-radius:var(--radius-sm);transition:all var(--transition-fast);display:inline-block}.meta-value.meta-clickable:hover{background:rgba(76,201,240,0.15);color:var(--neon-cyan)}.rating-badges{display:flex;gap:var(--spacing-sm);flex-wrap:wrap;margin-top:auto}.rating-badge{padding:3px 8px;border-radius:var(--radius-sm);font-size:0.65rem;font-weight:700}.rating-badge.esrb{background:linear-gradient(135deg,var(--bg-medium),#16213e);color:#fff;border:1px solid #4a4a6a}.rating-badge.pegi{background:linear-gradient(135deg,#0f3460,#16213e);color:var(--primary-cyan);border:1px solid var(--primary-cyan)}.game-badge.achievement-progress{gap:4px;font-size:0.65rem;font-weight:700;font-family:var(--font-mono)}.ach-icon{width:14px;height:14px;object-fit:contain;flex-shrink:0;filter:drop-shadow(0 0 3px rgba(255,255,255,0.3))}.game-badge.achievement-progress.ra{background:linear-gradient(135deg,rgba(74,144,217,0.12) 0%,rgba(212,160,23,0.12) 100%);border-image:linear-gradient(135deg,#4A90D9,#D4A017) 1;color:#D4A017}.game-badge.achievement-progress.ra.ach-complete{background:linear-gradient(135deg,rgba(74,144,217,0.2) 0%,rgba(212,160,23,0.2) 100%);color:#e8b820}.game-badge.achievement-progress.steam{background:linear-gradient(135deg,rgba(27,40,56,0.35) 0%,rgba(102,192,244,0.12) 100%);border-image:linear-gradient(135deg,#2A6DAB,#66C0F4) 1;color:#66C0F4}.game-badge.achievement-progress.steam.ach-complete{background:linear-gradient(135deg,rgba(27,40,56,0.45) 0%,rgba(102,192,244,0.2) 100%);color:#88d4f8}.game-badge.achievement-progress.xbox{background:linear-gradient(135deg,rgba(16,124,16,0.12) 0%,rgba(82,176,67,0.12) 100%);border-image:linear-gradient(135deg,#107C10,#52B043) 1;color:#52B043}.game-badge.achievement-progress.xbox.ach-complete{background:linear-gradient(135deg,rgba(16,124,16,0.2) 0%,rgba(82,176,67,0.2) 100%);color:#6dcc5e}.game-badge.achievement-progress.psn{background:linear-gradient(135deg,rgba(0,48,135,0.15) 0%,rgba(108,168,217,0.12) 100%);border-image:linear-gradient(135deg,#0070D1,#6CA8D9) 1;color:#6CA8D9}.game-badge.achievement-progress.psn.ach-complete{background:linear-gradient(135deg,rgba(0,48,135,0.25) 0%,rgba(108,168,217,0.2) 100%);color:#8dc0e8}.game-badge.achievement-progress.rpcs3{background:linear-gradient(135deg,rgba(74,122,140,0.12) 0%,rgba(168,208,224,0.1) 100%);border-image:linear-gradient(135deg,#4A7A8C,#A8D0E0) 1;color:#A8D0E0}.game-badge.achievement-progress.rpcs3.ach-complete{background:linear-gradient(135deg,rgba(74,122,140,0.2) 0%,rgba(168,208,224,0.18) 100%);color:#c0e0ec}.game-badge.achievement-progress.ach-low{opacity:0.7}.ach-bar{display:inline-block;width:30px;height:4px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;vertical-align:middle}.ach-fill{display:block;height:100%;border-radius:2px;transition:width 0.3s ease}.ra .ach-fill{background:linear-gradient(90deg,#4A90D9,#D4A017)}.ra.ach-complete .ach-fill{box-shadow:0 0 4px #D4A017}.steam .ach-fill{background:linear-gradient(90deg,#2A6DAB,#66C0F4)}.steam.ach-complete .ach-fill{box-shadow:0 0 4px #66C0F4}.xbox .ach-fill{background:linear-gradient(90deg,#107C10,#52B043)}.xbox.ach-complete .ach-fill{box-shadow:0 0 4px #52B043}.psn .ach-fill{background:linear-gradient(90deg,#0070D1,#6CA8D9)}.psn.ach-complete .ach-fill{box-shadow:0 0 4px #6CA8D9}.rpcs3 .ach-fill{background:linear-gradient(90deg,#4A7A8C,#A8D0E0)}.rpcs3.ach-complete .ach-fill{box-shadow:0 0 4px #A8D0E0}.game-card-image{width:100%;height:100%;overflow:hidden;background:var(--bg-medium);display:flex;align-items:center;justify-content:center;position:absolute;top:0;left:0;right:0;bottom:0}.game-boxart-img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;transition:transform var(--transition-normal)}.game-boxart-img.boxart-3d{object-fit:contain;padding:4px}.game-card-new:hover .game-boxart-img{transform:scale(1.05)}.game-boxart-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--bg-medium),var(--bg-dark))}.placeholder-icon{font-size:3rem;opacity:0.3}.games-count-bar{display:flex;justify-content:space-between;align-items:center;gap:var(--spacing-md);min-height:3rem;margin-bottom:var(--spacing-lg);padding:var(--spacing-sm) 0;border-bottom:1px solid var(--card-border)}.showing-text{flex-shrink:0;font-size:0.85rem;color:var(--text-muted);letter-spacing:1px}.showing-text span{color:var(--primary-cyan);font-weight:600}.sort-control{display:flex;align-items:center;gap:var(--spacing-sm)}.sort-label{font-size:0.85rem;color:var(--text-muted);white-space:nowrap}.sort-select{padding:var(--spacing-xs) var(--spacing-sm);background-color:var(--bg-light);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.85rem;cursor:pointer}.sort-select:focus-visible{outline:2px solid var(--primary-cyan);outline-offset:1px}#gamesEmpty .clear-btn{display:inline-flex;margin-top:var(--spacing-md)}.alphabet-nav.is-disabled{opacity:0.35}.bulk-action-bar{position:sticky;bottom:var(--spacing-md);z-index:101;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:var(--spacing-md);margin-top:var(--spacing-lg);border-radius:var(--radius-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.bulk-action-bar[hidden]{display:none}.filters-toggle-btn{display:none}.filter-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:2000;align-items:center;justify-content:center}.filter-modal.active{display:flex}.filter-modal-content{width:90%;max-width:500px;max-height:70vh;border-radius:var(--radius-lg);overflow:hidden;display:flex;flex-direction:column}.filter-modal-header{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);gap:var(--spacing-md)}.filter-modal-header h2{color:var(--primary-cyan);font-family:var(--font-heading);margin:0}.filter-mode-toggle{display:flex;border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden}.filter-mode-btn{padding:4px 12px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;font-size:0.85rem;font-weight:600;transition:all var(--transition-fast)}.filter-mode-btn:hover{color:var(--text-primary)}.filter-mode-btn.active[data-mode="include"]{background:rgba(76,201,240,0.2);color:var(--primary-cyan)}.filter-mode-btn.active[data-mode="exclude"]{background:rgba(239,68,68,0.2);color:var(--neon-red)}.filter-modal-body{padding:var(--spacing-lg);overflow-y:auto;flex:1}.filter-options{display:flex;flex-direction:column;gap:var(--spacing-sm)}.filter-option{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);cursor:pointer;transition:all var(--transition-fast)}.filter-option:hover{border-color:var(--primary-cyan);background:rgba(76,201,240,0.1)}.filter-modal.exclude-mode .filter-option:hover{border-color:var(--neon-red);background:rgba(239,68,68,0.1)}.filter-option-name{color:var(--text-primary)}.filter-option-count{color:var(--text-muted);font-size:0.85rem}button.filter-option{width:100%;font:inherit;color:inherit;text-align:left}.filter-option[aria-checked]::before{content:'';flex:0 0 auto;width:1.1rem;height:1.1rem;margin-right:var(--spacing-sm);border:1px solid var(--card-border);border-radius:var(--radius-sm);line-height:1.1rem;text-align:center}.filter-option[aria-checked] .filter-option-name{flex:1}.filter-option[aria-checked="true"]{border-color:var(--primary-cyan);background:rgba(76,201,240,0.15)}.filter-option[aria-checked="true"]::before{content:'✓';color:var(--primary-cyan);border-color:var(--primary-cyan)}.filter-modal.exclude-mode .filter-option[aria-checked="true"]{border-color:var(--neon-red);background:rgba(239,68,68,0.15)}.filter-modal.exclude-mode .filter-option[aria-checked="true"]::before{content:'✕';color:var(--neon-red);border-color:var(--neon-red)}.filter-modal-footer{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-md);padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border)}.filter-modal-footer[hidden]{display:none}.filter-modal-hint{color:var(--text-muted);font-size:0.85rem}.filter-modal .hint-exclude,.filter-modal.exclude-mode .hint-include{display:none}.filter-modal.exclude-mode .hint-exclude{display:inline}@media (max-width:768px){.games-grid-new{grid-template-columns:1fr;grid-auto-rows:auto;row-gap:var(--spacing-lg)}.game-card-wrapper{grid-row-end:auto}.game-card-new{min-height:180px}.card-boxart-section{width:100px}.meta-grid{grid-template-columns:1fr}.filter-bar-top{flex-wrap:wrap}.filter-buttons{width:100%;justify-content:flex-start}.filters-toggle-btn{display:flex}.filter-bar-new .filter-buttons{display:none;position:absolute;top:100%;left:0;right:0;margin-top:var(--spacing-xs);padding:var(--spacing-md);border:1px solid var(--card-border);border-radius:var(--radius-lg);background:var(--bg-medium);box-shadow:0 8px 24px rgba(0,0,0,0.5)}.filter-bar-new.filters-open .filter-buttons{display:flex}.games-count-bar{flex-wrap:wrap}.games-count-bar .active-filter-tags{order:3;flex-basis:100%;min-height:1.75rem}.filter-btn{flex:1;min-width:80px;justify-content:center}.bulk-controls{width:100%;justify-content:flex-start}.glitch-text::before,.glitch-text::after{display:none}}.game-page-header{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg);position:relative;z-index:1}.game-page-title{font-family:var(--font-display);font-size:2rem;color:var(--primary-cyan);text-shadow:0 0 30px rgba(76,201,240,0.3);margin:0}.header-buttons{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.play-key-hint{align-self:center;font-size:0.8rem;color:var(--text-secondary)}.last-scan-indicator{display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-md);font-size:0.8rem;color:var(--text-secondary)}.last-scan-indicator .scan-icon{font-size:1rem}.last-scan-indicator .scan-time{color:var(--primary-cyan);font-weight:500}.report-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.report-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1.25rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.report-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.report-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.report-tab .tab-count{font-size:0.75rem;padding:0.15rem 0.5rem;background:rgba(0,0,0,0.3);border-radius:var(--radius-full);color:var(--text-secondary)}.report-tab.active .tab-count{background:var(--primary-cyan);color:var(--bg-darkest)}.report-tab .tab-icon{font-size:1.1rem}.report-panel{display:none}.report-panel.active{display:block;animation:fadeIn 0.3s ease}@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.results-table-container{background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden}.settings-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.settings-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.settings-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.settings-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.settings-tab .tab-icon{font-size:1.1rem}.settings-tab-panel{display:none}.settings-tab-panel.active{display:block}.naming-tag-option{padding:0.25rem 0.6rem;border-radius:4px;font-size:0.8rem;font-weight:600;background:rgba(100,116,139,0.15);border:1px solid rgba(100,116,139,0.3);color:var(--text-secondary);cursor:pointer;transition:all 0.15s ease}.naming-tag-option:hover{border-color:var(--neon-green);color:var(--neon-green)}.parent-option{padding:var(--spacing-sm);border-bottom:1px solid var(--card-border);cursor:pointer}.parent-option:hover{background:rgba(76,201,240,0.1)}.tab-subnav{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:rgba(0,0,0,0.75);border-radius:var(--radius-md);position:sticky;top:calc(var(--sticky-top) + 54px);z-index:99;backdrop-filter:blur(10px)}.subnav-link{padding:var(--spacing-xs) var(--spacing-md);color:var(--text-secondary);text-decoration:none;font-size:0.85rem;border-radius:var(--radius-sm);transition:all var(--transition-fast)}.subnav-link:hover{background:rgba(76,201,240,0.1);color:var(--text-primary)}.subnav-link.active{background:rgba(76,201,240,0.15);color:var(--primary-cyan)}.achievement-overview{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg)}.achievement-stat{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md)}.achievement-stat-icon{font-size:2rem}.achievement-stat-info{display:flex;flex-direction:column}.achievement-stat-label{font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.achievement-stat-value{font-size:1.3rem;font-weight:600;color:var(--neon-cyan)}.dashboard-page{--card-min:280px;--card-gap:var(--spacing-lg)}.systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min,280px),100%),1fr));gap:var(--card-gap,var(--spacing-lg))}.system-card{display:flex;flex-direction:column;padding:var(--spacing-lg);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-lg);text-decoration:none;color:inherit;transition:all var(--transition-fast)}.system-card:hover{border-color:var(--neon-cyan);transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,0.3),0 0 15px rgba(76,201,240,0.15)}.system-card-header{margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.system-name{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);margin:0}.system-card-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.system-stat{display:flex;flex-direction:column;align-items:center;text-align:center}.system-stat-value{font-size:1.2rem;font-weight:600;color:var(--neon-cyan)}.system-stat-label{font-size:0.7rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.3px}.system-card-action{margin-top:auto;padding-top:var(--spacing-md);text-align:center;color:var(--neon-cyan);font-size:0.9rem;font-weight:500;opacity:0.7;transition:opacity 0.2s}.system-card:hover .system-card-action{opacity:1}.filter-group{display:flex;align-items:center;gap:0.5rem}.filter-group label{color:var(--text-secondary);font-size:0.85rem;white-space:nowrap}.filter-group select,.filter-group input{padding:0.5rem 0.75rem;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:0.85rem}.filter-group select:focus,.filter-group input:focus{outline:none;border-color:var(--primary-cyan)}.alert{padding:var(--spacing-md) var(--spacing-lg);border-radius:var(--radius-md);margin-bottom:var(--spacing-md)}.alert-warning{background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);color:var(--neon-orange)}.alert-danger{background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);color:var(--neon-red)}.alert-success{background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);color:var(--neon-green)}.alert-info{background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);color:var(--primary-cyan)}.alert a{color:inherit;text-decoration:underline}.field-tip{display:block;margin-top:var(--spacing-xs);font-size:0.8rem;color:var(--text-muted)}.changelog-entry{margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-xl);border-bottom:1px solid var(--card-border)}.changelog-entry:last-child{border-bottom:none}.changelog-version{font-family:var(--font-heading);font-size:1.5rem;color:var(--primary-cyan);margin-bottom:var(--spacing-xs)}.changelog-date{font-size:0.85rem;color:var(--text-muted);margin-bottom:var(--spacing-md)}.changelog-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-xs);margin-bottom:var(--spacing-md)}.changelog-tag{padding:4px 12px;border-radius:var(--radius-full);font-size:0.75rem;font-weight:600;text-transform:uppercase}.tag-feature{background:rgba(76,201,240,0.15);border:1px solid var(--primary-cyan);color:var(--primary-cyan)}.tag-minor{background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);color:var(--neon-purple)}.tag-patch{background:rgba(245,158,11,0.15);border:1px solid var(--neon-orange);color:var(--neon-orange)}.tag-fix{background:rgba(239,68,68,0.15);border:1px solid var(--neon-red);color:var(--neon-red)}.tag-major{background:rgba(247,37,133,0.15);border:1px solid var(--secondary-magenta);color:var(--secondary-magenta)}.tag-ui{background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);color:var(--neon-purple)}.tag-improvement{background:rgba(76,201,240,0.15);border:1px solid var(--primary-cyan);color:var(--primary-cyan)}.tag-enhancement{background:rgba(34,197,94,0.15);border:1px solid var(--neon-green);color:var(--neon-green)}.tag-initial{background:rgba(247,37,133,0.15);border:1px solid var(--secondary-magenta);color:var(--secondary-magenta)}.changelog-list{list-style:none;padding:0;margin:0}.changelog-list li{padding:var(--spacing-xs) 0;padding-left:var(--spacing-lg);position:relative;color:var(--text-secondary)}.changelog-list li::before{content:'•';position:absolute;left:0;color:var(--primary-cyan)}.login-container{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:var(--spacing-lg)}.login-box{width:100%;max-width:400px;padding:var(--spacing-xl);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);box-shadow:0 0 40px rgba(76,201,240,0.1)}.login-header{text-align:center;margin-bottom:var(--spacing-xl)}.login-logo{font-family:var(--font-heading);font-size:2rem;color:var(--primary-cyan);margin-bottom:var(--spacing-xs)}.login-subtitle{color:var(--text-secondary);font-size:0.9rem}.help-page{--help-text-max:calc(75ch + 2 * var(--spacing-lg));--help-nav-width:220px;--page-max:var(--help-text-max)}.help-page > .help-nav{flex-wrap:nowrap;overflow-x:auto}@media (max-width:600px){.help-page code{overflow-wrap:anywhere}}@media (min-width:1300px){.help-page{--page-max:calc(var(--help-nav-width) + var(--spacing-xl) + var(--help-text-max));display:grid;grid-template-columns:var(--help-nav-width) minmax(0,1fr);column-gap:var(--spacing-xl);align-items:start}.help-page > *{grid-column:2}.help-page > .page-header{grid-column:1 / -1}.help-page > .help-nav{grid-column:1;grid-row:2 / span 100;flex-direction:column;flex-wrap:nowrap;margin:0;max-height:100vh;overflow-x:hidden;overflow-y:auto}.help-page .help-nav-item{white-space:normal}}.help-section{margin-bottom:var(--spacing-xl)}.help-section h2{font-family:var(--font-heading);font-size:1.25rem;color:var(--primary-cyan);margin-bottom:var(--spacing-md)}.help-item{padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.help-item h3{color:var(--text-primary);font-size:1rem;margin-bottom:var(--spacing-xs)}.help-item p{color:var(--text-secondary);font-size:0.9rem;margin:0}.tool-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg)}.tool-description{color:var(--text-secondary);font-size:0.95rem;margin-bottom:var(--spacing-lg);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);border-left:3px solid var(--primary-cyan)}.tool-results{margin-top:var(--spacing-lg)}.tool-result-item{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.tool-result-item:hover{border-color:var(--primary-cyan)}.tool-result-icon{font-size:1.5rem}.tool-result-info{flex:1}.tool-result-name{color:var(--text-primary);font-weight:500}.tool-result-detail{color:var(--text-muted);font-size:0.85rem}@media (max-width:768px){.settings-tabs,.report-tabs{gap:var(--spacing-xs);padding:var(--spacing-xs)}.settings-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.settings-tab .tab-text{display:none}.settings-tab .tab-icon{font-size:1.25rem}.report-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.report-tab .tab-label{display:none}.report-tab .tab-icon{font-size:1.25rem}}.game-fanart-bg{position:fixed;top:0;left:0;width:100%;height:100%;background-size:cover;background-position:center;opacity:0;z-index:-1;transition:opacity 0.5s ease}.game-fanart-bg.active{opacity:0.15}.game-fanart-bg::after{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(to bottom,transparent 0%,var(--bg-darkest) 100%)}.game-compact-layout{max-width:100%}.game-hero{display:flex;gap:var(--spacing-xl);margin-bottom:var(--spacing-xl);background:color-mix(in srgb,var(--bg-medium) 85%,transparent);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.hero-boxart{position:relative;flex-shrink:0;width:200px}.hero-boxart-frame{position:relative}.upscale-game-note{margin:var(--spacing-sm) 0 0;font-size:0.75rem;color:var(--text-secondary)}.game-boxart-image{width:100%;height:auto;border-radius:var(--radius-md);box-shadow:0 8px 32px rgba(0,0,0,0.4)}.hero-age-ratings{display:flex;flex-wrap:wrap;gap:5px;margin-top:auto;padding-top:var(--spacing-sm)}.hero-info{flex:1;min-width:0;display:flex;flex-direction:column}.quick-tags{display:flex;flex-wrap:wrap;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.tag{padding:4px 10px;border-radius:4px;font-size:0.8rem;font-weight:500;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1)}.quick-tags .tag,.quick-tags .game-badge{height:32px;box-sizing:border-box;line-height:1}.quick-tags .game-badge.age-rating-badge .age-rating-img{height:32px}.tag-system{color:var(--primary-cyan);border-color:var(--primary-cyan)}.hero-description{flex:1;font-size:0.9rem;line-height:1.6;color:var(--text-secondary);margin-bottom:var(--spacing-md);overflow-y:auto;max-height:120px}.key-details{display:flex;flex-wrap:wrap;gap:var(--spacing-lg);padding-top:var(--spacing-md);border-top:1px solid var(--card-border)}.kd-item{display:flex;flex-direction:column;gap:2px}.kd-label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase}.kd-value{font-size:0.9rem;color:var(--text-primary)}.trophy-summary{text-align:center}.trophy-summary-header{display:flex;justify-content:center;align-items:center;gap:var(--spacing-lg);margin-bottom:var(--spacing-md)}.trophy-summary-title{font-size:1.1rem;font-weight:600}.trophy-summary-total{font-size:1.3rem;font-weight:bold;color:var(--primary-cyan)}.trophy-summary-progress{display:flex;align-items:center;gap:var(--spacing-md);max-width:500px;margin:0 auto var(--spacing-lg)}.trophy-progress-bar.large{flex:1;height:12px;background:rgba(255,255,255,0.1);border-radius:6px;overflow:hidden}.trophy-progress-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),#00ddff);border-radius:6px}.trophy-progress-percent{font-size:1.2rem;font-weight:bold;color:var(--primary-cyan);min-width:60px}.trophy-summary-counts{display:flex;justify-content:center;gap:var(--spacing-xl);flex-wrap:wrap}.trophy-count{display:flex;align-items:center;gap:var(--spacing-sm);font-size:1.1rem;font-weight:600}.trophy-count img{width:28px;height:28px}.trophy-count.platinum{color:var(--trophy-display-platinum)}.trophy-count.gold{color:var(--trophy-display-gold)}.trophy-count.silver{color:var(--trophy-display-silver)}.trophy-count.bronze{color:var(--trophy-display-bronze)}.bottom-row{display:flex;gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.bottom-section{flex:1;background:color-mix(in srgb,var(--bg-medium) 85%,transparent);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-md)}.section-title-sm{font-size:0.9rem;color:var(--text-secondary);margin-bottom:var(--spacing-md)}.hltb-section{display:flex;flex-direction:column;gap:var(--spacing-sm)}.more-details{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-sm)}.md-item{display:flex;flex-direction:column}.md-item.md-inline{flex-direction:row;align-items:baseline;gap:var(--spacing-sm);grid-column:span 2}.md-label{font-size:0.7rem;color:var(--text-muted);text-transform:uppercase}.md-value{font-size:0.85rem;color:var(--text-primary)}.trophy-columns{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:650px;gap:var(--spacing-xl)}.trophy-columns > .card{height:100%;display:flex;flex-direction:column;overflow:hidden}.trophy-columns > .card + .card{margin-top:0}.trophy-columns > .card .card-header{flex-shrink:0}.trophy-column .card-body{flex:1;overflow-y:auto;min-height:0}.trophy-list{display:flex;flex-direction:column;gap:var(--spacing-md)}.trophy-item{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.trophy-item.locked{opacity:0.5}.trophy-item.locked .trophy-item-icon img:first-child{filter:grayscale(1) brightness(0.5)}.trophy-item-icon{position:relative;flex-shrink:0}.trophy-item-icon > img:first-child{width:60px;height:60px;border-radius:var(--radius-sm);background:rgba(0,0,0,0.3);object-fit:cover}.trophy-item-icon > img.trophy-type-badge{position:absolute;bottom:-4px;right:-4px;width:24px;height:24px}.trophy-item-icon .badge-placeholder{width:60px;height:60px;border-radius:var(--radius-sm);background:rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:1.5rem}.trophy-item-info{flex:1;min-width:0}.trophy-item-name{font-weight:600;font-size:0.95rem;margin-bottom:4px}.trophy-item-desc{font-size:0.85rem;color:var(--text-secondary);margin-bottom:8px}.trophy-item-meta{display:flex;gap:var(--spacing-md);align-items:center;flex-wrap:wrap;font-size:0.8rem}.trophy-type-label{padding:2px 8px;border-radius:4px;font-weight:600;text-transform:uppercase;font-size:0.7rem}.trophy-type-label.p{background:var(--trophy-display-platinum);color:var(--trophy-display-text)}.trophy-type-label.g{background:var(--trophy-display-gold);color:var(--trophy-display-text)}.trophy-type-label.s{background:var(--trophy-display-silver);color:var(--trophy-display-text)}.trophy-type-label.b{background:var(--trophy-display-bronze);color:var(--trophy-display-text)}.trophy-timestamp{color:var(--primary-cyan)}.trophy-empty{text-align:center;padding:var(--spacing-xl);color:var(--text-muted);font-style:italic}.hltb-search-input{width:100%;padding:10px 14px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;margin-bottom:var(--spacing-sm)}.hltb-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.btn-hltb{width:100%;display:flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:10px 16px;background:rgba(76,201,240,0.1);border:1px solid var(--primary-cyan);border-radius:var(--radius-md);color:var(--primary-cyan);font-family:var(--font-primary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.btn-hltb:hover{background:rgba(76,201,240,0.2)}.btn-hltb:disabled{opacity:0.5;cursor:not-allowed}@media (max-width:900px){.trophy-columns{grid-template-columns:1fr;grid-template-rows:auto}.trophy-columns > .card{height:auto;max-height:500px}.game-hero{flex-direction:column;align-items:center}.hero-boxart{width:150px}.bottom-row{flex-direction:column}}.alphabet-nav{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);position:sticky;top:var(--sticky-top);z-index:99}.alphabet-label{color:var(--text-muted);font-size:0.8rem;white-space:nowrap}.alphabet-letters{display:flex;flex-wrap:wrap;gap:3px}.alphabet-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:rgba(76,201,240,0.08);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-sm);color:var(--text-secondary);font-size:0.75rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast);padding:0}.alphabet-btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.active{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.disabled{opacity:0.25;cursor:not-allowed}.alphabet-btn.disabled:hover{background:rgba(76,201,240,0.08);border-color:rgba(76,201,240,0.2);color:var(--text-secondary)}@media (max-width:768px){.alphabet-nav{padding:var(--spacing-xs) var(--spacing-md)}.alphabet-btn{width:24px;height:24px;font-size:0.65rem}.alphabet-label{display:none}}.warning-banner{background:linear-gradient(135deg,rgba(255,193,7,0.1),rgba(247,37,133,0.1));border:1px solid rgba(255,193,7,0.3);border-radius:var(--radius-md);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;align-items:flex-start;gap:1rem}.warning-banner .warning-icon{font-size:1.5rem}.warning-banner .warning-text h4{color:var(--neon-orange);margin-bottom:0.25rem}.warning-banner .warning-text p{color:var(--text-secondary);font-size:0.9rem;margin:0}.info-banner{background:linear-gradient(135deg,rgba(76,201,240,0.1),rgba(114,9,183,0.1));border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-md);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;align-items:flex-start;gap:1rem}.info-banner .info-icon{font-size:1.5rem}.info-banner .info-text h4{color:var(--text-primary);margin-bottom:0.25rem}.info-banner .info-text p{color:var(--text-secondary);font-size:0.9rem;margin:0}.file-list{background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);max-height:400px;overflow-y:auto}.file-list-header{display:grid;gap:1rem;padding:0.75rem 1rem;background:var(--bg-medium);border-bottom:1px solid var(--border-color);font-size:0.75rem;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);position:sticky;top:0;z-index:10}.file-list-item{display:grid;gap:1rem;padding:0.75rem 1rem;border-bottom:1px solid var(--border-color);font-size:0.9rem;align-items:center}.file-list-item:hover{background:rgba(76,201,240,0.05)}.file-list-item:last-child{border-bottom:none}.file-list-item input[type="checkbox"]{width:18px;height:18px;accent-color:var(--neon-cyan);cursor:pointer}.file-list-item .filename{color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-list-item .filesize{color:var(--text-secondary)}.action-bar{display:flex;gap:1rem;margin-top:1rem;padding:1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md)}.action-btn{padding:0.4rem 0.75rem;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-sm);color:var(--neon-cyan);cursor:pointer;font-size:0.8rem;transition:all var(--transition-fast)}.action-btn:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 10px rgba(76,201,240,0.3)}.theme-selector{display:flex;gap:var(--spacing-md);flex-wrap:wrap}.theme-option{display:flex;flex-direction:column;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-md);background:var(--card-bg);border:2px solid var(--card-border);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--transition-fast),box-shadow var(--transition-fast);min-width:110px}.theme-option:hover{border-color:var(--card-border-hover)}.theme-option.active{border-color:var(--primary-cyan);box-shadow:0 0 12px var(--primary-cyan-glow)}.theme-preview{display:flex;gap:4px;padding:4px;background:rgba(0,0,0,0.3);border-radius:var(--radius-sm)}.theme-swatch{width:24px;height:24px;border-radius:3px}.theme-name{font-family:var(--font-primary);font-size:0.85rem;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px}.theme-option.active .theme-name{color:var(--primary-cyan)}.section-title{font-family:var(--font-display);font-size:1rem;color:var(--text-primary);margin-bottom:1rem;padding-bottom:0.5rem;border-bottom:1px solid var(--border-color);display:flex;align-items:center;gap:0.5rem}.emu-folder-list{list-style:none;margin:0 0 var(--spacing-md);padding:0}.emu-folder-list li{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-sm);padding:var(--spacing-sm) 0;border-bottom:1px solid var(--border-color)}.emu-folder-list code{overflow-wrap:anywhere}.emu-folder-add{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.emu-folder-add .form-input{flex:1 1 16rem}.museum-progress{padding:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.museum-progress-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-sm)}.museum-progress-title{font-family:var(--font-heading);font-size:0.95rem;color:var(--primary-cyan)}.museum-progress-stats{font-size:0.85rem;color:var(--text-secondary);margin-top:var(--spacing-sm)}.museum-progress-stats .text-success{color:var(--status-success)}.museum-progress-stats .text-warning{color:var(--status-warning)}.museum-progress-stats .text-error{color:var(--status-error)}.museum-timeline{display:flex;flex-direction:column;gap:var(--spacing-xl)}.museum-era{position:relative}.museum-era-header{display:flex;align-items:center;gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.museum-era-line{width:4px;height:48px;background:linear-gradient(180deg,var(--primary-cyan),transparent);border-radius:2px;flex-shrink:0}.museum-era-info{display:flex;flex-direction:column;gap:2px}.museum-era-title{font-family:var(--font-heading);font-size:1.3rem;color:var(--primary-cyan);margin:0;text-shadow:0 0 20px rgba(76,201,240,0.3)}.museum-era-date{font-family:var(--font-mono);font-size:0.8rem;color:var(--text-muted)}.museum-era-count{font-size:0.8rem;color:var(--text-secondary)}.museum-page{--card-min:260px;--card-gap:var(--spacing-md)}.museum-systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.museum-system-card{display:flex;flex-direction:column;position:relative;border-radius:var(--radius-lg);overflow:hidden;text-decoration:none;color:inherit;transition:transform var(--transition-normal),box-shadow var(--transition-normal)}.museum-system-card:hover{transform:translateY(-3px);box-shadow:0 0 20px rgba(76,201,240,0.15);border-color:var(--card-border-hover)}.museum-card-image{height:140px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.2);padding:var(--spacing-md)}.museum-card-image img{max-height:100%;max-width:100%;object-fit:contain}.museum-card-placeholder{font-size:3rem;opacity:0.3}.museum-card-body{padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-xs);flex:1}.museum-card-title{font-family:var(--font-heading);font-size:0.9rem;color:var(--text-primary);margin:0;line-height:1.3}.museum-card-summary{font-size:0.78rem;color:var(--text-secondary);margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.4}.museum-card-meta{display:flex;flex-wrap:wrap;gap:4px;margin-top:auto}.museum-meta-chip{display:inline-block;padding:2px 8px;font-size:0.7rem;font-family:var(--font-mono);color:var(--text-secondary);background:rgba(76,201,240,0.08);border:1px solid rgba(76,201,240,0.15);border-radius:var(--radius-full);white-space:nowrap}.museum-content-indicator{position:absolute;top:var(--spacing-sm);right:var(--spacing-sm);width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-full);background:rgba(34,197,94,0.2);color:var(--neon-green);font-size:0.7rem}.museum-system-header{margin-bottom:var(--spacing-xl)}.museum-hero-layout{display:flex;gap:var(--spacing-xl);align-items:flex-start}.museum-hero-image{flex-shrink:0;width:240px;height:200px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.2);border-radius:var(--radius-lg);border:1px solid var(--card-border);padding:var(--spacing-md)}.museum-hero-image img{max-height:100%;max-width:100%;object-fit:contain}.museum-hero-placeholder{font-size:4rem;opacity:0.3}.museum-hero-info{flex:1;display:flex;flex-direction:column;gap:var(--spacing-sm)}.museum-hero-title{font-family:var(--font-heading);font-size:2rem;color:var(--primary-cyan);margin:0;text-shadow:0 0 30px rgba(76,201,240,0.3)}.museum-hero-summary{font-size:1rem;color:var(--text-secondary);line-height:1.5;margin:0}.museum-hero-meta{display:flex;flex-wrap:wrap;gap:var(--spacing-xs)}.museum-hero-actions{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-sm);flex-wrap:wrap}.museum-nav{display:flex;gap:0;border-bottom:2px solid var(--border-color);margin-bottom:var(--spacing-lg);position:sticky;background:var(--bg-darkest);z-index:10;padding-top:var(--spacing-xs)}.museum-nav-item{padding:var(--spacing-sm) var(--spacing-lg);font-family:var(--font-heading);font-size:0.85rem;color:var(--text-secondary);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-2px;transition:color var(--transition-fast),border-color var(--transition-fast);white-space:nowrap}.museum-nav-item:hover{color:var(--text-primary)}.museum-nav-item.active{color:var(--primary-cyan);border-bottom-color:var(--primary-cyan)}.museum-section{margin-bottom:var(--spacing-xl)}.museum-section-actions{display:flex;align-items:center;flex-wrap:wrap;gap:var(--spacing-sm)}.museum-section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md);flex-wrap:wrap;gap:var(--spacing-sm)}.museum-section-title{font-family:var(--font-heading);font-size:1.2rem;color:var(--primary-cyan);margin:0}.museum-history-content{max-width:calc(75ch + 2 * var(--spacing-lg));padding:var(--spacing-lg);line-height:1.75;color:var(--text-primary);font-size:0.95rem}.museum-history-content p{margin:0 0 var(--spacing-md) 0}.museum-history-content p:last-child{margin-bottom:0}.museum-placeholder{padding:var(--spacing-xl);text-align:center}.museum-placeholder .text-muted{color:var(--text-muted)}.museum-generated-badge{font-size:0.75rem;font-family:var(--font-mono);color:var(--text-muted);padding:4px 10px;background:rgba(76,201,240,0.06);border:1px solid rgba(76,201,240,0.12);border-radius:var(--radius-full)}.museum-ai-badge{display:inline-block;padding:2px 8px;font-size:0.7rem;font-family:var(--font-mono);color:var(--neon-purple);background:rgba(168,85,247,0.1);border:1px solid rgba(168,85,247,0.2);border-radius:var(--radius-full)}.museum-system-page{--card-min:280px;--card-gap:var(--spacing-md)}.museum-specs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.museum-spec-item{padding:var(--spacing-md);display:flex;flex-direction:column;gap:4px}.museum-spec-label{font-family:var(--font-heading);font-size:0.75rem;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:0.5px}.museum-spec-value{font-size:0.9rem;color:var(--text-primary);font-family:var(--font-mono);word-break:break-word}.museum-top-games-list{display:flex;flex-direction:column;gap:var(--spacing-sm)}.museum-top-game{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);transition:border-color var(--transition-fast)}.museum-top-game:hover{border-color:var(--card-border-hover)}.museum-rank{font-family:var(--font-heading);font-size:1.3rem;color:var(--primary-cyan);min-width:40px;text-align:center;flex-shrink:0}.museum-top-game-art{width:48px;height:64px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);overflow:hidden;background:rgba(0,0,0,0.2)}.museum-top-game-art img{max-height:100%;max-width:100%;object-fit:cover}.museum-top-game-placeholder{font-size:1.5rem;opacity:0.3}.museum-top-game-info{flex:1;display:flex;flex-direction:column;gap:2px}.museum-top-game-title{font-family:var(--font-primary);font-weight:600;font-size:0.95rem;color:var(--text-primary);text-decoration:none}a.museum-top-game-title:hover{color:var(--primary-cyan)}.museum-top-game-meta{display:flex;gap:var(--spacing-xs);align-items:center}.museum-controllers-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--spacing-md)}.museum-controller-card{display:flex;flex-direction:column;overflow:hidden;border-radius:var(--radius-lg)}.museum-controller-image{height:180px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.15);padding:var(--spacing-md);position:relative}.museum-controller-image img{max-height:100%;max-width:100%;object-fit:contain;cursor:zoom-in}.museum-controller-image-actions{display:flex;gap:4px}.museum-controller-image-actions .btn{padding:2px 6px;font-size:0.65rem;line-height:1.2;letter-spacing:0.5px;backdrop-filter:blur(8px);background:rgba(0,0,0,0.6);border-color:rgba(76,201,240,0.4);color:var(--primary-cyan);box-shadow:none;text-shadow:none}.museum-controller-image-actions .btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan)}.museum-controller-image > .museum-controller-image-actions{position:absolute;bottom:var(--spacing-xs);right:var(--spacing-xs);opacity:0;transition:opacity var(--transition-fast)}.museum-controller-image:hover > .museum-controller-image-actions{opacity:1}.museum-controller-upload-overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--spacing-sm);background:rgba(10,14,23,0.85);z-index:5;border-radius:inherit}.museum-controller-upload-text{font-family:var(--font-primary);font-size:0.75rem;color:var(--text-secondary);letter-spacing:0.5px}.museum-lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.95);z-index:10002;align-items:center;justify-content:center;flex-direction:column}.museum-lightbox.active{display:flex}.museum-lightbox-image{max-width:90%;max-height:80vh;object-fit:contain;border-radius:var(--radius-md);position:relative;z-index:1}.museum-lightbox::before{content:"";position:absolute;top:45%;left:50%;transform:translate(-50%,-50%);width:90vmin;height:90vmin;background:radial-gradient(circle at center,rgba(76,201,240,0.19) 0%,rgba(76,201,240,0.09) 25%,rgba(76,201,240,0.03) 45%,transparent 40%);border-radius:50%;pointer-events:none;z-index:0}.museum-lightbox-close{position:absolute;top:20px;right:35px;font-size:2.5rem;color:var(--text-primary);cursor:pointer;z-index:10003;transition:color var(--transition-fast);background:none;border:none;line-height:1}.museum-lightbox-close:hover{color:var(--primary-cyan)}.museum-lightbox-title{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);color:var(--text-secondary);font-family:var(--font-heading);font-size:0.9rem;white-space:nowrap}.museum-lightbox-hint{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);color:var(--text-muted);font-size:0.75rem;white-space:nowrap}.museum-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.5);border:2px solid var(--card-border);color:var(--text-primary);font-size:3rem;padding:10px 20px;cursor:pointer;border-radius:var(--radius-md);transition:all var(--transition-fast);z-index:10003}.museum-lightbox-nav:hover{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.museum-lightbox-nav.prev{left:20px}.museum-lightbox-nav.next{right:20px}.museum-controller-placeholder{display:flex;flex-direction:column;align-items:center;gap:var(--spacing-sm);color:var(--text-muted);font-size:2.5rem}.museum-controller-info{padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-xs)}.museum-controller-name{font-family:var(--font-heading);font-size:0.85rem;color:var(--text-primary);margin:0}.museum-controller-meta{display:flex;flex-wrap:wrap;gap:4px}.museum-controller-desc{font-size:0.8rem;color:var(--text-secondary);margin:var(--spacing-xs) 0 0 0;line-height:1.4}.museum-controller-buttons{margin-top:var(--spacing-xs);font-size:0.8rem;color:var(--text-secondary)}.museum-controller-buttons summary{cursor:pointer;color:var(--primary-cyan);font-family:var(--font-heading);font-size:0.75rem}.museum-controller-buttons p{margin:var(--spacing-xs) 0 0 0;font-family:var(--font-mono);font-size:0.75rem;line-height:1.5}@media (max-width:768px){.museum-hero-layout{flex-direction:column;align-items:center;text-align:center}.museum-hero-image{width:180px;height:150px}.museum-hero-meta,.museum-hero-actions{justify-content:center}.museum-systems-grid{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}.museum-specs-grid{grid-template-columns:1fr}.museum-controllers-grid{grid-template-columns:1fr}.museum-nav{overflow-x:auto;-webkit-overflow-scrolling:touch}}body::before{content:'';position:fixed;top:0;left:0;width:100%;height:100%;background:linear-gradient( to top right,rgba(76,201,240,0.08) 0%,rgba(114,9,183,0.06) 25%,rgba(247,37,133,0.04) 50%,rgba(20,26,35,0.02) 75%,transparent 100% );pointer-events:none;z-index:-2}body::after{content:'';position:fixed;top:0;left:0;width:100%;height:100%;background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,0,0,0.03) 2px,rgba(0,0,0,0.03) 4px );pointer-events:none;z-index:9999}@media (prefers-reduced-motion:reduce){body::after{display:none}}@keyframes glow-pulse{0%,100%{text-shadow:0 0 10px var(--primary-cyan-glow),0 0 20px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow)}50%{text-shadow:0 0 15px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow),0 0 45px var(--primary-cyan-glow)}}@keyframes pulse-status{0%,100%{opacity:1}50%{opacity:0.5}}@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes highlight-pulse{0%,100%{box-shadow:0 0 0 0 rgba(76,201,240,0)}50%{box-shadow:0 0 20px 5px rgba(76,201,240,0.4)}}.highlight-jump{animation:highlight-pulse 0.75s ease-in-out 2}@keyframes modalSlideIn{from{opacity:0;transform:scale(0.9) translateY(-20px)}to{opacity:1;transform:scale(1) translateY(0)}}@media (prefers-reduced-motion:reduce){.highlight-jump{animation:none}}.mt-0{margin-top:0}.mt-xs{margin-top:var(--spacing-xs)}.mt-sm{margin-top:var(--spacing-sm)}.mt-md{margin-top:var(--spacing-md)}.mt-lg{margin-top:var(--spacing-lg)}.mt-xl{margin-top:var(--spacing-xl)}.mt-2xl{margin-top:var(--spacing-2xl)}.mb-0{margin-bottom:0}.mb-xs{margin-bottom:var(--spacing-xs)}.mb-sm{margin-bottom:var(--spacing-sm)}.mb-md{margin-bottom:var(--spacing-md)}.mb-lg{margin-bottom:var(--spacing-lg)}.mb-xl{margin-bottom:var(--spacing-xl)}.mb-2xl{margin-bottom:var(--spacing-2xl)}.ml-0{margin-left:0}.ml-xs{margin-left:var(--spacing-xs)}.ml-sm{margin-left:var(--spacing-sm)}.ml-md{margin-left:var(--spacing-md)}.ml-lg{margin-left:var(--spacing-lg)}.ml-xl{margin-left:var(--spacing-xl)}.mr-0{margin-right:0}.mr-xs{margin-right:var(--spacing-xs)}.mr-sm{margin-right:var(--spacing-sm)}.mr-md{margin-right:var(--spacing-md)}.mr-lg{margin-right:var(--spacing-lg)}.mr-xl{margin-right:var(--spacing-xl)}.mx-0{margin-left:0;margin-right:0}.mx-sm{margin-left:var(--spacing-sm);margin-right:var(--spacing-sm)}.mx-md{margin-left:var(--spacing-md);margin-right:var(--spacing-md)}.mx-lg{margin-left:var(--spacing-lg);margin-right:var(--spacing-lg)}.mx-auto{margin-left:auto;margin-right:auto}.my-0{margin-top:0;margin-bottom:0}.my-sm{margin-top:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.my-md{margin-top:var(--spacing-md);margin-bottom:var(--spacing-md)}.my-lg{margin-top:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.pt-0{padding-top:0}.pt-xs{padding-top:var(--spacing-xs)}.pt-sm{padding-top:var(--spacing-sm)}.pt-md{padding-top:var(--spacing-md)}.pt-lg{padding-top:var(--spacing-lg)}.pt-xl{padding-top:var(--spacing-xl)}.pb-0{padding-bottom:0}.pb-xs{padding-bottom:var(--spacing-xs)}.pb-sm{padding-bottom:var(--spacing-sm)}.pb-md{padding-bottom:var(--spacing-md)}.pb-lg{padding-bottom:var(--spacing-lg)}.pb-xl{padding-bottom:var(--spacing-xl)}.pl-0{padding-left:0}.pl-sm{padding-left:var(--spacing-sm)}.pl-md{padding-left:var(--spacing-md)}.pl-lg{padding-left:var(--spacing-lg)}.pr-0{padding-right:0}.pr-sm{padding-right:var(--spacing-sm)}.pr-md{padding-right:var(--spacing-md)}.pr-lg{padding-right:var(--spacing-lg)}.px-0{padding-left:0;padding-right:0}.px-sm{padding-left:var(--spacing-sm);padding-right:var(--spacing-sm)}.px-md{padding-left:var(--spacing-md);padding-right:var(--spacing-md)}.px-lg{padding-left:var(--spacing-lg);padding-right:var(--spacing-lg)}.py-0{padding-top:0;padding-bottom:0}.py-sm{padding-top:var(--spacing-sm);padding-bottom:var(--spacing-sm)}.py-md{padding-top:var(--spacing-md);padding-bottom:var(--spacing-md)}.py-lg{padding-top:var(--spacing-lg);padding-bottom:var(--spacing-lg)}.d-none{display:none}.d-block{display:block}.d-inline{display:inline}.d-inline-block{display:inline-block}.d-flex{display:flex}.d-inline-flex{display:inline-flex}.d-grid{display:grid}.flex-1{flex:1}.flex-auto{flex:auto}.flex-none{flex:none}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.flex-column{flex-direction:column}.flex-row{flex-direction:row}.align-start{align-items:flex-start}.align-center{align-items:center}.align-end{align-items:flex-end}.align-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-0{gap:0}.gap-xs{gap:var(--spacing-xs)}.gap-sm{gap:var(--spacing-sm)}.gap-md{gap:var(--spacing-md)}.gap-lg{gap:var(--spacing-lg)}.gap-xl{gap:var(--spacing-xl)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-primary{color:var(--text-primary)}.text-secondary{color:var(--text-secondary)}.text-muted{color:var(--text-muted)}.text-cyan{color:var(--primary-cyan)}.text-magenta{color:var(--secondary-magenta)}.text-success{color:var(--neon-green)}.text-warning{color:var(--neon-orange)}.text-danger{color:var(--neon-red)}.text-purple{color:var(--neon-purple)}.font-heading{font-family:var(--font-heading)}.font-mono{font-family:var(--font-mono)}.font-normal{font-weight:400}.font-medium{font-weight:500}.font-semibold{font-weight:600}.font-bold{font-weight:700}.text-sm{font-size:0.85rem}.text-base{font-size:1rem}.text-lg{font-size:1.125rem}.text-xl{font-size:1.25rem}.text-2xl{font-size:1.5rem}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.leading-tight{line-height:1.25}.leading-normal{line-height:1.6}.leading-loose{line-height:2}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.break-word{word-wrap:break-word;overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}.sticky{position:sticky}.w-full{width:100%}.w-auto{width:auto}.h-full{height:100%}.h-auto{height:auto}.min-h-screen{min-height:100vh}.overflow-hidden{overflow:hidden}.overflow-auto{overflow:auto}.overflow-scroll{overflow:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.border{border:1px solid var(--card-border)}.border-0{border:0}.border-t{border-top:1px solid var(--card-border)}.border-b{border-bottom:1px solid var(--card-border)}.border-l{border-left:1px solid var(--card-border)}.border-r{border-right:1px solid var(--card-border)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-md{border-radius:var(--radius-md)}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-full{border-radius:var(--radius-full)}.cursor-pointer{cursor:pointer}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.opacity-0{opacity:0}.opacity-25{opacity:0.25}.opacity-50{opacity:0.5}.opacity-75{opacity:0.75}.opacity-100{opacity:1}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.select-none{user-select:none}.select-text{user-select:text}.select-all{user-select:all}.mobile-header,.mobile-bottom-bar,.sidebar-scrim{display:none}@media (max-width:992px){:root{--sticky-top:var(--mobile-header-height)}.mobile-header{position:fixed;top:0;left:0;right:0;z-index:1001;height:var(--mobile-header-height);display:flex;align-items:center;gap:var(--spacing-sm);padding:0 var(--spacing-sm);background:var(--bg-medium);border-bottom:1px solid var(--card-border)}.mobile-header-logo{flex:1}.mobile-header-logo .logo-text{font-size:1.25rem}.mobile-menu-toggle,.mobile-search-toggle{display:flex;align-items:center;justify-content:center;width:44px;height:44px;background:none;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer}.mobile-menu-toggle{flex-direction:column;gap:4px}.mobile-menu-toggle span{width:24px;height:3px;background:var(--text-primary);border-radius:2px}.mobile-search-toggle{font-size:1.25rem}.mobile-menu-toggle:focus-visible,.mobile-search-toggle:focus-visible{outline:none;box-shadow:0 0 0 2px var(--primary-cyan)}.sidebar{top:var(--mobile-header-height);height:calc(100dvh - var(--mobile-header-height));width:min(var(--sidebar-width),85vw);background:linear-gradient(180deg,var(--bg-medium) 0%,var(--bg-dark) 100%);backdrop-filter:none;-webkit-backdrop-filter:none;visibility:hidden;transition:transform var(--transition-normal),visibility 0s var(--transition-normal)}.sidebar.open{visibility:visible;transition:transform var(--transition-normal)}.sidebar-header > .logo{display:none}.sidebar-header > .logo + *{margin-top:0}.global-search-list{max-height:50dvh}.sidebar-nav{overflow-y:auto}.sidebar-scrim{display:block;position:fixed;inset:0;z-index:999;background:rgba(0,0,0,0.6);opacity:0;pointer-events:none;transition:opacity var(--transition-normal)}body.mobile-menu-open .sidebar-scrim{opacity:1;pointer-events:auto}body.mobile-menu-open{overflow:hidden}.mobile-bottom-bar{position:fixed;left:0;right:0;bottom:0;z-index:998;display:flex;height:calc(var(--mobile-bar-height) + env(safe-area-inset-bottom, 0px));padding-bottom:env(safe-area-inset-bottom,0px);background:var(--bg-medium);border-top:1px solid var(--card-border)}.mobile-bottom-item{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;min-width:0;color:var(--text-secondary);text-decoration:none;font-size:0.75rem}.mobile-bottom-icon{font-size:1.25rem}.mobile-bottom-label{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mobile-bottom-item.active{color:var(--primary-cyan);box-shadow:inset 0 2px 0 var(--primary-cyan)}.mobile-bottom-item:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--primary-cyan)}.main-content{padding-top:calc(var(--mobile-header-height) + var(--spacing-md));padding-bottom:calc(var(--mobile-bar-height) + env(safe-area-inset-bottom, 0px) + var(--spacing-md))}body:has(.mobile-bottom-bar) .back-to-top-btn{bottom:calc(var(--mobile-bar-height) + 16px)}body:has(.mobile-bottom-bar) .unified-toast-container{bottom:calc(var(--mobile-bar-height) + 12px)}body:has(.mobile-bottom-bar) .launch-indicator{bottom:calc(var(--mobile-bar-height) + var(--spacing-sm))}body:has(.mobile-bottom-bar) .launch-indicator-popover{bottom:calc(var(--mobile-bar-height) + var(--spacing-sm) + 2.5rem)}}@media (max-width:1600px){.content-wrapper{width:80%}}@media (max-width:1200px){.content-wrapper{width:95%;min-width:auto}.game-hero{grid-template-columns:250px 1fr}.game-details-grid{grid-template-columns:1fr}}@media (max-width:992px){.sidebar{transform:translateX(-100%)}.sidebar.open{transform:translateX(0)}.main-content{margin-left:0}.game-hero{grid-template-columns:1fr}.game-boxart-container{max-width:300px;margin:0 auto}.unified-toast-container{left:20px;right:20px;width:auto;max-width:calc(100% - 40px)}.system-controller-grid{grid-template-columns:1fr !important}}@media (max-width:900px){.score-lists-grid{grid-template-columns:1fr}.psn-trophy-lists-row{grid-template-columns:1fr}.psn-info-panels-row{grid-template-columns:1fr}.psn-game-info-card{flex-direction:column}.psn-game-boxart{width:140px}.psn-meta-grid{grid-template-columns:1fr 1fr}.psn-stats-bar{grid-template-columns:repeat(2,1fr)}}@media (max-width:768px){.page-header{flex-direction:column;align-items:flex-start;gap:var(--spacing-md)}.page-title{font-size:1.8rem}.stats-grid{grid-template-columns:1fr}.systems-grid{grid-template-columns:repeat(2,1fr)}.filter-bar{flex-direction:column}.search-box{min-width:100%}.game-meta-badges{flex-direction:column}.game-ratings{flex-direction:column}.game-hero{grid-template-columns:1fr}.hero-boxart{width:200px;margin:0 auto}.tool-tab{padding:0.75rem 0.5rem;font-size:0.8rem}.tool-tab .tab-label{display:none}.tool-tab .tab-icon{font-size:1.25rem}.edit-grid{grid-template-columns:1fr}.psn-games-grid{grid-template-columns:1fr}.psn-search-sort-row{flex-direction:column}.psn-sort-controls{width:100%}.psn-sort-select{flex:1}.psn-profile-bar{flex-direction:column;text-align:center}}@media (max-width:600px){.gdm-header{flex-direction:column;align-items:center;text-align:center}.gdm-boxart-container{width:140px}.gdm-details-grid{grid-template-columns:1fr}.gdm-meta{justify-content:center}.gdm-ratings{justify-content:center}.gdm-actions{flex-direction:column}.gdm-actions .btn{width:100%}.gem-hltb-times{grid-template-columns:1fr}.gem-footer{flex-direction:column}.gem-footer .btn{width:100%}.gem-tabs{flex-wrap:nowrap}.gem-tab{padding:6px 10px;font-size:0.75rem}.gem-grid{grid-template-columns:1fr}.gem-footer-right{flex-direction:column;width:100%}.psn-meta-grid{grid-template-columns:1fr}.psn-trophy-type-breakdown{flex-wrap:wrap;gap:var(--spacing-md)}.platform-filters{justify-content:center}}@media (max-width:480px){.systems-grid{grid-template-columns:1fr}.page-title{font-size:1.5rem;letter-spacing:2px}.stat-value{font-size:2.5rem}} \ No newline at end of file diff --git a/static/css/pages/game-list.css b/static/css/pages/game-list.css index bb757698..e6e3dbf1 100644 --- a/static/css/pages/game-list.css +++ b/static/css/pages/game-list.css @@ -236,7 +236,7 @@ .glitch-text::before { left: 2px; - text-shadow: -2px 0 var(--primary-magenta); + text-shadow: -2px 0 var(--secondary-magenta); clip: rect(24px, 550px, 90px, 0); animation: glitch-anim 3s infinite linear alternate-reverse; } @@ -302,7 +302,7 @@ bottom: -2px; background: linear-gradient(45deg, var(--primary-cyan), - var(--primary-magenta), + var(--secondary-magenta), var(--primary-cyan)); border-radius: calc(var(--radius-lg) + 2px); z-index: -1; diff --git a/static/css/pages/pages.css b/static/css/pages/pages.css index e50c53a2..e25e5f33 100644 --- a/static/css/pages/pages.css +++ b/static/css/pages/pages.css @@ -875,7 +875,7 @@ left: 0; width: 100%; height: 100%; - background: linear-gradient(to bottom, transparent 0%, var(--bg-primary) 100%); + background: linear-gradient(to bottom, transparent 0%, var(--bg-darkest) 100%); } /* Hero section */ diff --git a/static/js/all-games-controller.js b/static/js/all-games-controller.js index 195bcfec..008f3007 100644 --- a/static/js/all-games-controller.js +++ b/static/js/all-games-controller.js @@ -480,11 +480,26 @@ const AllGamesController = (function() { } } - items.forEach(wrapper => { + // One column (the phone layout): the CSS stacks the cards with + // auto rows, which inline row lines would override, leaving a + // row-gap per 1px line between cards. + if (columns <= 1) { + items.forEach(w => { + w.style.gridColumn = w.style.gridRowStart = w.style.gridRowEnd = ''; + }); + return; + } + + // Measure every card before placing any, so the loop forces one + // layout rather than one per card. + const heights = items.map(wrapper => { const card = wrapper.querySelector('.game-card-new'); - if (!card) return; + return card ? card.getBoundingClientRect().height : null; + }); - const h = card.getBoundingClientRect().height; + items.forEach((wrapper, i) => { + const h = heights[i]; + if (h === null) return; const span = h > 0 ? Math.ceil(h) + gap : 296; const col = masonryItemCount % columns; @@ -497,6 +512,16 @@ const AllGamesController = (function() { }); } + // A resize that changes the column count re-places every card; the old + // inline columns would otherwise point past the grid's last column. + const _onResizeRelayout = debounce(() => { + const grid = document.getElementById('gamesGrid'); + if (!grid || !grid.firstElementChild) return; + const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').length; + if (columns !== masonryColumnEnds.length) window.relayoutMasonry(); + }, 200); + window.addEventListener('resize', _onResizeRelayout, { passive: true }); + // Expose a full re-layout function for external use (e.g. after card edits) window.relayoutMasonry = function() { const grid = document.getElementById('gamesGrid'); @@ -1606,6 +1631,7 @@ const AllGamesController = (function() { clearTimeout(scrollSaveTimer); clearTimeout(_imageRelayoutTimer); window.removeEventListener('scroll', _onScrollSave); + window.removeEventListener('resize', _onResizeRelayout); document.removeEventListener('click', _onFilterBackdropClick); selectedGames.clear(); } diff --git a/static/js/core.bundle.js b/static/js/core.bundle.js index 512d4fe2..2284fd51 100644 --- a/static/js/core.bundle.js +++ b/static/js/core.bundle.js @@ -1726,15 +1726,23 @@ const UnifiedToastController = { * Poll status for a specific type */ async pollStatus(type, endpoint) { + const prevAbort = this._pollAbortControllers.get(type); + if (prevAbort) { + if (Date.now() - prevAbort._startedAt < 10000) return; + prevAbort.abort(); + } + const ac = new AbortController(); + ac._startedAt = Date.now(); + this._pollAbortControllers.set(type, ac); try { - const prevAbort = this._pollAbortControllers.get(type); - if (prevAbort) prevAbort.abort(); - - const ac = new AbortController(); - this._pollAbortControllers.set(type, ac); - - const data = await API.get(endpoint, { signal: ac.signal }); - this._pollAbortControllers.delete(type); + let data; + try { + data = await API.get(endpoint, { signal: ac.signal }); + } finally { + if (this._pollAbortControllers.get(type) === ac) { + this._pollAbortControllers.delete(type); + } + } if (data.success === false) return; @@ -3662,7 +3670,7 @@ async function cleanMissingRoms() { if (resultDiv) { if (data.removed > 0) { let html = `
`; - html += `${escapeHtml(t('Removed {n} games:', {n: formatNumber(data.removed)}))}`; + html += `${escapeHtml(t('Removed {n} games:', {n: formatNumber(data.removed)}))}`; html += `
    `; for (const game of data.removed_games) { html += `
  • ${escapeHtml(game.title)}
  • `; @@ -3719,7 +3727,7 @@ async function clearClzImports() { if (resultDiv) { if (data.removed > 0) { let html = `
    `; - html += `${escapeHtml(t('Removed {n} CLZ Import games:', {n: formatNumber(data.removed)}))}`; + html += `${escapeHtml(t('Removed {n} CLZ Import games:', {n: formatNumber(data.removed)}))}`; html += `
      `; for (const game of data.removed_games) { html += `
    • ${escapeHtml(game.title)}
    • `; diff --git a/static/js/game-modals.js b/static/js/game-modals.js index 7d25a517..c90de5c4 100644 --- a/static/js/game-modals.js +++ b/static/js/game-modals.js @@ -132,6 +132,7 @@ const GameDetailModal = { maxCacheSize: 50, _screenshots: [], _screenshotIndex: 0, + _loadingHtml: null, /** * Open the game detail modal @@ -149,6 +150,11 @@ const GameDetailModal = { const wasOpen = modal.classList.contains('active'); modal.classList.add('active'); document.body.style.overflow = 'hidden'; + // showError() replaced the spinner; put it back for this load. + if (this._loadingHtml) { + loading.innerHTML = this._loadingHtml; + this._loadingHtml = null; + } loading.style.display = 'flex'; content.style.display = 'none'; @@ -162,6 +168,9 @@ const GameDetailModal = { return; } + // A reply that lands after the modal closed or moved on to another + // game must not paint over it. + const isCurrent = () => this.currentId === gameId; API.get(`/api/game/${gameId}/detail`) .then(data => { if (data.success) { @@ -171,12 +180,12 @@ const GameDetailModal = { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } - this.populate(data.game); - } else { + if (isCurrent()) this.populate(data.game); + } else if (isCurrent()) { this.showError(data.error || t('Failed to load game')); } }) - .catch(err => this.showError(t('Network error'))); + .catch(err => { if (isCurrent()) this.showError(t('Network error')); }); }, /** @@ -368,7 +377,9 @@ const GameDetailModal = { * @param {string} message - The error message */ showError(message) { - document.getElementById('gameDetailLoading').innerHTML = + const loading = document.getElementById('gameDetailLoading'); + if (!this._loadingHtml) this._loadingHtml = loading.innerHTML; + loading.innerHTML = `

      ${escapeHtml(message)}

      `; }, @@ -2237,7 +2248,9 @@ window.triggerAiFill = function() { showNotification(`AI filled ${count} field${count !== 1 ? 's' : ''}: ${data.filled_fields.join(', ')}`, 'success'); // Clear cache and refresh modal GameDetailModal.cache.delete(gameId); - GameDetailModal.open(gameId); + // Only if the modal still shows this game: the fill can + // finish after the user closed it or opened another. + if (GameDetailModal.currentId === gameId) GameDetailModal.open(gameId); // Refresh the game card in the list page if (typeof AllGamesController !== 'undefined' && AllGamesController.refreshCards) { AllGamesController.refreshCards(gameId); diff --git a/static/js/games.bundle.js b/static/js/games.bundle.js index b8795ff7..9734b733 100644 --- a/static/js/games.bundle.js +++ b/static/js/games.bundle.js @@ -1162,6 +1162,7 @@ const GameDetailModal = { maxCacheSize: 50, _screenshots: [], _screenshotIndex: 0, + _loadingHtml: null, /** * Open the game detail modal @@ -1176,6 +1177,10 @@ const GameDetailModal = { const wasOpen = modal.classList.contains('active'); modal.classList.add('active'); document.body.style.overflow = 'hidden'; + if (this._loadingHtml) { + loading.innerHTML = this._loadingHtml; + this._loadingHtml = null; + } loading.style.display = 'flex'; content.style.display = 'none'; @@ -1188,6 +1193,7 @@ const GameDetailModal = { return; } + const isCurrent = () => this.currentId === gameId; API.get(`/api/game/${gameId}/detail`) .then(data => { if (data.success) { @@ -1196,12 +1202,12 @@ const GameDetailModal = { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } - this.populate(data.game); - } else { + if (isCurrent()) this.populate(data.game); + } else if (isCurrent()) { this.showError(data.error || t('Failed to load game')); } }) - .catch(err => this.showError(t('Network error'))); + .catch(err => { if (isCurrent()) this.showError(t('Network error')); }); }, /** @@ -1372,7 +1378,9 @@ const GameDetailModal = { * @param {string} message - The error message */ showError(message) { - document.getElementById('gameDetailLoading').innerHTML = + const loading = document.getElementById('gameDetailLoading'); + if (!this._loadingHtml) this._loadingHtml = loading.innerHTML; + loading.innerHTML = `

      ${escapeHtml(message)}

      `; }, @@ -3062,7 +3070,7 @@ window.triggerAiFill = function() { if (count > 0) { showNotification(`AI filled ${count} field${count !== 1 ? 's' : ''}: ${data.filled_fields.join(', ')}`, 'success'); GameDetailModal.cache.delete(gameId); - GameDetailModal.open(gameId); + if (GameDetailModal.currentId === gameId) GameDetailModal.open(gameId); if (typeof AllGamesController !== 'undefined' && AllGamesController.refreshCards) { AllGamesController.refreshCards(gameId); } diff --git a/static/js/launch-indicator.js b/static/js/launch-indicator.js index 66cd3d70..fd032cc2 100644 --- a/static/js/launch-indicator.js +++ b/static/js/launch-indicator.js @@ -50,7 +50,10 @@ await fetch(`/api/launch/${token}/kill`, { method: 'POST', headers: {'X-CSRF-Token': csrfToken()}, - }); + }).catch(() => {}); + // The open list still shows the killed game; close it so the next + // click rebuilds it from the refreshed list. + if (popover) { popover.remove(); popover = null; } await refresh(); } diff --git a/static/js/log-viewer.js b/static/js/log-viewer.js index b3f0bc1d..e6435433 100644 --- a/static/js/log-viewer.js +++ b/static/js/log-viewer.js @@ -175,6 +175,8 @@ const LogViewer = { try { const data = await API.get(`/api/logs/content/${encodeURIComponent(filename)}`); + // Another file was picked while this one loaded; it owns the view. + if (this.currentFile !== filename) return; if (!data.success) { container.innerHTML = `
      ${escapeHtml(data.error || t('Error loading file'))}
      `; return; @@ -187,6 +189,7 @@ const LogViewer = { this.applySmartViewMode(); this.render(); } catch (e) { + if (this.currentFile !== filename) return; container.innerHTML = `
      ${t('Error loading log file')}
      `; console.error('Error loading log file:', e); } diff --git a/static/js/main.js b/static/js/main.js index f5414ffb..bd4121f8 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1056,7 +1056,7 @@ async function cleanMissingRoms() { if (resultDiv) { if (data.removed > 0) { let html = `
      `; - html += `${escapeHtml(t('Removed {n} games:', {n: formatNumber(data.removed)}))}`; + html += `${escapeHtml(t('Removed {n} games:', {n: formatNumber(data.removed)}))}`; html += `
        `; for (const game of data.removed_games) { html += `
      • ${escapeHtml(game.title)}
      • `; @@ -1113,7 +1113,7 @@ async function clearClzImports() { if (resultDiv) { if (data.removed > 0) { let html = `
        `; - html += `${escapeHtml(t('Removed {n} CLZ Import games:', {n: formatNumber(data.removed)}))}`; + html += `${escapeHtml(t('Removed {n} CLZ Import games:', {n: formatNumber(data.removed)}))}`; html += `
          `; for (const game of data.removed_games) { html += `
        • ${escapeHtml(game.title)}
        • `; diff --git a/static/js/museum.js b/static/js/museum.js index 5be787f3..8f7c04bf 100644 --- a/static/js/museum.js +++ b/static/js/museum.js @@ -80,6 +80,9 @@ function _stopPolling() { function _pollStatus() { API.get('/api/museum/generate-status') .then(function (s) { + // A reply still in flight when an earlier one stopped polling + // would announce the finished run a second time. + if (!_pollTimer) return; // Update UI var pct = s.total_systems > 0 ? Math.round((s.current_index / s.total_systems) * 100) diff --git a/static/js/official-names.js b/static/js/official-names.js index 55f99641..81789702 100644 --- a/static/js/official-names.js +++ b/static/js/official-names.js @@ -37,9 +37,12 @@ async function loadProposals() { listStatus.textContent = t('Loading…'); + const selected = systemSelect.value; try { - const qs = new URLSearchParams(systemSelect.value ? { system_id: systemSelect.value } : {}); + const qs = new URLSearchParams(selected ? { system_id: selected } : {}); const data = await API.get(`/api/official-names/proposals?${qs}`); + // The system changed while this loaded; its own load follows. + if (systemSelect.value !== selected) return; list.replaceChildren(...data.proposals.map(renderRow)); actions.hidden = data.proposals.length === 0; listStatus.textContent = data.proposals.length diff --git a/static/js/settings-page.js b/static/js/settings-page.js index 14299c08..f7bb4c27 100644 --- a/static/js/settings-page.js +++ b/static/js/settings-page.js @@ -204,7 +204,7 @@ const NormalizationManager = { // interpolated value goes through escapeHtml (PASS-59-63). let msg = '
          '; msg += ''; - msg += ``; + msg += ``; mappings.forEach(m => { msg += ``; msg += ``; diff --git a/static/js/toast-controller.js b/static/js/toast-controller.js index c2266586..31f8f7b3 100644 --- a/static/js/toast-controller.js +++ b/static/js/toast-controller.js @@ -646,16 +646,27 @@ const UnifiedToastController = { * Poll status for a specific type */ async pollStatus(type, endpoint) { + // One request per type at a time. A tick that finds one in flight + // lets it land (its reply is just as fresh): aborting it instead + // meant a status reply slower than the poll interval never arrived + // and the toast froze. Only a request stuck past 10 s is replaced. + const prevAbort = this._pollAbortControllers.get(type); + if (prevAbort) { + if (Date.now() - prevAbort._startedAt < 10000) return; + prevAbort.abort(); + } + const ac = new AbortController(); + ac._startedAt = Date.now(); + this._pollAbortControllers.set(type, ac); try { - // Abort any previous in-flight request for this type to prevent pileup - const prevAbort = this._pollAbortControllers.get(type); - if (prevAbort) prevAbort.abort(); - - const ac = new AbortController(); - this._pollAbortControllers.set(type, ac); - - const data = await API.get(endpoint, { signal: ac.signal }); - this._pollAbortControllers.delete(type); + let data; + try { + data = await API.get(endpoint, { signal: ac.signal }); + } finally { + if (this._pollAbortControllers.get(type) === ac) { + this._pollAbortControllers.delete(type); + } + } // Different endpoints have different response formats // bulk-scrape-job/status returns { success: true, running: ..., ... } diff --git a/static/js/versions.js b/static/js/versions.js index f3c8f30f..21c133d8 100644 --- a/static/js/versions.js +++ b/static/js/versions.js @@ -81,6 +81,8 @@ try { const qs = new URLSearchParams({ system: systemId, search: term, per_page: 20 }); const data = await API.get(`/api/games?${qs}`); + // An older search can land after a newer one; keep the newest. + if (input.value.trim() !== term) return; renderCandidates(data.games.filter(g => g.id !== gameId)); } catch (err) { showNotification(failMessage(err, t('Search failed.')), 'error'); diff --git a/templates/chd_converter.html b/templates/chd_converter.html index c39eaad5..6484ef5d 100644 --- a/templates/chd_converter.html +++ b/templates/chd_converter.html @@ -281,9 +281,9 @@

          📊 {{ _('Conversion Statistics') }}

          html += `
          - ${f.name} + ${escapeHtml(f.name)} ${formatBytes(f.size)} - ${f.type} + ${escapeHtml(f.type)} Pending
          `; diff --git a/templates/chd_verify.html b/templates/chd_verify.html index 7adaa470..35bed220 100644 --- a/templates/chd_verify.html +++ b/templates/chd_verify.html @@ -235,7 +235,7 @@

          📊 {{ _('Verification Results') }}

          html += `
          - ${f.name} + ${escapeHtml(f.name)} ${formatBytes(f.size)} Pending
          diff --git a/templates/collector_trophies.html b/templates/collector_trophies.html index 11d16814..d90a9e12 100644 --- a/templates/collector_trophies.html +++ b/templates/collector_trophies.html @@ -144,7 +144,7 @@

          {{ tier_name }}

          .collector-rank-card.rank-tier-7 { border-color: var(--neon-purple); box-shadow: 0 0 20px rgba(168, 85, 247, 0.25); } .collector-rank-card.rank-tier-8, .collector-rank-card.rank-tier-9 { - border-color: var(--primary-magenta); + border-color: var(--secondary-magenta); background: linear-gradient(135deg, var(--bg-medium) 0%, rgba(247, 37, 133, 0.08) 100%); box-shadow: 0 0 24px rgba(247, 37, 133, 0.3); } @@ -231,14 +231,14 @@

          {{ tier_name }}

          .collector-rank-next-fill { height: 100%; - background: linear-gradient(90deg, var(--primary-cyan), var(--primary-magenta)); + background: linear-gradient(90deg, var(--primary-cyan), var(--secondary-magenta)); transition: width 0.4s ease; } .collector-rank-max { font-family: var(--font-heading); font-size: 0.9rem; - color: var(--primary-magenta); + color: var(--secondary-magenta); text-transform: uppercase; letter-spacing: 1px; } @@ -291,7 +291,7 @@

          {{ tier_name }}

          .trophy-progress-fill { height: 100%; - background: linear-gradient(90deg, var(--primary-cyan), var(--primary-magenta)); + background: linear-gradient(90deg, var(--primary-cyan), var(--secondary-magenta)); border-radius: var(--radius-full); transition: width 0.6s ease-out; } diff --git a/templates/compare_games.html b/templates/compare_games.html index 60ff2b82..921d687f 100644 --- a/templates/compare_games.html +++ b/templates/compare_games.html @@ -301,7 +301,7 @@

          Select a Game

          font-family: var(--font-heading); font-size: 1.4rem; font-weight: 700; - color: var(--primary-magenta); + color: var(--secondary-magenta); text-transform: uppercase; letter-spacing: 2px; flex-shrink: 0; @@ -409,7 +409,7 @@

          Select a Game

          .match-badge.diff { background: rgba(247, 37, 133, 0.15); - color: var(--primary-magenta); + color: var(--secondary-magenta); border: 1px solid rgba(247, 37, 133, 0.3); } @@ -436,7 +436,7 @@

          Select a Game

          /* Empty state */ .compare-empty { text-align: center; - padding: var(--spacing-xxl); + padding: var(--spacing-2xl); color: var(--text-muted); font-size: 1rem; } diff --git a/templates/duplicate_finder.html b/templates/duplicate_finder.html index 92aaed1d..870f575f 100644 --- a/templates/duplicate_finder.html +++ b/templates/duplicate_finder.html @@ -107,7 +107,7 @@ } .duplicate-group-toggle .group-stats .wasted { - color: var(--accent-pink); + color: var(--secondary-magenta); } .duplicate-group-files { diff --git a/templates/game_detail.html b/templates/game_detail.html index 0e6287a5..c0d7ee57 100644 --- a/templates/game_detail.html +++ b/templates/game_detail.html @@ -1605,7 +1605,7 @@

          .tag-system { background: rgba(76, 201, 240, 0.15); color: var(--primary-cyan); } .tag-date { background: rgba(255, 193, 7, 0.2); color: #ffc107; border-color: rgba(255, 193, 7, 0.4); } -.tag-region { background: rgba(247, 37, 133, 0.15); color: var(--primary-magenta); } +.tag-region { background: rgba(247, 37, 133, 0.15); color: var(--secondary-magenta); } .tag-rating { background: rgba(76, 201, 240, 0.2); font-weight: 600; } /* Description */ @@ -1654,7 +1654,7 @@

          } .kd-value.franchise { - color: var(--primary-magenta); + color: var(--secondary-magenta); } /* Achievement & Trophy Row */ @@ -2058,7 +2058,7 @@

          .screenshots-nav:hover { background: var(--primary-cyan); - color: var(--bg-primary); + color: var(--bg-darkest); } .screenshots-nav.prev { left: 5px; } @@ -2809,7 +2809,7 @@

          .result-region { font-size: 0.75rem; - color: var(--primary-magenta); + color: var(--secondary-magenta); background: rgba(247, 37, 133, 0.15); padding: 2px 8px; border-radius: var(--radius-sm); @@ -3573,7 +3573,7 @@

          } .rename-modal { - background: var(--bg-secondary); + background: var(--bg-medium); border: 1px solid var(--card-border); border-radius: var(--radius-lg); padding: 2rem; @@ -3648,7 +3648,7 @@

          } .rename-modal .btn-rename-confirm { - background: linear-gradient(135deg, var(--primary-cyan), var(--primary-magenta)); + background: linear-gradient(135deg, var(--primary-cyan), var(--secondary-magenta)); border: none; color: white; padding: 0.6rem 1.5rem; diff --git a/templates/rom_tools_settings.html b/templates/rom_tools_settings.html index 5336f612..8c635609 100644 --- a/templates/rom_tools_settings.html +++ b/templates/rom_tools_settings.html @@ -95,11 +95,11 @@ } .tool-status-item .tool-info .status.available { - color: var(--accent-green); + color: var(--neon-green); } .tool-status-item .tool-info .status.missing { - color: var(--accent-pink); + color: var(--secondary-magenta); } .info-banner { @@ -163,7 +163,7 @@ } .tag .remove-tag:hover { - color: var(--accent-pink); + color: var(--secondary-magenta); } .add-tag-input { diff --git a/templates/screenshot_dedup.html b/templates/screenshot_dedup.html index 91401a75..8c658227 100644 --- a/templates/screenshot_dedup.html +++ b/templates/screenshot_dedup.html @@ -94,7 +94,7 @@ } .game-dedup-toggle .group-stats .reclaimable { - color: var(--accent-pink); + color: var(--secondary-magenta); } .game-dedup-body { diff --git a/templates/settings.html b/templates/settings.html index d0d19c64..2e03033a 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -195,7 +195,7 @@

          {{ _('Settings') }}

          position: sticky; top: var(--sticky-top); z-index: 70; - background: var(--bg-primary); + background: var(--bg-darkest); } .settings-tabs::-webkit-scrollbar { @@ -471,8 +471,8 @@

          {{ _('Settings') }}

          .log-badge.image_resize { background: rgba(247, 37, 133, 0.15); - color: var(--primary-magenta); - border: 1px solid var(--primary-magenta); + color: var(--secondary-magenta); + border: 1px solid var(--secondary-magenta); } .log-badge.system { @@ -592,7 +592,7 @@

          {{ _('Settings') }}

          .scraper-priority-item.dragging { opacity: 0.5; - border-color: var(--primary-magenta); + border-color: var(--secondary-magenta); cursor: grabbing; } @@ -613,7 +613,7 @@

          {{ _('Settings') }}

          .scraper-priority-rank { width: 28px; height: 28px; - background: linear-gradient(135deg, var(--primary-cyan), var(--primary-magenta)); + background: linear-gradient(135deg, var(--primary-cyan), var(--secondary-magenta)); border-radius: 50%; display: flex; align-items: center; diff --git a/templates/systems.html b/templates/systems.html index f01ac7b8..6e7cc706 100644 --- a/templates/systems.html +++ b/templates/systems.html @@ -148,7 +148,7 @@

          {{ _('System Specifications') }}

          .glitch-text::before { left: 2px; - text-shadow: -2px 0 var(--primary-magenta); + text-shadow: -2px 0 var(--secondary-magenta); clip: rect(24px, 550px, 90px, 0); animation: glitch-anim 3s infinite linear alternate-reverse; } @@ -290,7 +290,7 @@

          {{ _('System Specifications') }}

          left: -2px; right: -2px; bottom: -2px; - background: linear-gradient(45deg, var(--primary-cyan), var(--primary-magenta), var(--primary-cyan)); + background: linear-gradient(45deg, var(--primary-cyan), var(--secondary-magenta), var(--primary-cyan)); border-radius: calc(var(--radius-lg) + 2px); z-index: -1; opacity: 0; From ca4a3cf71fbaf661c4f038f2c4073b8f243472a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:29:10 +0000 Subject: [PATCH 4/8] =?UTF-8?q?fix(routes):=20review=20sweep=20=E2=80=94?= =?UTF-8?q?=20auth=20guard,=20trophies=20and=20imports=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The last active admin can no longer be demoted or deactivated - /api/psn/games clamps page/per_page (per_page=0 was a 500) - Steam/Xbox/PSN library fetch: duplicate check uses one index built from one query instead of re-reading the system per fetched title - /trophies reads and cleans PlayStation titles once, not per trophy set; PSN full sync cleans each title once - PSN single-game sync writes trophies in one batch; detail-page debug logging moved from INFO to DEBUG - Collector trophies refresh: one read and one transaction (~140 round trips before); sidebar rank cached per request Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013s5ztLFVCcdTjjTeVcTnJr --- app.py | 10 +- routes/auth.py | 11 ++ routes/collector_trophies.py | 91 +++++++--------- routes/platform_import.py | 70 ++++++------ routes/trophies.py | 168 +++++++++++++++------------- tests/test_pass45_security.py | 19 +++- tests/test_review_routes_core.py | 182 +++++++++++++++++++++++++++++++ 7 files changed, 390 insertions(+), 161 deletions(-) create mode 100644 tests/test_review_routes_core.py diff --git a/app.py b/app.py index 863dbe38..e6bd87a8 100644 --- a/app.py +++ b/app.py @@ -1019,11 +1019,17 @@ def _get_collector_rank_safe(): trophies module / table isn't ready yet (e.g. first boot before the table has been populated). The sidebar badge reads this on every render, so it must never raise.""" + # inject_config runs for every render_template call in a request, so the + # rank is read once per request and reused. + if '_collector_rank' in g: + return g._collector_rank try: from routes.collector_trophies import get_current_rank - return get_current_rank() + rank = get_current_rank() except Exception: - return None + rank = None + g._collector_rank = rank + return rank # ============================================================================= diff --git a/routes/auth.py b/routes/auth.py index ef132778..df61d8ba 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -227,6 +227,17 @@ def api_update_user(user_id): if not user: return error(_('User not found'), code=404) + # The last active admin cannot be demoted or deactivated: nobody would be + # left who can manage users or settings (the delete route guards the same). + if user['role'] == 'admin' and user['is_active']: + demoted = 'role' in data and data['role'] in VALID_ROLES and data['role'] != 'admin' + deactivated = 'is_active' in data and not data['is_active'] + if demoted or deactivated: + admin_count = query("SELECT COUNT(*) as count FROM users " + "WHERE role = 'admin' AND is_active = 1", one=True) + if admin_count['count'] <= 1: + return error(_('Cannot delete the last admin user'), code=403) + updates = [] params = [] diff --git a/routes/collector_trophies.py b/routes/collector_trophies.py index 0344b4c9..65103f1e 100644 --- a/routes/collector_trophies.py +++ b/routes/collector_trophies.py @@ -20,7 +20,7 @@ from services.api_helpers import handle_api_errors, success from services.auth import login_required, permission_required -from services.database import query, execute +from services.database import get_request_db, query from services.formatters import get_manufacturer logger = logging.getLogger(__name__) @@ -470,6 +470,17 @@ def _refresh_trophies(user_id=None): newly_earned = 0 total_earned = 0 + # One read of the caller's rows and one transaction for the writes; this + # was a SELECT plus a committed write per trophy (~140 round trips). + existing_rows = { + r['id']: r['earned_at'] for r in query( + "SELECT id, earned_at FROM collector_trophies WHERE user_id = ?", + (user_id,), + ) + } + updates = [] + inserts = [] + for trophy_def in TROPHY_DEFINITIONS: trophy_id = trophy_def['id'] stat_key = trophy_def['stat'] @@ -478,65 +489,47 @@ def _refresh_trophies(user_id=None): progress = min(current_value, threshold) met = current_value >= threshold + definition = (trophy_def['name'], trophy_def['description'], + trophy_def['icon'], trophy_def['tier'], + trophy_def['category'], threshold) - existing = query( - "SELECT earned_at FROM collector_trophies WHERE id = ? AND user_id = ?", - (trophy_id, user_id), one=True - ) - - if existing is not None: - already_earned = existing['earned_at'] is not None + if trophy_id in existing_rows: + already_earned = existing_rows[trophy_id] is not None + if met: + total_earned += 1 + # Stamp earned_at the first time the threshold is met; otherwise + # keep it. Definition fields are re-written every refresh so edits + # (name, description, icon, tier, threshold) propagate without a + # manual DELETE FROM collector_trophies. + earned_at = now if met and not already_earned else existing_rows[trophy_id] if met and not already_earned: - execute( - """UPDATE collector_trophies - SET progress = ?, earned_at = ?, - name = ?, description = ?, icon = ?, tier = ?, - category = ?, threshold = ? - WHERE id = ? AND user_id = ?""", - (progress, now, trophy_def['name'], trophy_def['description'], - trophy_def['icon'], trophy_def['tier'], trophy_def['category'], - threshold, trophy_id, user_id) - ) newly_earned += 1 - total_earned += 1 - elif met and already_earned: - # Re-write fields every refresh so definition edits (name, - # description, icon, tier, threshold) propagate without a - # manual DELETE FROM collector_trophies. - execute( - """UPDATE collector_trophies - SET progress = ?, name = ?, description = ?, icon = ?, - tier = ?, category = ?, threshold = ? - WHERE id = ? AND user_id = ?""", - (progress, trophy_def['name'], trophy_def['description'], - trophy_def['icon'], trophy_def['tier'], trophy_def['category'], - threshold, trophy_id, user_id) - ) - total_earned += 1 - else: - execute( - """UPDATE collector_trophies - SET progress = ?, name = ?, description = ?, icon = ?, - tier = ?, category = ?, threshold = ? - WHERE id = ? AND user_id = ?""", - (progress, trophy_def['name'], trophy_def['description'], - trophy_def['icon'], trophy_def['tier'], trophy_def['category'], - threshold, trophy_id, user_id) - ) + updates.append((progress, earned_at, *definition, trophy_id, user_id)) else: earned_at = now if met else None if met: newly_earned += 1 total_earned += 1 - execute( + inserts.append((trophy_id, user_id, *definition[:5], threshold, + earned_at, progress)) + + conn = get_request_db() + with conn: + if updates: + conn.executemany( + """UPDATE collector_trophies + SET progress = ?, earned_at = ?, + name = ?, description = ?, icon = ?, tier = ?, + category = ?, threshold = ? + WHERE id = ? AND user_id = ?""", + updates, + ) + if inserts: + conn.executemany( """INSERT INTO collector_trophies (id, user_id, name, description, icon, tier, category, threshold, earned_at, progress) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - trophy_id, user_id, trophy_def['name'], trophy_def['description'], - trophy_def['icon'], trophy_def['tier'], trophy_def['category'], - threshold, earned_at, progress, - ) + inserts, ) # Compute the rank after refresh so the API response reflects the new state diff --git a/routes/platform_import.py b/routes/platform_import.py index 2e80761b..d6e66fd8 100644 --- a/routes/platform_import.py +++ b/routes/platform_import.py @@ -73,36 +73,43 @@ def _ensure_system_exists(folder, name=None): return None -def _check_duplicate(title, system_id, platform_field=None, platform_value=None): - """Check if a game already exists by platform ID or normalized title. +_PLATFORM_ID_FIELDS = ('steam_app_id', 'xbox_title_id', 'psn_npwr_id') - Args: - title: Game title to check - system_id: System ID to check against - platform_field: Column name for platform-specific ID (e.g. 'steam_app_id') - platform_value: Value of the platform-specific ID - Returns: - int or None: Existing game_id if found, else None - """ - # 1. Exact platform ID match - if platform_field and platform_value: - allowed_fields = ('steam_app_id', 'xbox_title_id', 'psn_npwr_id') - if platform_field not in allowed_fields: - return None - row = query(f"SELECT id FROM games WHERE {platform_field} = ?", (platform_value,), one=True) - if row: - return row['id'] +class _DuplicateIndex: + """Duplicate lookup for a whole fetched library, built with one query. - # 2. Normalized title match on same system - norm = normalize_title(title) - if norm and system_id: - games = query("SELECT id, title FROM games WHERE system_id = ?", (system_id,)) - for game in games: - if normalize_title(game['title']) == norm: - return game['id'] + Checking each fetched title with its own queries re-read and + re-normalised every game of the target system per title, so a + 1 000-game Steam library against a 1 000-game Windows system cost a + million title normalisations and 2 000 queries. - return None + `find()` answers as the per-title check did: the platform-ID match + anywhere in the library first, then a normalised-title match among the + games of the given system. The first row in rowid order wins. + """ + + def __init__(self, platform_field): + if platform_field not in _PLATFORM_ID_FIELDS: + raise ValueError(f"unknown platform field {platform_field!r}") + self._by_platform_id = {} + self._by_title = {} + for row in query(f"SELECT id, title, system_id, {platform_field} AS pid " + f"FROM games ORDER BY id"): + if row['pid']: + self._by_platform_id.setdefault(row['pid'], row['id']) + norm = normalize_title(row['title']) + if norm: + self._by_title.setdefault((norm, row['system_id']), row['id']) + + def find(self, title, system_id, platform_value=None): + """Existing game_id for this title / platform ID, else None.""" + if platform_value and platform_value in self._by_platform_id: + return self._by_platform_id[platform_value] + norm = normalize_title(title) + if norm and system_id: + return self._by_title.get((norm, system_id)) + return None # ============================================================================= @@ -167,6 +174,7 @@ def api_steam_fetch_library(): system_id = system['id'] if system else None # Check duplicates for each game + dupes = _DuplicateIndex('steam_app_id') result_games = [] for game in games: app_id = str(game.get('appid', '')) @@ -174,7 +182,7 @@ def api_steam_fetch_library(): playtime = game.get('playtime_forever', 0) last_played = game.get('rtime_last_played', 0) - existing_id = _check_duplicate(name, system_id, 'steam_app_id', app_id) + existing_id = dupes.find(name, system_id, app_id) result_games.append({ 'appid': app_id, @@ -501,6 +509,7 @@ def api_xbox_fetch_library(): if windows_system: xbox_systems['windows'] = windows_system + dupes = _DuplicateIndex('xbox_title_id') result_games = [] for title in titles: name = title.get('name', '') @@ -517,7 +526,7 @@ def api_xbox_fetch_library(): system_id = system['id'] if system else None # Check duplicates - existing_id = _check_duplicate(name, system_id, 'xbox_title_id', title_id) + existing_id = dupes.find(name, system_id, title_id) # Extract achievement summary from title data achievement = title.get('achievement', {}) @@ -833,6 +842,7 @@ def _build_psn_library_response(games_data, from_api=False): if system: psn_systems[platform_str] = system + dupes = _DuplicateIndex('psn_npwr_id') result_games = [] for game in games_data: if from_api: @@ -858,9 +868,7 @@ def _build_psn_library_response(games_data, from_api=False): system_id = system['id'] if system else None # Check duplicates - existing_id = linked_game_id or _check_duplicate( - name, system_id, 'psn_npwr_id', npwr_id - ) + existing_id = linked_game_id or dupes.find(name, system_id, npwr_id) result_games.append({ 'npwr_id': npwr_id, diff --git a/routes/trophies.py b/routes/trophies.py index 78b302d6..2e5d2048 100644 --- a/routes/trophies.py +++ b/routes/trophies.py @@ -12,7 +12,7 @@ import threading import config -from services.database import query, execute +from services.database import query, execute, execute_many from services.api_helpers import success, error from services.auth import login_required, admin_required, editor_required, get_user_settings from services.jobs.base import _download_psn_trophy_image as download_psn_trophy_image @@ -326,8 +326,31 @@ def get_trophy_data(trophy_path=None): return {}, {'P': (0, 0), 'G': (0, 0), 'S': (0, 0), 'B': (0, 0)} -def find_matching_game(trophy_title): - """Find a matching game in RetroDB by title (local RPCS3 trophies = PS3)""" +_PS_FOLDERS_SQL = "('ps3', 'ps4', 'ps5', 'psvita', 'psx', 'ps2')" + + +def _ps_match_candidates(): + """Every PlayStation-family game with its cleaned title, read once. + + `find_matching_game` runs once per trophy set on /trophies; reading and + re-cleaning the PlayStation games inside it cost two full queries and + every title cleaned twice per set. + """ + rows = query(f""" + SELECT g.*, s.name AS system_name, s.folder + FROM games g + JOIN systems s ON g.system_id = s.id + WHERE s.folder IN {_PS_FOLDERS_SQL} + """) + return [(row, _clean_title_for_matching(row['title'])) for row in rows] + + +def find_matching_game(trophy_title, candidates=None): + """Find a matching game in RetroDB by title (local RPCS3 trophies = PS3). + + `candidates` is `_ps_match_candidates()`; pass it when matching many + titles so the games are read once. + """ # Title aliases for common abbreviated names in trophy data title_aliases = { 'MGSV:TPP': 'Metal Gear Solid V: The Phantom Pain', @@ -342,37 +365,23 @@ def find_matching_game(trophy_title): search_title = title_aliases.get(trophy_title, trophy_title) clean_title = _clean_title_for_matching(search_title) - # Pass 1: Exact cleaned match on PS3 only - ps3_games = query(""" - SELECT g.*, s.name AS system_name, s.folder - FROM games g - JOIN systems s ON g.system_id = s.id - WHERE s.folder = 'ps3' - """) + if candidates is None: + candidates = _ps_match_candidates() - if ps3_games: - for game in ps3_games: - if _clean_title_for_matching(game['title']) == clean_title: - return dict(game) + # Pass 1: Exact cleaned match on PS3 only + for game, db_clean in candidates: + if game['folder'] == 'ps3' and db_clean == clean_title: + return dict(game) # Pass 2: Exact cleaned match across all PlayStation systems - all_ps_games = query(""" - SELECT g.*, s.name AS system_name, s.folder - FROM games g - JOIN systems s ON g.system_id = s.id - WHERE s.folder IN ('ps3', 'ps4', 'ps5', 'psvita', 'psx', 'ps2') - """) - - if all_ps_games: - for game in all_ps_games: - if _clean_title_for_matching(game['title']) == clean_title: - return dict(game) + for game, db_clean in candidates: + if db_clean == clean_title: + return dict(game) - # Pass 3: Substring match (one contains the other) - for game in all_ps_games: - db_clean = _clean_title_for_matching(game['title']) - if clean_title and db_clean and (clean_title in db_clean or db_clean in clean_title): - return dict(game) + # Pass 3: Substring match (one contains the other) + for game, db_clean in candidates: + if clean_title and db_clean and (clean_title in db_clean or db_clean in clean_title): + return dict(game) return None @@ -395,8 +404,9 @@ def trophies(): # Match trophy sets with RetroDB games games_data = {} + candidates = _ps_match_candidates() if trophy_sets else [] for npwr_id, ts in trophy_sets.items(): - game = find_matching_game(ts.title) + game = find_matching_game(ts.title, candidates) games_data[npwr_id] = game # Sort trophy sets alphabetically by title @@ -604,8 +614,10 @@ def psn_trophies(): def api_psn_games(): """Paginated PSN games API for the PSN trophies page""" try: - page = request.args.get('page', 1, type=int) - per_page = min(request.args.get('per_page', 60, type=int), 200) + # Clamped: per_page=0 divided by zero below (500), and a page < 1 + # produced a negative OFFSET. + page = max(request.args.get('page', 1, type=int), 1) + per_page = max(min(request.args.get('per_page', 60, type=int), 200), 1) search = request.args.get('search', '').strip() letter = request.args.get('letter', '').strip() sort = request.args.get('sort', 'name-asc').strip() @@ -836,11 +848,11 @@ def trophy_sort_key(t): for t in trophies_list ) locked_count = sum(1 for t in trophies_list if not bool(t.get('earned'))) - logger.info(f"PSN Trophy Detail - platinum_earned: {platinum_earned}, total: {len(trophies_list)}, locked: {locked_count}") + logger.debug(f"PSN Trophy Detail - platinum_earned: {platinum_earned}, total: {len(trophies_list)}, locked: {locked_count}") # Debug: log first few trophies to understand data format for i, t in enumerate(trophies_list[:5]): - logger.info(f" Trophy {i}: type={t.get('trophy_type')!r}, earned={t.get('earned')!r}, group_id={t.get('group_id')!r}, group_name={t.get('group_name')!r}") + logger.debug(f" Trophy {i}: type={t.get('trophy_type')!r}, earned={t.get('earned')!r}, group_id={t.get('group_id')!r}, group_name={t.get('group_name')!r}") for t in trophies_list: if not bool(t.get('earned')): @@ -908,10 +920,10 @@ def trophy_sort_key(t): (platinum_earned and len(locked_trophies) > 0) ) - logger.info(f"PSN Trophy Detail - has_dlc: {has_dlc}, groups_list: {len(groups_list)}, dlc_inferred in locked: {'dlc_inferred' in locked_by_group}") - logger.info(f" locked_by_group keys: {list(locked_by_group.keys())}") + logger.debug(f"PSN Trophy Detail - has_dlc: {has_dlc}, groups_list: {len(groups_list)}, dlc_inferred in locked: {'dlc_inferred' in locked_by_group}") + logger.debug(f" locked_by_group keys: {list(locked_by_group.keys())}") for gid, gdata in locked_by_group.items(): - logger.info(f" Group '{gid}': name='{gdata['name']}', count={len(gdata['trophies'])}") + logger.debug(f" Group '{gid}': name='{gdata['name']}', count={len(gdata['trophies'])}") # Get HLTB data from linked game (shared data source) hltb_data = None @@ -1156,11 +1168,13 @@ def _run_psn_full_sync(psnawp, user_id): # sync threads have no request context, so `g` would surface # only as a typing/IDE hazard, but the shadow is fragile under # future refactors that move this body into a request handler. + # Each title is cleaned once here, not once per trophy title in + # the loop below (that was trophy titles x PS games cleanings). + all_ps_games = [(ps_game['id'], ps_game['folder'], + _clean_title_for_matching(ps_game['title'])) + for ps_game in all_ps_games] for ps_game in all_ps_games: - folder = ps_game['folder'] - if folder not in ps_by_folder: - ps_by_folder[folder] = [] - ps_by_folder[folder].append(ps_game) + ps_by_folder.setdefault(ps_game[1], []).append(ps_game) platform_map = {'PS3': 'ps3', 'PS4': 'ps4', 'PS5': 'ps5', 'PSVITA': 'psvita', 'PS Vita': 'psvita'} @@ -1186,22 +1200,21 @@ def _run_psn_full_sync(psnawp, user_id): # shadowing flask.g (mirrors the loop above and # the existing_groups comprehension at :1384). if sf and sf in ps_by_folder: - for ps_game in ps_by_folder[sf]: - if _clean_title_for_matching(ps_game['title']) == clean_title: - linked_game_id = ps_game['id'] + for game_id, _folder, db_clean in ps_by_folder[sf]: + if db_clean == clean_title: + linked_game_id = game_id break if not linked_game_id: - for ps_game in all_ps_games: - if _clean_title_for_matching(ps_game['title']) == clean_title: - linked_game_id = ps_game['id'] + for game_id, _folder, db_clean in all_ps_games: + if db_clean == clean_title: + linked_game_id = game_id break if not linked_game_id: - for ps_game in all_ps_games: - db_clean = _clean_title_for_matching(ps_game['title']) + for game_id, _folder, db_clean in all_ps_games: if db_clean and (clean_title in db_clean or db_clean in clean_title): - linked_game_id = ps_game['id'] + linked_game_id = game_id break _upsert_psn_title(conn, user_id, title, platform, icon_url, linked_game_id) @@ -1391,7 +1404,10 @@ def api_psn_sync_game(npwr_id): """, (psn_game['id'],)) # Pass 41.8 — `tg` instead of `g` to avoid shadowing flask.g. custom_group_names = {tg['group_id']: tg['group_name'] for tg in existing_groups} if existing_groups else {} - + + # Rows are written in one batch after the loop: one commit per + # trophy made a 100-trophy game 100 separate transactions. + trophy_rows = [] for trophy in trophies: try: # Extract trophy data @@ -1400,7 +1416,7 @@ def api_psn_sync_game(npwr_id): continue # Debug: log trophy attributes for first trophy - if trophies_synced == 0: + if not trophy_rows: logger.info(f"Trophy object type: {type(trophy)}") logger.info(f"Trophy attributes: {[a for a in dir(trophy) if not a.startswith('_')]}") @@ -1408,7 +1424,7 @@ def api_psn_sync_game(npwr_id): group_id = getattr(trophy, 'trophy_group_id', None) or 'default' # Debug: log group info for first 5 trophies - if trophies_synced < 5: + if len(trophy_rows) < 5: api_name = group_names_from_api.get(group_id, 'NOT_FOUND') logger.info(f"Trophy {trophy_id}: group_id={group_id!r}, api_name={api_name!r}") @@ -1471,7 +1487,7 @@ def api_psn_sync_game(npwr_id): earned = False # Debug log for first few trophies - show trophy type details - if trophies_synced < 3: + if len(trophy_rows) < 3: logger.info(f"Trophy {trophy_id}: name='{trophy_name}', raw_type={type(trophy_type_raw).__name__}:{trophy_type_raw}, str={trophy_type_str}, letter={trophy_type}, earned={earned}") earned_date = None @@ -1495,24 +1511,7 @@ def api_psn_sync_game(npwr_id): # direct filtering. The psn_game_id already cascades owner # via psn_games.user_id, so user_id on psn_trophies is a # denormalised lookup aid. - execute(""" - INSERT INTO psn_trophies ( - psn_game_id, user_id, trophy_id, group_id, group_name, - name, description, trophy_type, icon_url, - rarity, rarity_label, earned, earned_date - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(psn_game_id, trophy_id) DO UPDATE SET - group_id = excluded.group_id, - group_name = excluded.group_name, - name = excluded.name, - description = excluded.description, - trophy_type = excluded.trophy_type, - icon_url = excluded.icon_url, - rarity = excluded.rarity, - rarity_label = excluded.rarity_label, - earned = excluded.earned, - earned_date = excluded.earned_date - """, ( + trophy_rows.append(( psn_game['id'], user_id, trophy_id, @@ -1528,12 +1527,31 @@ def api_psn_sync_game(npwr_id): earned_date )) - trophies_synced += 1 - except Exception as te: logger.warning(f"Error syncing trophy: {te}") continue + if trophy_rows: + execute_many(""" + INSERT INTO psn_trophies ( + psn_game_id, user_id, trophy_id, group_id, group_name, + name, description, trophy_type, icon_url, + rarity, rarity_label, earned, earned_date + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(psn_game_id, trophy_id) DO UPDATE SET + group_id = excluded.group_id, + group_name = excluded.group_name, + name = excluded.name, + description = excluded.description, + trophy_type = excluded.trophy_type, + icon_url = excluded.icon_url, + rarity = excluded.rarity, + rarity_label = excluded.rarity_label, + earned = excluded.earned, + earned_date = excluded.earned_date + """, trophy_rows) + trophies_synced = len(trophy_rows) + # Update first and last trophy dates if we found any if first_trophy_date or last_trophy_date: execute(""" diff --git a/tests/test_pass45_security.py b/tests/test_pass45_security.py index 53f711e7..6186c667 100644 --- a/tests/test_pass45_security.py +++ b/tests/test_pass45_security.py @@ -1234,13 +1234,24 @@ def test_get_does_not_mutate_db(self, tmp_path, monkeypatch, admin_test_client): executes = [] - def fake_execute(sql, params=()): - executes.append((sql, params)) - return None + class _RecordingConn: + """Stands in for the request connection _refresh_trophies writes + through; records every statement.""" + def execute(self, sql, params=()): + executes.append((sql, params)) + + def executemany(self, sql, rows): + executes.append((sql, rows)) + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False # Stub the DB layer at the module level. _trophies_sorted reads # via query() so we stub that to return [] (empty). - monkeypatch.setattr(ct, 'execute', fake_execute) + monkeypatch.setattr(ct, 'get_request_db', lambda: _RecordingConn()) monkeypatch.setattr(ct, 'query', lambda sql, params=(), one=False: None if one else []) diff --git a/tests/test_review_routes_core.py b/tests/test_review_routes_core.py new file mode 100644 index 00000000..a4326315 --- /dev/null +++ b/tests/test_review_routes_core.py @@ -0,0 +1,182 @@ +"""Regression pins for the 2026-10-27 review of app.py and the import / +achievement / trophy route blueprints.""" +import sqlite3 + +import pytest + +import config + + +# --------------------------------------------------------------------------- +# routes/auth.py — the last active admin cannot be demoted or deactivated +# --------------------------------------------------------------------------- + +def _stub_user_db(monkeypatch, admin_count): + import routes.auth as auth + writes = [] + + def fake_query(sql, args=(), one=False): + if 'COUNT(*)' in sql: + return {'count': admin_count} + return {'id': 7, 'username': 'solo', 'role': 'admin', 'is_active': 1} + + monkeypatch.setattr(auth, 'query', fake_query) + monkeypatch.setattr(auth, 'execute', lambda sql, args=(): writes.append(sql)) + return writes + + +@pytest.mark.parametrize('body', [{'role': 'editor'}, {'is_active': False}]) +def test_last_admin_cannot_be_demoted_or_deactivated(admin_test_client, monkeypatch, body): + writes = _stub_user_db(monkeypatch, admin_count=1) + client, csrf = admin_test_client() + resp = client.post('/api/users/7/update', json=body, headers={'X-CSRF-Token': csrf}) + assert resp.status_code == 403 + assert resp.get_json()['success'] is False + assert writes == [] + + +def test_admin_can_be_demoted_when_another_admin_remains(admin_test_client, monkeypatch): + writes = _stub_user_db(monkeypatch, admin_count=2) + client, csrf = admin_test_client() + resp = client.post('/api/users/7/update', json={'role': 'editor'}, + headers={'X-CSRF-Token': csrf}) + assert resp.status_code == 200 + assert len(writes) == 1 + + +def test_last_admin_can_still_be_renamed(admin_test_client, monkeypatch): + writes = _stub_user_db(monkeypatch, admin_count=1) + client, csrf = admin_test_client() + resp = client.post('/api/users/7/update', json={'display_name': 'Boss', 'role': 'admin'}, + headers={'X-CSRF-Token': csrf}) + assert resp.status_code == 200 + assert len(writes) == 1 + + +# --------------------------------------------------------------------------- +# routes/collector_trophies.py — batched refresh keeps the old semantics +# --------------------------------------------------------------------------- + +def test_collector_trophy_refresh_batches_and_keeps_earned_at(monkeypatch): + import app as app_module + import routes.collector_trophies as ct + + stats = {td['stat']: 0 for td in ct.TROPHY_DEFINITIONS} + first = ct.TROPHY_DEFINITIONS[0] + stats[first['stat']] = first['threshold'] + monkeypatch.setattr(ct, '_gather_collection_stats', lambda user_id=None: dict(stats)) + + conn = sqlite3.connect(config.DB_PATH) + conn.execute("DELETE FROM collector_trophies WHERE user_id = 1") + conn.commit() + try: + with app_module.app.test_request_context(): + r1 = ct._refresh_trophies(user_id=1) + earned_ids = {td['id'] for td in ct.TROPHY_DEFINITIONS + if stats[td['stat']] >= td['threshold']} + assert r1['newly_earned'] == r1['total_earned'] == len(earned_ids) >= 1 + rows = dict(conn.execute( + "SELECT id, earned_at FROM collector_trophies WHERE user_id = 1").fetchall()) + assert len(rows) == len(ct.TROPHY_DEFINITIONS) + assert {k for k, v in rows.items() if v} == earned_ids + + # Second refresh: nothing new, earned_at untouched, total still counted. + conn.execute("UPDATE collector_trophies SET earned_at = '2000-01-01T00:00:00Z' " + "WHERE user_id = 1 AND earned_at IS NOT NULL") + conn.commit() + with app_module.app.test_request_context(): + r2 = ct._refresh_trophies(user_id=1) + assert r2['newly_earned'] == 0 + assert r2['total_earned'] == len(earned_ids) + kept = {r[0] for r in conn.execute( + "SELECT earned_at FROM collector_trophies WHERE user_id = 1 " + "AND earned_at IS NOT NULL")} + assert kept == {'2000-01-01T00:00:00Z'} + finally: + conn.execute("DELETE FROM collector_trophies WHERE user_id = 1") + conn.commit() + conn.close() + + +# --------------------------------------------------------------------------- +# app.py — the sidebar rank is read once per request +# --------------------------------------------------------------------------- + +def test_collector_rank_read_once_per_request(monkeypatch): + import app as app_module + import routes.collector_trophies as ct + calls = [] + monkeypatch.setattr(ct, 'get_current_rank', lambda: calls.append(1) or {'name': 'x'}) + with app_module.app.test_request_context(): + assert app_module._get_collector_rank_safe() == {'name': 'x'} + assert app_module._get_collector_rank_safe() == {'name': 'x'} + assert len(calls) == 1 + + +# --------------------------------------------------------------------------- +# routes/platform_import.py — one duplicate index per fetched library +# --------------------------------------------------------------------------- + +def test_duplicate_index_matches_like_the_per_title_check(monkeypatch): + import routes.platform_import as pi + rows = [ + {'id': 1, 'title': 'Halo', 'system_id': 10, 'pid': None}, + {'id': 2, 'title': 'Portal', 'system_id': 20, 'pid': '400'}, + {'id': 3, 'title': 'HALO!', 'system_id': 10, 'pid': None}, + {'id': 4, 'title': 'Abzû', 'system_id': 10, 'pid': '400'}, + ] + seen_sql = [] + + def fake_query(sql, args=(), one=False): + seen_sql.append(sql) + return rows + + monkeypatch.setattr(pi, 'query', fake_query) + idx = pi._DuplicateIndex('steam_app_id') + # Platform ID wins over title, across systems; first row wins. + assert idx.find('Something else', 10, '400') == 2 + # Normalised title, same system only; first row wins. + assert idx.find('halo', 10, '999') == 1 + assert idx.find('halo', 20, '999') is None + assert idx.find('Abzu', 10) == 4 + assert idx.find('', 10) is None + assert idx.find('Halo', None) is None + assert len(seen_sql) == 1 + + with pytest.raises(ValueError): + pi._DuplicateIndex('title; DROP TABLE games') + + +# --------------------------------------------------------------------------- +# routes/trophies.py — local-trophy matching reads the games once +# --------------------------------------------------------------------------- + +def test_find_matching_game_with_candidates_skips_the_query(monkeypatch): + import routes.trophies as tr + rows = [ + {'id': 1, 'title': 'Journey', 'folder': 'ps4'}, + {'id': 2, 'title': 'Journey', 'folder': 'ps3'}, + {'id': 3, 'title': 'Uncharted 2: Among Thieves', 'folder': 'ps3'}, + ] + calls = [] + monkeypatch.setattr(tr, 'query', lambda *a, **k: calls.append(a) or rows) + candidates = tr._ps_match_candidates() + assert len(calls) == 1 + + # PS3 exact match beats an earlier exact match on another system. + assert tr.find_matching_game('Journey', candidates)['id'] == 2 + # Substring pass. + assert tr.find_matching_game('Uncharted 2', candidates)['id'] == 3 + assert tr.find_matching_game('Nothing Like It', candidates) is None + assert len(calls) == 1 + # Without candidates it still reads them itself. + assert tr.find_matching_game('Journey')['id'] == 2 + assert len(calls) == 2 + + +def test_psn_games_api_clamps_paging(admin_test_client): + client, _csrf = admin_test_client() + resp = client.get('/api/psn/games?per_page=0&page=-3') + assert resp.status_code == 200 + body = resp.get_json() + assert body['per_page'] == 1 and body['page'] == 1 From 0ec471d7751609e6016d188d01f11367084b5ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:30:42 +0000 Subject: [PATCH 5/8] =?UTF-8?q?fix(scraper):=20review=20sweep=20=E2=80=94?= =?UTF-8?q?=20ratings,=20defaults,=20ScreenScraper=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IGDB age ratings read IGDB's single global rating enum (CERO/USK/GRAC/ ClassInd/ACB were mapped as 1-5) and the newer organization / rating_category shape; hybrid fetch requests those fields - IGDB players: null offlinemax no longer aborts the merge; no made-up 1 - ScreenScraper primary reads the flat get_game_info dict on a cache miss - ScreenScraper media extensions come from the URL path, not the query string (which carried account credentials into file names) - TGDB lookup tables: a failed fetch is retried, not cached empty; unknown IDs are dropped instead of written as bare numbers - TGDB / RAWG / ES-DE no longer invent players=1 (ES-DE overwrote curated players/modes through COALESCE) - ES-DE gamelist: full-path match wins over a same-filename match - RetroAchievements: curly apostrophes normalised; search title once - Scans keep an existing system logo; scan_roms finds SVG logos Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013s5ztLFVCcdTjjTeVcTnJr --- scraper/hybrid_scraper.py | 1 + scraper/metadata_merger.py | 179 +++++++++++++++++++------- scraper/retroachievements.py | 19 ++- scraper/scan_roms.py | 16 ++- scraper/scrape_esde.py | 67 +++++----- scraper/scrape_rawg.py | 6 +- scraper/scrape_thegamesdb.py | 118 +++++++++-------- services/rom_scanner.py | 4 +- tests/test_metadata_merger.py | 77 ++++++++++- tests/test_scraper_lookup_defaults.py | 149 +++++++++++++++++++++ 10 files changed, 478 insertions(+), 158 deletions(-) create mode 100644 tests/test_scraper_lookup_defaults.py diff --git a/scraper/hybrid_scraper.py b/scraper/hybrid_scraper.py index 9e7ca784..1c8dac3b 100644 --- a/scraper/hybrid_scraper.py +++ b/scraper/hybrid_scraper.py @@ -258,6 +258,7 @@ def fetch_igdb_extended(game_id): fields name, first_release_date, involved_companies.company.name, involved_companies.developer, involved_companies.publisher, genres.name, age_ratings.category, age_ratings.rating, + age_ratings.organization.name, age_ratings.rating_category.rating, game_modes.name, player_perspectives.name, multiplayer_modes.offlinemax, multiplayer_modes.onlinemax, multiplayer_modes.campaigncoop, multiplayer_modes.splitscreen, diff --git a/scraper/metadata_merger.py b/scraper/metadata_merger.py index 8cc4ed0a..98b452c5 100644 --- a/scraper/metadata_merger.py +++ b/scraper/metadata_merger.py @@ -165,6 +165,23 @@ def _download_and_finalize(url, local_path, image_type, *, timeout=15): return True +# File extensions a ScreenScraper media URL may name in its path. Its media +# URLs are usually `.../mediaJeu.php?devid=...&media=box-2D(us)`, so the text +# after the last dot is "php?..." (query string, account credentials and all), +# not a file type. +_MEDIA_EXTS = frozenset({ + 'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mkv', 'avi', + 'mov', 'pdf', 'cbz', 'zip', +}) + + +def _url_ext(url, default): + """The media file extension (no dot) a URL's path names, else `default`.""" + from urllib.parse import urlparse + ext = os.path.splitext(urlparse(url).path)[1].lstrip('.').lower() + return ext if ext in _MEDIA_EXTS else default + + # Bounded concurrency for a game's screenshot downloads. ES-DE fetches a game's # media files all at once (Scraper.cpp / MediaDownloadHandle) rather than # one-by-one; this is the Python analogue. Capped so a burst never floods a @@ -400,6 +417,83 @@ def _tgdb_ss_job(url, suffix): result['filled_fields'].append('franchise (TGDB)') +# ============================================================================= +# IGDB AGE RATINGS +# ============================================================================= +# IGDB's legacy `category` enum names the board (1=ESRB, 2=PEGI, 3=CERO, 4=USK, +# 5=GRAC, 6=CLASS_IND, 7=ACB); its `rating` enum is ONE global list across all +# boards: 1-5 PEGI, 6-12 ESRB, 13-17 CERO, 18-22 USK, 23-27 GRAC, 28-33 +# CLASS_IND, 34-39 ACB. IGDB is replacing both with `organization` and +# `rating_category` objects (fetched as organization.name and +# rating_category.rating), so a record may carry only the new shape. +_IGDB_RATING_BOARDS = { + 1: 'esrb_rating', 2: 'pegi_rating', 3: 'cero_rating', 4: 'usk_rating', + 5: 'grac_rating', 6: 'classind_rating', 7: 'acb_rating', +} +_IGDB_ORG_BOARDS = { + 'ESRB': 'esrb_rating', 'PEGI': 'pegi_rating', 'CERO': 'cero_rating', + 'USK': 'usk_rating', 'GRAC': 'grac_rating', 'CLASS_IND': 'classind_rating', + 'CLASSIND': 'classind_rating', 'ACB': 'acb_rating', +} +_IGDB_RATING_ENUM = { + 1: 'PEGI 3', 2: 'PEGI 7', 3: 'PEGI 12', 4: 'PEGI 16', 5: 'PEGI 18', + 6: 'RP', 7: 'EC', 8: 'E', 9: 'E10+', 10: 'T', 11: 'M', 12: 'AO', + 13: 'A', 14: 'B', 15: 'C', 16: 'D', 17: 'Z', + 18: '0', 19: '6', 20: '12', 21: '16', 22: '18', + 23: 'ALL', 24: '12', 25: '15', 26: '18', + 28: 'L', 29: '10', 30: '12', 31: '14', 32: '16', 33: '18', + 34: 'G', 35: 'PG', 36: 'M', 37: 'MA15+', 38: 'R18+', +} +# Which enum values belong to which board, so a mismatched pair is ignored. +_IGDB_RATING_ENUM_RANGE = { + 'pegi_rating': range(1, 6), 'esrb_rating': range(6, 13), + 'cero_rating': range(13, 18), 'usk_rating': range(18, 23), + 'grac_rating': range(23, 28), 'classind_rating': range(28, 34), + 'acb_rating': range(34, 40), +} +_IGDB_RATING_WORDS = { + 'THREE': '3', 'SEVEN': '7', 'TEN': '10', 'TWELVE': '12', 'FOURTEEN': '14', + 'FIFTEEN': '15', 'SIXTEEN': '16', 'EIGHTEEN': '18', +} +# rating_category.rating text (after word → number and '+' removal) → value. +_IGDB_RATING_TEXT = { + 'esrb_rating': {'RP': 'RP', 'EC': 'EC', 'E': 'E', 'E10': 'E10+', 'T': 'T', + 'M': 'M', 'AO': 'AO'}, + 'pegi_rating': {n: f'PEGI {n}' for n in ('3', '7', '12', '16', '18')}, + 'cero_rating': {v: v for v in ('A', 'B', 'C', 'D', 'Z')}, + 'usk_rating': {v: v for v in ('0', '6', '12', '16', '18')}, + 'grac_rating': {'ALL': 'ALL', '12': '12', '15': '15', '18': '18'}, + 'classind_rating': {v: v for v in ('L', '10', '12', '14', '16', '18')}, + 'acb_rating': {'G': 'G', 'PG': 'PG', 'M': 'M', 'MA15': 'MA15+', 'R18': 'R18+'}, +} + + +def _igdb_age_rating(rating): + """(metadata field, value) for one IGDB age_ratings entry, or (None, None).""" + if not isinstance(rating, dict): + return None, None + field = _IGDB_RATING_BOARDS.get(rating.get('category')) + if not field: + org = rating.get('organization') + org_name = org.get('name') if isinstance(org, dict) else None + field = _IGDB_ORG_BOARDS.get(str(org_name or '').strip().upper()) + if not field: + return None, None + + val = rating.get('rating') + if val in _IGDB_RATING_ENUM_RANGE[field]: + return field, _IGDB_RATING_ENUM.get(val) + + rc = rating.get('rating_category') + text = rc.get('rating') if isinstance(rc, dict) else None + if not text: + return field, None + token = re.sub(r'^(?:ESRB|PEGI|CERO|USK|GRAC|CLASS_?IND|ACB)[\s_]*', '', + str(text).strip().upper()).replace('+', '').strip() + token = _IGDB_RATING_WORDS.get(token, token) + return field, _IGDB_RATING_TEXT[field].get(token) + + # ============================================================================= # IGDB METADATA APPLICATION # ============================================================================= @@ -486,30 +580,20 @@ def apply_igdb_to_metadata(metadata, igdb_data, db_game_id, result, fill_only=Fa if not metadata['players']: multi = igdb_data.get('multiplayer_modes', []) if multi: - max_p = max((m.get('offlinemax', 1) for m in multi), default=1) - metadata['players'] = max_p - result['filled_fields'].append('players (IGDB)') - - # Age ratings — IGDB categories: 1=ESRB, 2=PEGI, 3=CERO, 4=USK, 5=GRAC, 6=CLASS_IND, 7=ACB - _igdb_rating_maps = { - 1: ('esrb_rating', {6: 'RP', 7: 'EC', 8: 'E', 9: 'E10+', 10: 'T', 11: 'M', 12: 'AO'}), - 2: ('pegi_rating', {1: 'PEGI 3', 2: 'PEGI 7', 3: 'PEGI 12', 4: 'PEGI 16', 5: 'PEGI 18'}), - 3: ('cero_rating', {1: 'A', 2: 'B', 3: 'C', 4: 'D', 5: 'Z'}), - 4: ('usk_rating', {1: '0', 2: '6', 3: '12', 4: '16', 5: '18'}), - 5: ('grac_rating', {1: 'ALL', 2: '12', 3: '15', 4: '18'}), - 6: ('classind_rating', {1: 'L', 2: '10', 3: '12', 4: '14', 5: '16', 6: '18'}), - 7: ('acb_rating', {1: 'G', 2: 'PG', 3: 'M', 4: 'MA15+', 5: 'R18+'}), - } - for rating in igdb_data.get('age_ratings', []): - cat = rating.get('category') - val = rating.get('rating') - if cat in _igdb_rating_maps: - field, vmap = _igdb_rating_maps[cat] - if not metadata.get(field): - mapped = vmap.get(val) - if mapped: - metadata[field] = mapped - result['filled_fields'].append(f'{field} (IGDB)') + # `or 0`: IGDB may send an explicit null, which max() cannot + # compare with an int. No offline count leaves the field open. + max_p = max(((m.get('offlinemax') or 0) for m in multi if isinstance(m, dict)), + default=0) + if max_p: + metadata['players'] = max_p + result['filled_fields'].append('players (IGDB)') + + # Age ratings — see _igdb_age_rating for the two IGDB shapes. + for rating in igdb_data.get('age_ratings') or []: + field, mapped = _igdb_age_rating(rating) + if field and mapped and not metadata.get(field): + metadata[field] = mapped + result['filled_fields'].append(f'{field} (IGDB)') # Download cover (only if not already present) if not metadata['boxart']: @@ -787,8 +871,12 @@ def get_localized(items, lang='en'): return items[0].get('text', '') return None + # `ss_data` is either a raw jeu record (search cache) or the flat dict + # scrape_screenscraper.parse_game_data returns (ROM lookup on a cache + # miss). Each text field below reads the raw key first, then the flat one. + # Title - always set when primary source (fill_only=False), matching IGDB/RAWG - title = get_localized(ss_data.get('noms')) + title = get_localized(ss_data.get('noms')) or ss_data.get('title') if title and (not metadata['title'] or not fill_only): metadata['title'] = normalize_title(title) result['filled_fields'].append('title (ScreenScraper)') @@ -817,30 +905,30 @@ def get_localized(items, lang='en'): result['filled_fields'].append(f'alternate_titles +{added} (ScreenScraper)') # Description - only fill if empty - desc = get_localized(ss_data.get('synopsis')) + desc = get_localized(ss_data.get('synopsis')) or ss_data.get('description') if desc and not metadata['description']: metadata['description'] = desc result['filled_fields'].append('description (ScreenScraper)') # Release date - only fill if empty - dates = ss_data.get('dates', []) + dates = ss_data.get('dates') or ss_data.get('release_date') if dates and not metadata['release_date']: - date_str = get_localized(dates) + date_str = dates if isinstance(dates, str) else get_localized(dates) if date_str: metadata['release_date'] = date_str[:10] if len(date_str) >= 10 else date_str result['filled_fields'].append('release_date (ScreenScraper)') # Developer/Publisher - only fill if empty - if ss_data.get('developpeur') and not metadata['developer']: - dev = ss_data['developpeur'] + if (ss_data.get('developpeur') or ss_data.get('developer')) and not metadata['developer']: + dev = ss_data.get('developpeur') or ss_data['developer'] if isinstance(dev, dict): metadata['developer'] = dev.get('text', '') else: metadata['developer'] = str(dev) result['filled_fields'].append('developer (ScreenScraper)') - if ss_data.get('editeur') and not metadata['publisher']: - pub = ss_data['editeur'] + if (ss_data.get('editeur') or ss_data.get('publisher')) and not metadata['publisher']: + pub = ss_data.get('editeur') or ss_data['publisher'] if isinstance(pub, dict): metadata['publisher'] = pub.get('text', '') else: @@ -851,17 +939,20 @@ def get_localized(items, lang='en'): genres = ss_data.get('genres', []) if genres and not metadata['genre']: genre_names = [] - for g in genres: - name = get_localized(g.get('noms', [])) - if name: - genre_names.append(name) + if isinstance(genres, str): + genre_names.append(genres) + else: + for g in genres: + name = get_localized(g.get('noms', [])) + if name: + genre_names.append(name) if genre_names: metadata['genre'] = normalize_genre(', '.join(genre_names)) result['filled_fields'].append('genre (ScreenScraper)') # Players - only fill if empty - if ss_data.get('joueurs') and not metadata['players']: - players = ss_data['joueurs'] + if (ss_data.get('joueurs') or ss_data.get('players')) and not metadata['players']: + players = ss_data.get('joueurs') or ss_data['players'] if isinstance(players, dict): metadata['players'] = players.get('text', '') else: @@ -1050,7 +1141,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): else: ext = 'png' else: - ext = boxart_url.rsplit('.', 1)[-1] if '.' in boxart_url else 'png' + ext = _url_ext(boxart_url, 'png') if boxart_url: filename = f"{db_game_id}_ss_boxart{preferred_image_extension('boxart', '.' + ext)}" @@ -1073,7 +1164,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): else: ext = 'png' else: - ext = boxart_3d_url.rsplit('.', 1)[-1] if '.' in boxart_3d_url else 'png' + ext = _url_ext(boxart_3d_url, 'png') if boxart_3d_url: filename = f"{db_game_id}_ss_boxart3d{preferred_image_extension('boxart_3d', '.' + ext)}" @@ -1095,7 +1186,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): _ss_candidates = [] if parsed_media.get('screenshot'): _u = parsed_media['screenshot'] - _ss_candidates.append((_u, _u.rsplit('.', 1)[-1] if '.' in _u else 'png')) + _ss_candidates.append((_u, _url_ext(_u, 'png'))) for m in medias: if m.get('type') == 'ss' and m.get('url'): _ss_candidates.append((m['url'], m.get('format', 'png'))) @@ -1135,7 +1226,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): else: ext = 'jpg' else: - ext = fanart_url.rsplit('.', 1)[-1] if '.' in fanart_url else 'jpg' + ext = _url_ext(fanart_url, 'jpg') if fanart_url: filename = f"{db_game_id}_ss_fanart{preferred_image_extension('fanart', '.' + ext)}" @@ -1157,7 +1248,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): else: ext = 'mp4' else: - ext = video_url.rsplit('.', 1)[-1] if '.' in video_url else 'mp4' + ext = _url_ext(video_url, 'mp4') if video_url: filename = f"{db_game_id}_ss_video.{ext}" @@ -1180,7 +1271,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): else: ext = 'pdf' else: - ext = manual_url.rsplit('.', 1)[-1] if '.' in manual_url else 'pdf' + ext = _url_ext(manual_url, 'pdf') if manual_url: filename = f"{db_game_id}_ss_manual.{ext}" diff --git a/scraper/retroachievements.py b/scraper/retroachievements.py index de29c852..320f8091 100644 --- a/scraper/retroachievements.py +++ b/scraper/retroachievements.py @@ -170,10 +170,12 @@ def normalize_title(title): title = re.sub(r'\s*[\(\[].*?[\)\]]\s*', '', title) # Normalize punctuation - replace with space title = re.sub(r"[:\-\u2013\u2014/&]", ' ', title) + # Normalize apostrophe variants to standard - before the strip below, + # which would otherwise delete a curly one ("Kirby’s" -> "kirbys" + # while "Kirby's" -> "kirby's", so the two never matched) + title = re.sub(r"[\u2019\u2018\u02BC\u02BB`\u00B4\u2032\u02B9]", "'", title) # Remove remaining special chars except apostrophes title = re.sub(r"[^\w\s']", '', title) - # Normalize apostrophe variants to standard - title = re.sub(r"[\u2019\u2018\u02BC\u02BB`\u00B4\u2032\u02B9]", "'", title) # Collapse whitespace title = re.sub(r'\s+', ' ', title).strip() return title @@ -189,12 +191,16 @@ def get_significant_words(title): return set(w for w in words if w not in stopwords and (len(w) > 1 or w.isdigit())) - def calculate_similarity(search_title, ra_title): + # The search side is the same for every RA title on the console (often + # 2,000+), so normalize it once rather than per comparison. + norm_search = normalize_title(game_name) + search_words = get_significant_words(game_name) + + def calculate_similarity(ra_title): """ - Calculate match score between search title and RA title + Calculate match score between the search title and an RA title Returns a score from 0-100 """ - norm_search = normalize_title(search_title) norm_ra = normalize_title(ra_title) # Exact match after normalization = perfect score @@ -202,7 +208,6 @@ def calculate_similarity(search_title, ra_title): return 100 # Word-based comparison - search_words = get_significant_words(search_title) ra_words = get_significant_words(ra_title) if not search_words or not ra_words: @@ -285,7 +290,7 @@ def calculate_similarity(search_title, ra_title): for game in games: ra_title = game.get('Title', '') - score = calculate_similarity(game_name, ra_title) + score = calculate_similarity(ra_title) if score > best_score: best_score = score diff --git a/scraper/scan_roms.py b/scraper/scan_roms.py index 146e9088..93f27423 100644 --- a/scraper/scan_roms.py +++ b/scraper/scan_roms.py @@ -16,6 +16,7 @@ from config import SYSTEM_NAME_MAP from scraper.base_scraper import get_scraper_conn from services.rom_scanner import RomPathNotConfigured +from services.game_utils import find_image_file logger = logging.getLogger(__name__) @@ -146,11 +147,11 @@ def scan_roms(): # Get system name from mapping or generate from folder name system_name = SYSTEM_NAME_MAP.get(folder, folder.replace('_', ' ').title()) - # Check for system logo (folder.png in static/images/systems/) - logo_filename = f"{folder}.png" - logo_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - 'static', 'images', 'systems', logo_filename) - system_logo = logo_filename if os.path.exists(logo_path) else None + # System logo in static/images/systems/, any supported extension: a + # PNG-only check set the logo of every SVG-only system (genesis, ...) + # to NULL on each scan. + system_logo, _ = find_image_file( + os.path.join(config.STATIC_PATH, 'images', 'systems'), folder) # Insert or update system c.execute("INSERT OR IGNORE INTO systems (name, folder, logo) VALUES (?, ?, ?)", @@ -164,8 +165,9 @@ def scan_roms(): system_id = result[0] - # Update system name and logo - c.execute("UPDATE systems SET name = ?, logo = ? WHERE folder = ?", + # Update system name and logo (a logo already set is kept when none + # is found on disk) + c.execute("UPDATE systems SET name = ?, logo = COALESCE(?, logo) WHERE folder = ?", (system_name, system_logo, folder)) # Get supported extensions from systeminfo.txt diff --git a/scraper/scrape_esde.py b/scraper/scrape_esde.py index 6a251922..6fa98980 100644 --- a/scraper/scrape_esde.py +++ b/scraper/scrape_esde.py @@ -655,48 +655,41 @@ def fetch_esde_game_details(game_path, system_folder=None): for i, g in enumerate(games[:5]): logger.info(f" [{i}] {g.get('path', 'NO PATH')}") - # Try to match the game by path + # Try to match the game by path. A full-path match wins over a bare + # filename match: games kept in their own folders can share a filename + # ("./Game A/disc1.chd", "./Game B/disc1.chd"), and returning the first + # filename hit picked whichever came first in the gamelist. + search_normalized = decoded_game_path.replace('./', '').strip() + search_filename = os.path.basename(decoded_game_path) + match = None + filename_match = None for game in games: - stored_path = game.get('path', '') - - # Normalize paths for comparison + stored_path = game.get('path') or '' stored_normalized = stored_path.replace('./', '').strip() - search_normalized = decoded_game_path.replace('./', '').strip() - - # Also try with just the filename - stored_filename = os.path.basename(stored_path) - search_filename = os.path.basename(decoded_game_path) - - # Try multiple matching strategies - matched = False - if stored_path == decoded_game_path: - logger.info(f"Found exact match for path: {decoded_game_path}") - matched = True - elif stored_normalized == search_normalized: - logger.info(f"Found match after normalizing: {search_normalized}") - matched = True - elif stored_filename == search_filename: - logger.info(f"Found match by filename: {search_filename}") - matched = True - elif stored_normalized.lower() == search_normalized.lower(): - logger.info(f"Found case-insensitive match: {search_normalized}") - matched = True - - if not matched: - continue - - # Found a match - format and return + if (stored_path == decoded_game_path + or stored_normalized.lower() == search_normalized.lower()): + logger.info(f"Found path match: {decoded_game_path}") + match = game + break + if filename_match is None and os.path.basename(stored_path) == search_filename: + filename_match = game + if match is None and filename_match is not None: + logger.info(f"Found match by filename: {search_filename}") + match = filename_match + + if match is not None: + game = match release_date = game.get('releasedate', '') if release_date and len(release_date) >= 8: release_date = f"{release_date[:4]}-{release_date[4:6]}-{release_date[6:8]}" - + return { 'name': game.get('name', ''), 'description': game.get('desc', ''), 'developer': game.get('developer', ''), 'publisher': game.get('publisher', ''), 'genre': game.get('genre', ''), - 'players': game.get('players', '1'), + 'players': game.get('players', ''), 'release_date': release_date, 'rating': game.get('rating', ''), 'image_path': game.get('image', ''), @@ -704,7 +697,7 @@ def fetch_esde_game_details(game_path, system_folder=None): 'video_path': game.get('video', ''), 'esde_data': game } - + logger.error(f"No matching game found for path: {game_path} (decoded: {decoded_game_path}) in {system_folder}") return None @@ -932,7 +925,10 @@ def copy_media(src_path, dest_folder, prefix): from services.normalization import normalize_genre from services.game_utils import normalize_players_value genre = normalize_genre(esde_data.get('genre', '') or game_data.get('genre', '')) - players = normalize_players_value(esde_data.get('players', '1') or game_data.get('players', '1')) + # No default: a gamelist row without must leave the column + # alone. A '1' default wrote players=1 / modes='Single-Player' through + # COALESCE over curated values on every ES-DE import (Pass 40.6 class). + players = normalize_players_value(esde_data.get('players') or game_data.get('players')) # Format release date release_date = esde_data.get('releasedate', '') or game_data.get('release_date', '') @@ -1142,8 +1138,9 @@ def copy_media(src_path, dest_folder, prefix): # Extract region from ROM filename region = extract_region_from_filename(rom_filename) - # Derive game modes from players - modes = derive_game_modes(players) + # Derive game modes from players (only when the gamelist gave a count) + from services.normalization import modes_from_player_count + modes = modes_from_player_count(players) or None # Pass 48.3 — only flip `scraped = 1` when ES-DE actually filled at least # one metadata/media field. `region` is excluded from the test: it's diff --git a/scraper/scrape_rawg.py b/scraper/scrape_rawg.py index abb92de3..23458a33 100644 --- a/scraper/scrape_rawg.py +++ b/scraper/scrape_rawg.py @@ -268,8 +268,10 @@ def get_game_details(game_id): if screenshots_data and 'results' in screenshots_data: screenshot_urls = [s.get('image') for s in screenshots_data['results'][:5] if s.get('image')] - # Determine player count from tags - players = 1 + # Determine player count from tags. None when no tag says, so the + # fill-only merge leaves `players` open for a source that knows + # (a default of 1 filled it with a guess no later source could replace). + players = None if game.get('tags'): for tag in game['tags']: tag_name = tag.get('name', '').lower() diff --git a/scraper/scrape_thegamesdb.py b/scraper/scrape_thegamesdb.py index 854a58c3..b07b06f4 100644 --- a/scraper/scrape_thegamesdb.py +++ b/scraper/scrape_thegamesdb.py @@ -135,6 +135,7 @@ def clear_caches(): _publishers_cache = None _developers_cache = None _genres_cache = None + _lookup_failed_at.clear() logger.info("TGDB lookup caches cleared") @@ -142,78 +143,72 @@ def clear_caches(): # CACHED LOOKUP TABLES # ============================================================================= -def _get_publishers(): - """Fetch and cache all publisher names (single API call, reused across games).""" - global _publishers_cache - if _publishers_cache is not None: - return _publishers_cache +# A failed lookup-table fetch is retried after this many seconds rather than +# cached as an empty table for the life of the process. +_LOOKUP_RETRY_SECONDS = 300 +_lookup_failed_at = {} - try: - response = _tgdb_request(f"{TGDB_API_BASE}/v1/Publishers", params={}, timeout=15) - if response and response.status_code == 200: - data = response.json() - _publishers_cache = data.get('data', {}).get('publishers', {}) - logger.info(f"Cached {len(_publishers_cache)} TGDB publishers") - return _publishers_cache - except Exception as e: - logger.warning(f"Failed to fetch publishers: {e}") - - _publishers_cache = {} - return _publishers_cache +def _get_lookup(kind): + """Fetch and cache one TGDB lookup table ('publishers', 'developers' or + 'genres') — a single API call, reused across games. -def _get_developers(): - """Fetch and cache all developer names (single API call, reused across games).""" - global _developers_cache - if _developers_cache is not None: - return _developers_cache + A failed fetch returns {} without caching it, so one transient error does + not leave every later game in the process with unresolved IDs; retries + are spaced by _LOOKUP_RETRY_SECONDS. + """ + cache_name = f'_{kind}_cache' + cached = globals()[cache_name] + if cached is not None: + return cached + if time.time() - _lookup_failed_at.get(kind, 0) < _LOOKUP_RETRY_SECONDS: + return {} try: - response = _tgdb_request(f"{TGDB_API_BASE}/v1/Developers", params={}, timeout=15) + response = _tgdb_request(f"{TGDB_API_BASE}/v1/{kind.capitalize()}", params={}, timeout=15) if response and response.status_code == 200: - data = response.json() - _developers_cache = data.get('data', {}).get('developers', {}) - logger.info(f"Cached {len(_developers_cache)} TGDB developers") - return _developers_cache + table = response.json().get('data', {}).get(kind, {}) + if isinstance(table, dict) and table: + globals()[cache_name] = table + logger.info(f"Cached {len(table)} TGDB {kind}") + return table except Exception as e: - logger.warning(f"Failed to fetch developers: {e}") + logger.warning(f"Failed to fetch {kind}: {e}") - _developers_cache = {} - return _developers_cache + _lookup_failed_at[kind] = time.time() + return {} -def _get_genres(): - """Fetch and cache all genre names (single API call, reused across games).""" - global _genres_cache - if _genres_cache is not None: - return _genres_cache +def _get_publishers(): + """Cached TGDB publisher table.""" + return _get_lookup('publishers') + + +def _get_developers(): + """Cached TGDB developer table.""" + return _get_lookup('developers') - try: - response = _tgdb_request(f"{TGDB_API_BASE}/v1/Genres", params={}, timeout=15) - if response and response.status_code == 200: - data = response.json() - _genres_cache = data.get('data', {}).get('genres', {}) - logger.info(f"Cached {len(_genres_cache)} TGDB genres") - return _genres_cache - except Exception as e: - logger.warning(f"Failed to fetch genres: {e}") - _genres_cache = {} - return _genres_cache +def _get_genres(): + """Cached TGDB genre table.""" + return _get_lookup('genres') def _resolve_names(ids, cache_fn, entity_key='name'): - """Resolve a list of entity IDs to names using a cached lookup table.""" + """Resolve a list of entity IDs to names using a cached lookup table. + + An ID the table does not name is left out: writing the bare number as a + publisher/developer name would fill the field with junk that no later + source can replace (fill-only). + """ if not ids: return '' lookup = cache_fn() names = [] for eid in ids: - info = lookup.get(str(eid), {}) - if info: - names.append(info.get(entity_key, str(eid))) - else: - names.append(str(eid)) + info = lookup.get(str(eid)) + if isinstance(info, dict) and info.get(entity_key): + names.append(info[entity_key]) return ', '.join(names) @@ -224,13 +219,16 @@ def _resolve_genre_names(genre_ids): lookup = _get_genres() names = [] for gid in genre_ids: - info = lookup.get(str(gid), {}) - if info: - names.append(info.get('name', str(gid))) - elif isinstance(gid, int) and gid in TGDB_GENRE_MAP: - names.append(TGDB_GENRE_MAP[gid]) - else: - names.append(str(gid)) + info = lookup.get(str(gid)) + if isinstance(info, dict) and info.get('name'): + names.append(info['name']) + continue + try: + fallback = TGDB_GENRE_MAP.get(int(gid)) + except (TypeError, ValueError): + fallback = None + if fallback: + names.append(fallback) return names @@ -442,7 +440,7 @@ def fetch_game_details(game_id): 'publisher': publisher_str, 'developer': developer_str, 'genres': genre_names, - 'players': game.get('players', 1), + 'players': game.get('players'), 'rating': game.get('rating', ''), 'summary': game.get('overview', ''), 'boxart_url': boxart_url, diff --git a/services/rom_scanner.py b/services/rom_scanner.py index 00a7e28e..837dabbe 100644 --- a/services/rom_scanner.py +++ b/services/rom_scanner.py @@ -138,7 +138,9 @@ def run_inline_scan(): logger.warning(f"System not found for folder: {folder}") continue system_id = row[0] - c.execute("UPDATE systems SET name = ?, logo = ? WHERE folder = ?", + # Keep a logo already set when none is bundled (an imported ES-DE + # logo lives under IMAGE_PATH, not STATIC_PATH). + c.execute("UPDATE systems SET name = ?, logo = COALESCE(?, logo) WHERE folder = ?", (system_name, system_logo, folder)) extensions = parse_systeminfo(system_path) diff --git a/tests/test_metadata_merger.py b/tests/test_metadata_merger.py index 23bcfbfa..74d52f81 100644 --- a/tests/test_metadata_merger.py +++ b/tests/test_metadata_merger.py @@ -210,8 +210,13 @@ def test_age_rating_category_map(self, blank): 'age_ratings': [ {'category': 1, 'rating': 11}, # ESRB M {'category': 2, 'rating': 5}, # PEGI 18 - {'category': 3, 'rating': 5}, # CERO Z - {'category': 4, 'rating': 5}, # USK 18 + # IGDB's rating enum is one global list across boards: + # CERO is 13-17, USK 18-22, GRAC 23-27, CLASS_IND 28-33, ACB 34-39. + {'category': 3, 'rating': 17}, # CERO Z + {'category': 4, 'rating': 22}, # USK 18 + {'category': 5, 'rating': 25}, # GRAC 15 + {'category': 6, 'rating': 31}, # CLASS_IND 14 + {'category': 7, 'rating': 37}, # ACB MA15+ ], } apply_igdb_to_metadata(meta, igdb, db_game_id=1, result=result) @@ -219,6 +224,46 @@ def test_age_rating_category_map(self, blank): assert meta['pegi_rating'] == 'PEGI 18' assert meta['cero_rating'] == 'Z' assert meta['usk_rating'] == '18' + assert meta['grac_rating'] == '15' + assert meta['classind_rating'] == '14' + assert meta['acb_rating'] == 'MA15+' + + def test_age_rating_board_value_mismatch_ignored(self, blank): + meta, result = blank + # CERO with a PEGI-range value is not a CERO rating; the old per-board + # 1-5 maps read it as CERO Z. + apply_igdb_to_metadata(meta, {'age_ratings': [{'category': 3, 'rating': 5}]}, + db_game_id=1, result=result) + assert meta['cero_rating'] == '' + + def test_age_rating_organization_shape(self, blank): + meta, result = blank + igdb = { + 'age_ratings': [ + {'organization': {'name': 'ESRB'}, 'rating_category': {'rating': 'E10'}}, + {'organization': {'name': 'PEGI'}, 'rating_category': {'rating': 'Twelve'}}, + {'organization': {'name': 'CERO'}, 'rating_category': {'rating': 'B'}}, + {'organization': {'name': 'ACB'}, 'rating_category': {'rating': 'R18'}}, + ], + } + apply_igdb_to_metadata(meta, igdb, db_game_id=1, result=result) + assert meta['esrb_rating'] == 'E10+' + assert meta['pegi_rating'] == 'PEGI 12' + assert meta['cero_rating'] == 'B' + assert meta['acb_rating'] == 'R18+' + + def test_players_null_offlinemax_not_raising(self, blank): + meta, result = blank + apply_igdb_to_metadata( + meta, {'multiplayer_modes': [{'offlinemax': None}, {'offlinemax': 2}]}, + db_game_id=1, result=result) + assert meta['players'] == 2 + + def test_players_without_offline_count_left_empty(self, blank): + meta, result = blank + apply_igdb_to_metadata(meta, {'multiplayer_modes': [{'onlinemax': 8}]}, + db_game_id=1, result=result) + assert meta['players'] == '' def test_unknown_rating_category_ignored(self, blank): meta, result = blank @@ -396,6 +441,34 @@ def test_classifications_map_to_rating_fields(self, blank): assert meta['usk_rating'] == '12' assert meta['acb_rating'] == 'M' + def test_parsed_record_shape_applies_text_fields(self, blank): + # A cache miss on the primary path falls back to get_game_info, which + # returns parse_game_data's flat dict (genres as a string). Iterating + # that string raised AttributeError and lost the whole record. + meta, result = blank + parsed = { + 'title': 'Sonic the Hedgehog', 'description': 'Gotta go fast.', + 'release_date': '1991-06-23', 'developer': 'Sonic Team', + 'publisher': 'Sega', 'genres': 'Platform', 'players': '1', + 'media': {}, + } + apply_screenscraper_to_metadata(meta, parsed, db_game_id=1, result=result) + assert meta['title'] == 'Sonic the Hedgehog' + assert meta['description'] == 'Gotta go fast.' + assert meta['release_date'] == '1991-06-23' + assert meta['developer'] == 'Sonic Team' + assert meta['publisher'] == 'Sega' + assert meta['genre'] + assert meta['players'] == '1' + + def test_media_url_extension_ignores_query_string(self): + from scraper.metadata_merger import _url_ext + url = ('https://neoclone.screenscraper.fr/api2/mediaVideoJeu.php' + '?devid=x&devpassword=y&ssid=u&sspassword=p&media=video-normalized') + assert _url_ext(url, 'mp4') == 'mp4' + assert _url_ext('https://cdn.example/x/manual.PDF?x=1', 'pdf') == 'pdf' + assert _url_ext('https://cdn.example/x/cover.png', 'jpg') == 'png' + def test_note_converted_0_20_to_0_100(self, blank): meta, result = blank # ScreenScraper note is 0-20 scale; 17.0 → 85.0 diff --git a/tests/test_scraper_lookup_defaults.py b/tests/test_scraper_lookup_defaults.py new file mode 100644 index 00000000..06e4fda4 --- /dev/null +++ b/tests/test_scraper_lookup_defaults.py @@ -0,0 +1,149 @@ +"""Regression pins: scraper fetches must not invent values that the fill-only +merge then keeps for good (an ID written as a name, a guessed player count).""" + +import pytest + + +@pytest.fixture +def tgdb(monkeypatch): + from scraper import scrape_thegamesdb as mod + mod.clear_caches() + yield mod + mod.clear_caches() + + +def test_tgdb_unresolved_ids_are_not_written_as_names(tgdb, monkeypatch): + monkeypatch.setattr(tgdb, '_tgdb_request', lambda *a, **kw: None) + assert tgdb._resolve_names([6431], tgdb._get_publishers) == '' + assert tgdb._resolve_genre_names([999999]) == [] + # The local genre map still names a known id. + assert tgdb._resolve_genre_names([1]) == ['Action'] + + +def test_tgdb_failed_lookup_is_retried_not_cached_empty(tgdb, monkeypatch): + calls = [] + + class _Resp: + status_code = 200 + + @staticmethod + def json(): + return {'data': {'publishers': {'7': {'id': 7, 'name': 'Sega'}}}} + + def _request(*a, **kw): + calls.append(1) + return None if len(calls) == 1 else _Resp() + + monkeypatch.setattr(tgdb, '_tgdb_request', _request) + assert tgdb._get_publishers() == {} + # Within the retry window: no second request. + assert tgdb._get_publishers() == {} + assert len(calls) == 1 + tgdb._lookup_failed_at['publishers'] = 0 + assert tgdb._resolve_names([7], tgdb._get_publishers) == 'Sega' + # Cached once fetched. + tgdb._get_publishers() + assert len(calls) == 2 + + +def test_rawg_players_unknown_without_a_player_tag(monkeypatch): + from scraper import scrape_rawg + + def _req(endpoint, params=None, max_retries=2): + if endpoint == 'games/1': + return {'id': 1, 'name': 'Myst', 'tags': [{'name': 'Puzzle'}]} + return None + + monkeypatch.setattr(scrape_rawg, '_make_request', _req) + monkeypatch.setattr(scrape_rawg, 'rate_limit', lambda *a: None) + assert scrape_rawg.get_game_details(1)['players'] is None + + +@pytest.fixture +def esde_db(tmp_path, monkeypatch): + import sqlite3 + from services import migrations + from scraper import scrape_esde + path = str(tmp_path / 'roms.db') + conn = sqlite3.connect(path) + migrations.apply_pending(conn) + conn.execute("INSERT INTO systems (id, name, folder) VALUES (1, 'NES', 'nes')") + conn.execute("INSERT INTO games (id, system_id, title, rom_path, players, modes) " + "VALUES (1, 1, 'Contra', '/roms/nes/Contra (USA).nes', 2, " + "'Single-Player, Local Multiplayer')") + conn.commit() + conn.close() + + def connect(): + c = sqlite3.connect(path) + c.row_factory = sqlite3.Row + return c + + monkeypatch.setenv('HOME', str(tmp_path)) + monkeypatch.setattr(scrape_esde, 'get_scraper_conn', connect) + monkeypatch.setattr(scrape_esde, '_get_esde_gamelists_path', lambda: None) + monkeypatch.setattr(scrape_esde, '_get_esde_media_path', lambda: None) + return connect + + +def test_esde_row_without_players_keeps_curated_players_and_modes(esde_db): + from scraper import scrape_esde + assert scrape_esde.apply_esde_metadata(1, {'name': 'Contra'}) is True + c = esde_db() + row = c.execute("SELECT players, modes FROM games WHERE id = 1").fetchone() + c.close() + assert row['players'] == 2 + assert row['modes'] == 'Single-Player, Local Multiplayer' + + +def test_esde_full_path_match_beats_earlier_filename_match(monkeypatch): + from scraper import scrape_esde + games = [ + {'path': './Game A/disc1.chd', 'name': 'Game A'}, + {'path': './Game B/disc1.chd', 'name': 'Game B'}, + ] + monkeypatch.setattr(scrape_esde, 'find_gamelist_xml', lambda folder: '/x/gamelist.xml') + monkeypatch.setattr(scrape_esde, 'parse_gamelist_xml', lambda p: list(games)) + got = scrape_esde.fetch_esde_game_details('./Game B/disc1.chd', 'psx') + assert got['name'] == 'Game B' + # A bare filename still finds the first holder of that name. + assert scrape_esde.fetch_esde_game_details('disc1.chd', 'psx')['name'] == 'Game A' + assert got['players'] == '' + + + +def test_scan_finds_svg_only_system_logo_and_keeps_existing(tmp_path, monkeypatch): + import sqlite3 + from scraper import scan_roms as scan_mod + rom_root = tmp_path / 'roms' + (rom_root / 'genesis').mkdir(parents=True) + (rom_root / 'genesis' / 'systeminfo.txt').write_text( + 'Supported file extensions:\n.md\n', encoding='utf-8') + (rom_root / 'genesis' / 'Sonic (USA).md').write_bytes(b'x') + (rom_root / 'nes').mkdir() + (rom_root / 'nes' / 'systeminfo.txt').write_text( + 'Supported file extensions:\n.nes\n', encoding='utf-8') + static = tmp_path / 'static' + (static / 'images' / 'systems').mkdir(parents=True) + (static / 'images' / 'systems' / 'genesis.svg').write_text('') + + db = str(tmp_path / 'scan.db') + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE systems (id INTEGER PRIMARY KEY, name TEXT, " + "folder TEXT UNIQUE, logo TEXT)") + conn.execute("CREATE TABLE games (id INTEGER PRIMARY KEY, system_id INTEGER, " + "title TEXT, rom_path TEXT, scraped INTEGER, created_at TEXT)") + conn.execute("INSERT INTO systems (name, folder, logo) VALUES ('NES', 'nes', 'custom.webp')") + conn.commit() + conn.close() + + monkeypatch.setattr(scan_mod.settings_manager, 'get_effective_path', + lambda key, fallback='': str(rom_root) if key == 'rom_path' else fallback) + monkeypatch.setattr(scan_mod.config, 'STATIC_PATH', str(static)) + monkeypatch.setattr(scan_mod, 'get_scraper_conn', lambda: sqlite3.connect(db)) + assert scan_mod.scan_roms() == 1 + + conn = sqlite3.connect(db) + logos = dict(conn.execute("SELECT folder, logo FROM systems").fetchall()) + conn.close() + assert logos == {'genesis': 'genesis.svg', 'nes': 'custom.webp'} From 41415e212981ef9a7c007b0d807d61e06d76973d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:44:50 +0000 Subject: [PATCH 6/8] fix: card grid column detection, ScreenScraper password masking - all-games-controller: the resize handler called debounce() at load, before the deferred core bundle defines it, so the Library and System pages threw and rendered no cards. Use a local timer. - The column count read from computed grid-template-columns includes the implicit tracks the cards' own inline placement created, so a grid narrowed to one column (phone) kept laying out two columns with a huge gap. A full layout clears inline placement first; a ResizeObserver on the grid re-lays out once its width settles (a window-resize timer fired mid sidebar animation and read the wrong count). - screenscraper_password / screenscraper_devpassword join SECRET_API_KEY_FIELDS: masked on GET /api/scraper-settings and the settings page, kept on a masked echo. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013s5ztLFVCcdTjjTeVcTnJr --- routes/scraper.py | 2 ++ static/js/all-games-controller.js | 42 +++++++++++++++++++++++-------- tests/test_review_routes_core.py | 15 +++++++++++ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/routes/scraper.py b/routes/scraper.py index 642b9bad..3b0bb512 100644 --- a/routes/scraper.py +++ b/routes/scraper.py @@ -72,6 +72,8 @@ 'ai_claude_api_key', 'steam_api_key', 'xbox_client_secret', + 'screenscraper_password', + 'screenscraper_devpassword', }) MASKED_SENTINEL_PREFIX = '***' diff --git a/static/js/all-games-controller.js b/static/js/all-games-controller.js index 008f3007..21bfa612 100644 --- a/static/js/all-games-controller.js +++ b/static/js/all-games-controller.js @@ -464,8 +464,18 @@ const AllGamesController = (function() { function layoutMasonryItems(items, append) { const grid = document.getElementById('gamesGrid'); if (!grid || items.length === 0) return; + if (_gridResizeObserver) _gridResizeObserver.observe(grid); const gap = 16; + // The computed track list includes implicit columns that the cards' + // own inline placement created (overlapping row lines spill into + // extra columns too), so a full layout clears it first; otherwise a + // grid narrowed to one column still reads as two. + if (!append) { + items.forEach(w => { + w.style.gridColumn = w.style.gridRowStart = w.style.gridRowEnd = ''; + }); + } const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').length; // Reset on fresh render or column count change @@ -512,15 +522,25 @@ const AllGamesController = (function() { }); } - // A resize that changes the column count re-places every card; the old - // inline columns would otherwise point past the grid's last column. - const _onResizeRelayout = debounce(() => { - const grid = document.getElementById('gamesGrid'); - if (!grid || !grid.firstElementChild) return; - const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').length; - if (columns !== masonryColumnEnds.length) window.relayoutMasonry(); - }, 200); - window.addEventListener('resize', _onResizeRelayout, { passive: true }); + // A grid width change re-places every card: the old inline columns + // would otherwise point past the grid's last column. The column count + // can't be compared first (the implicit tracks above keep the old + // count), so any width change re-lays out. The grid itself is observed, + // not the window: the sidebar animates across a breakpoint, and a + // layout taken mid-animation reads the wrong column count; the observer + // fires every frame, so the timer settles only once the width does. + let _resizeRelayoutTimer; + let _observedGridWidth = null; + const _gridResizeObserver = typeof ResizeObserver === 'undefined' ? null + : new ResizeObserver(entries => { + const width = Math.round(entries[0].contentRect.width); + const first = _observedGridWidth === null; + if (width === _observedGridWidth) return; + _observedGridWidth = width; + if (first) return; + clearTimeout(_resizeRelayoutTimer); + _resizeRelayoutTimer = setTimeout(() => window.relayoutMasonry(), 150); + }); // Expose a full re-layout function for external use (e.g. after card edits) window.relayoutMasonry = function() { @@ -1631,7 +1651,9 @@ const AllGamesController = (function() { clearTimeout(scrollSaveTimer); clearTimeout(_imageRelayoutTimer); window.removeEventListener('scroll', _onScrollSave); - window.removeEventListener('resize', _onResizeRelayout); + clearTimeout(_resizeRelayoutTimer); + if (_gridResizeObserver) _gridResizeObserver.disconnect(); + _observedGridWidth = null; document.removeEventListener('click', _onFilterBackdropClick); selectedGames.clear(); } diff --git a/tests/test_review_routes_core.py b/tests/test_review_routes_core.py index a4326315..a495378f 100644 --- a/tests/test_review_routes_core.py +++ b/tests/test_review_routes_core.py @@ -180,3 +180,18 @@ def test_psn_games_api_clamps_paging(admin_test_client): assert resp.status_code == 200 body = resp.get_json() assert body['per_page'] == 1 and body['page'] == 1 + + +def test_screenscraper_passwords_are_masked(): + """The ScreenScraper account and developer passwords are secrets: masked + on GET /api/scraper-settings and the settings page, kept on a masked echo.""" + from routes.scraper import SECRET_API_KEY_FIELDS, mask_api_keys_for_response + assert {'screenscraper_password', 'screenscraper_devpassword'} <= SECRET_API_KEY_FIELDS + masked = mask_api_keys_for_response({ + 'screenscraper_username': 'player1', + 'screenscraper_password': 'hunter2secret', + 'screenscraper_devpassword': 'devpass9876', + }) + assert masked['screenscraper_username'] == 'player1' + assert masked['screenscraper_password'] == '***cret' + assert masked['screenscraper_devpassword'] == '***9876' From d7d135171655cc26b6f6d2371b8cbd8fc1c8f113 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:44:50 +0000 Subject: [PATCH 7/8] =?UTF-8?q?docs(changelog):=203.33.0=20=E2=80=94=20rev?= =?UTF-8?q?iew=20sweep=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013s5ztLFVCcdTjjTeVcTnJr --- config.example.py | 2 +- data/changelog.yaml | 55 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/config.example.py b/config.example.py index d191b709..a2c0d315 100644 --- a/config.example.py +++ b/config.example.py @@ -130,7 +130,7 @@ # Application metadata APP_NAME = "RetroDB" APP_VERSION = "3.33.0" -APP_LAST_UPDATE = "2026-10-23" +APP_LAST_UPDATE = "2026-10-27" APP_DESCRIPTION = "Retro Gaming ROM Library Manager" # Supported image extensions for boxart/screenshots diff --git a/data/changelog.yaml b/data/changelog.yaml index 039f79b1..38e0cd82 100644 --- a/data/changelog.yaml +++ b/data/changelog.yaml @@ -1,10 +1,26 @@ - version: 3.33.0 - date: 2026-10-23 + date: 2026-10-27 tags: - type: feature label: Get RetroArch from inside RetroDB - type: fix label: Next disk works for games played in RetroDB + - type: fix + label: Game cards line up on a phone and after resizing the window + - type: fix + label: Scraped ratings and player counts are more accurate + - type: fix + label: ScreenScraper keeps its details when it is the main source + - type: fix + label: Clear scraped data puts the file name back as the title + - type: fix + label: RetroAchievements refresh for one console works again + - type: fix + label: Screenshot duplicate finder works in the downloaded app + - type: fix + label: ScreenScraper passwords are hidden, and the last admin stays admin + - type: performance + label: Faster trophies, library imports and maintenance tools body: |
          • Get RetroArch from inside RetroDB. Settings > @@ -20,6 +36,43 @@ own RetroArch with no setup. RetroArch listens for RetroDB's commands only while such a game runs, and on Windows and macOS while any game runs.
          • +
          • Game cards line up on a phone and after resizing the + window. On a phone the Library and console pages showed one + card and then a long empty gap; cards now stack one under another. + Making the window narrower or wider re-arranges the cards instead of + leaving them in the old columns.
          • +
          • Scraped ratings and player counts are more accurate. + Age ratings from IGDB for CERO, USK, GRAC, ClassInd and ACB are now + read correctly, so a game rated CERO Z or USK 18 gets that rating. + TheGamesDB, RAWG, IGDB and ES-DE no longer guess "1 player" when + they don't know; an ES-DE import no longer overwrites a player count + or game modes you set. TheGamesDB no longer saves a number in place + of a publisher or developer name after a failed lookup.
          • +
          • ScreenScraper keeps its details when it is the main + source. Its title, description, date, developer, publisher + and players are kept on every scrape, not only the first few + minutes after a search. Downloaded videos and manuals get a proper + file name.
          • +
          • Clear scraped data puts the file name back as the + title. It cleared the details but left the scraped + title.
          • +
          • RetroAchievements refresh for one console works + again. It failed with an error every time.
          • +
          • Screenshot duplicate finder works in the downloaded + app. It found no screenshots there, and deleting a + duplicate left the file on disk.
          • +
          • ScreenScraper passwords are hidden, and the last admin + stays admin. Settings now shows your ScreenScraper + passwords masked, like your other keys. The only admin account can + no longer be switched to editor or turned off, which would have + left nobody able to manage RetroDB.
          • +
          • Faster trophies, library imports and maintenance + tools. The Trophies page, Steam, Xbox and PlayStation + library imports, collector trophies, Calculate storage and the + unused-media scan all do far less work on a large library. Job + progress pop-ups keep updating when the server is busy, and the + HowLongToBeat and alternate-title jobs no longer hold up other + changes while they run.
          - version: 3.32.0 date: 2026-10-23 From 477083109b024a24e3fefc3a95d3c3dcd92073a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:33:36 +0000 Subject: [PATCH 8/8] fix(jobs): dismiss and the recovery banner follow the resume rights - /api/jobs/dismiss checks the same role / ownership as resume (_may_manage_job, shared); any signed-in user could dismiss any job - The dashboard recovery banner leaves out jobs the user may neither resume nor dismiss (a player no longer sees an interrupted bulk scrape) - Demoting or deactivating the last admin gets its own message; catalogs regenerated - CLAUDE.md: a release needs at least 5 changelog items, no upper limit (user, 2026-10-27) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013s5ztLFVCcdTjjTeVcTnJr --- CLAUDE.md | 2 +- app.py | 35 ++- data/changelog.yaml | 7 + messages.pot | 258 ++++++++++--------- routes/auth.py | 2 +- tests/test_review_routes_core.py | 45 ++++ translations/af/LC_MESSAGES/messages.mo | Bin 153589 -> 153589 bytes translations/af/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/de/LC_MESSAGES/messages.mo | Bin 160488 -> 160488 bytes translations/de/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/eo/LC_MESSAGES/messages.mo | Bin 212664 -> 212802 bytes translations/eo/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/es/LC_MESSAGES/messages.mo | Bin 160236 -> 160236 bytes translations/es/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/fr/LC_MESSAGES/messages.mo | Bin 163783 -> 163783 bytes translations/fr/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/he/LC_MESSAGES/messages.mo | Bin 171901 -> 171901 bytes translations/he/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/id/LC_MESSAGES/messages.mo | Bin 152562 -> 152562 bytes translations/id/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/it/LC_MESSAGES/messages.mo | Bin 158895 -> 158895 bytes translations/it/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/ja/LC_MESSAGES/messages.mo | Bin 176972 -> 176972 bytes translations/ja/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/ko/LC_MESSAGES/messages.mo | Bin 163863 -> 163863 bytes translations/ko/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/nb/LC_MESSAGES/messages.mo | Bin 151267 -> 151267 bytes translations/nb/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/nl/LC_MESSAGES/messages.mo | Bin 155341 -> 155341 bytes translations/nl/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/pl/LC_MESSAGES/messages.mo | Bin 158258 -> 158258 bytes translations/pl/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/pt_BR/LC_MESSAGES/messages.mo | Bin 159055 -> 159055 bytes translations/pt_BR/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/ru/LC_MESSAGES/messages.mo | Bin 201957 -> 201957 bytes translations/ru/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/tr/LC_MESSAGES/messages.mo | Bin 158685 -> 158685 bytes translations/tr/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/uk/LC_MESSAGES/messages.mo | Bin 200032 -> 200032 bytes translations/uk/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/xh/LC_MESSAGES/messages.mo | Bin 159404 -> 159404 bytes translations/xh/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/zh_Hans/LC_MESSAGES/messages.mo | Bin 145470 -> 145470 bytes translations/zh_Hans/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/zh_Hant/LC_MESSAGES/messages.mo | Bin 146082 -> 146082 bytes translations/zh_Hant/LC_MESSAGES/messages.po | 258 ++++++++++--------- translations/zu/LC_MESSAGES/messages.mo | Bin 160723 -> 160723 bytes translations/zu/LC_MESSAGES/messages.po | 258 ++++++++++--------- 48 files changed, 2964 insertions(+), 2803 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 62870a07..96aefa54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ genuinely diverges. ## Mandatory Workflow ### After Every Code Change -1. **Bundle changes into releases** (user, 2026-10-17): a release with one changelog item is too small. A release carries 5 to 10 changelog items (user, 2026-10-23): release once the open entry reaches 10, or sooner at a natural stop once it has 5. While the version at the top of `data/changelog.yaml` has no `v` git tag, it is still open: add the change to that entry and keep the version. Otherwise bump `config.py` AND `config.example.py` (`APP_VERSION` + `APP_LAST_UPDATE`) and open a new entry. Keep `APP_LAST_UPDATE` current either way. +1. **Bundle changes into releases** (user, 2026-10-17): a release with one changelog item is too small. A release carries at least 5 changelog items, with no upper limit (user, 2026-10-27: the floor only stops a release going out with one item; an open entry may grow past 10). While the version at the top of `data/changelog.yaml` has no `v` git tag, it is still open: add the change to that entry and keep the version. Otherwise bump `config.py` AND `config.example.py` (`APP_VERSION` + `APP_LAST_UPDATE`) and open a new entry. Keep `APP_LAST_UPDATE` current either way. 2. Add or extend the changelog entry at top of `data/changelog.yaml`. For a real release, also translate the **recent** entry (current line onward; deep history stays English) into each per-locale `data/changelog..yaml` (the human-translation locales in `docs/specs/i18n.md` §9 — a different delivery path from the installed UI-catalog set `services/i18n.py::available_locales()` derives, §3). Repeat `version`, `date`, and every tag verbatim — the `/changelog` route swaps the whole entry in by version, so anything omitted is dropped, not inherited (Pass 43.6). Skip for internal/no-release bumps. 3. Rebuild CSS if any `static/css/**.css` changed: `python3 build_css.py` 4. Rebuild JS if any bundled `static/js/*.js` changed: `python3 build_js.py` diff --git a/app.py b/app.py index e6bd87a8..d656feab 100644 --- a/app.py +++ b/app.py @@ -1343,6 +1343,11 @@ def dashboard(): jtype = rj['job_type'] status = rj['status'] + # A job the user may neither resume nor dismiss is not offered: + # the banner would show buttons that only answer 403. + if not _may_manage_job(jtype, params): + continue + # Determine if this job had real progress (interrupted mid-run) # vs was just queued (no work done yet) has_progress = progress.get('current', 0) > 0 @@ -1415,6 +1420,16 @@ def dashboard(): } +def _may_manage_job(job_type, params): + """PASS-59-118 — resuming or dismissing a recoverable job needs the + rights starting it needed: the start route's role, and a per-user job + belongs to its user (admins excepted).""" + role = g.user['role'] + owner = (params or {}).get('user_id') + return (role in _RESUME_ROLES.get(job_type, ('admin',)) + and (owner is None or owner == g.user['id'] or role == 'admin')) + + @app.route('/api/jobs/resume/', methods=['POST']) @login_required @handle_api_errors @@ -1456,12 +1471,7 @@ def resume_job(job_id): if not handler or not hasattr(handler, 'resume_from_params'): return api_error(f"No handler for job type '{job['job_type']}'", 400) - # PASS-59-118 — resuming needs the rights starting needed: the start - # route's role, and a per-user job belongs to its user (admins excepted). - role = g.user['role'] - need = _RESUME_ROLES.get(job['job_type'], ('admin',)) - owner = (job.get('params') or {}).get('user_id') - if role not in need or (owner is not None and owner != g.user['id'] and role != 'admin'): + if not _may_manage_job(job['job_type'], job.get('params')): return api_error('You do not have permission to perform this action', 403) resumed = handler.resume_from_params(job['params'], progress=job.get('progress')) @@ -1477,8 +1487,19 @@ def resume_job(job_id): @login_required @handle_api_errors def dismiss_job_route(job_id): - """Dismiss a recoverable job so it no longer appears on the dashboard.""" + """Dismiss a recoverable job so it no longer appears on the dashboard. + + The same rights as resuming it: dismissing throws the job away. + """ from services.jobs.base import dismiss_job + row = query("SELECT job_type, params FROM job_queue WHERE id = ?", (job_id,), one=True) + if row: + try: + params = json.loads(row['params']) if row['params'] else {} + except (TypeError, ValueError): + params = {} + if not _may_manage_job(row['job_type'], params if isinstance(params, dict) else {}): + return api_error('You do not have permission to perform this action', 403) dismiss_job(job_id) return api_success() diff --git a/data/changelog.yaml b/data/changelog.yaml index 38e0cd82..208d96ab 100644 --- a/data/changelog.yaml +++ b/data/changelog.yaml @@ -19,6 +19,8 @@ label: Screenshot duplicate finder works in the downloaded app - type: fix label: ScreenScraper passwords are hidden, and the last admin stays admin + - type: fix + label: Interrupted jobs are only offered to people allowed to run them - type: performance label: Faster trophies, library imports and maintenance tools body: | @@ -66,6 +68,11 @@ passwords masked, like your other keys. The only admin account can no longer be switched to editor or turned off, which would have left nobody able to manage RetroDB. +
        • Interrupted jobs are only offered to people allowed to run + them. The dashboard's interrupted-job banner now shows a + job only to someone allowed to start it, for example a bulk scrape + only to editors and admins. Other accounts no longer see it and can + no longer dismiss it.
        • Faster trophies, library imports and maintenance tools. The Trophies page, Steam, Xbox and PlayStation library imports, collector trophies, Calculate storage and the diff --git a/messages.pot b/messages.pot index 0593f8a9..220d2bc5 100644 --- a/messages.pot +++ b/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -37,8 +37,8 @@ msgstr "" msgid "Password change required" msgstr "" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "" @@ -55,9 +55,9 @@ msgstr "" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "" @@ -82,8 +82,8 @@ msgstr "" msgid "Could not fetch achievement data" msgstr "" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "" @@ -97,8 +97,8 @@ msgstr "" msgid "Sync already in progress for %(name)s" msgstr "" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "" @@ -114,7 +114,7 @@ msgstr "" msgid "No user selected" msgstr "" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "" @@ -146,70 +146,74 @@ msgstr "" msgid "Username already exists" msgstr "" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "" -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." msgstr "" -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "" @@ -226,17 +230,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "" @@ -245,7 +249,7 @@ msgstr "" msgid "Missing bonus_id or parent_id" msgstr "" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "" @@ -366,22 +370,22 @@ msgstr "" msgid "Wishlist item not found" msgstr "" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "" @@ -462,9 +466,9 @@ msgstr "" msgid "No fields to update" msgstr "" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "" @@ -492,11 +496,11 @@ msgstr "" msgid "Invalid filter type" msgstr "" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "" @@ -514,7 +518,7 @@ msgstr "" msgid "ROM file not found on disk: %(path)s" msgstr "" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "" @@ -523,23 +527,23 @@ msgstr "" msgid "Cannot rename: %(reason)s" msgstr "" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "" @@ -614,11 +618,11 @@ msgstr "" msgid "Next disk inserted" msgstr "" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "" @@ -697,52 +701,52 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "" -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " "Steam ID instead." msgstr "" -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." msgstr "" -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "" -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "" -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "" -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" @@ -751,11 +755,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" @@ -763,8 +767,8 @@ msgstr "" msgid "Missing required parameters" msgstr "" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "" @@ -777,31 +781,31 @@ msgstr "" msgid "Missing game_id or new_name" msgstr "" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "" @@ -810,7 +814,7 @@ msgstr "" msgid "Invalid log file" msgstr "" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "" @@ -819,56 +823,56 @@ msgstr "" msgid "Log file not found" msgstr "" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "" @@ -899,7 +903,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "" @@ -907,12 +911,12 @@ msgstr "" msgid "Request body must be a JSON object" msgstr "" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "" @@ -950,146 +954,146 @@ msgstr "" msgid "ES-DE scraper not available" msgstr "" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "" -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "" -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "" -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" msgstr "" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "" diff --git a/routes/auth.py b/routes/auth.py index df61d8ba..e5018401 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -236,7 +236,7 @@ def api_update_user(user_id): admin_count = query("SELECT COUNT(*) as count FROM users " "WHERE role = 'admin' AND is_active = 1", one=True) if admin_count['count'] <= 1: - return error(_('Cannot delete the last admin user'), code=403) + return error(_('The last admin must stay an active admin'), code=403) updates = [] params = [] diff --git a/tests/test_review_routes_core.py b/tests/test_review_routes_core.py index a495378f..b8bd258c 100644 --- a/tests/test_review_routes_core.py +++ b/tests/test_review_routes_core.py @@ -32,6 +32,7 @@ def test_last_admin_cannot_be_demoted_or_deactivated(admin_test_client, monkeypa resp = client.post('/api/users/7/update', json=body, headers={'X-CSRF-Token': csrf}) assert resp.status_code == 403 assert resp.get_json()['success'] is False + assert resp.get_json()['error'] == 'The last admin must stay an active admin' assert writes == [] @@ -195,3 +196,47 @@ def test_screenscraper_passwords_are_masked(): assert masked['screenscraper_username'] == 'player1' assert masked['screenscraper_password'] == '***cret' assert masked['screenscraper_devpassword'] == '***9876' + + +@pytest.mark.parametrize('job_type,params,role,allowed', [ + ('bulk_scrape', {'system_id': 3}, 'player', False), # editor job + ('bulk_scrape', {'system_id': 3}, 'editor', True), + ('ra_refresh', {'system_id': None}, 'editor', False), # admin-only job + ('psn_refresh', {'user_id': 1}, 'player', False), # someone else's job + ('psn_refresh', {'user_id': 2}, 'player', True), # the caller's own +]) +def test_dismiss_job_needs_the_resume_rights(admin_test_client, monkeypatch, + job_type, params, role, allowed): + """Dismissing a recoverable job throws it away, so it takes the rights + resuming it does (it was open to any signed-in user).""" + import json as _json + import app as app_module + import services.jobs.base as base + real_query = app_module.query + + def fake_query(sql, args=(), one=False): + if 'FROM job_queue' in sql: + return {'job_type': job_type, 'params': _json.dumps(params)} + return real_query(sql, args, one=one) + + monkeypatch.setattr(app_module, 'query', fake_query) + dismissed = [] + monkeypatch.setattr(base, 'dismiss_job', dismissed.append) + client, csrf = admin_test_client(user={'id': 2, 'username': 'u-stub', 'role': role}) + resp = client.post('/api/jobs/dismiss/4242', headers={'X-CSRF-Token': csrf}) + assert (resp.status_code, dismissed) == ((200, [4242]) if allowed else (403, [])) + + +@pytest.mark.parametrize('role,shown', [('player', False), ('editor', True)]) +def test_recovery_banner_hides_jobs_the_user_cannot_manage(admin_test_client, monkeypatch, + role, shown): + """An interrupted bulk scrape is an editor job: a player is not shown it.""" + import services.jobs.base as base + monkeypatch.setattr(base, 'get_recoverable_jobs', lambda: [{ + 'id': 4243, 'job_type': 'bulk_scrape', 'status': 'interrupted', + 'params': {'system_id': 3, 'system_name': 'SNES', 'game_count': 9}, + 'progress': {'current': 4, 'total': 9}}]) + client, _csrf = admin_test_client(user={'id': 2, 'username': 'u-stub', 'role': role}) + resp = client.get('/dashboard') + assert resp.status_code == 200 + assert ('recoveryItem4243' in resp.get_data(as_text=True)) is shown diff --git a/translations/af/LC_MESSAGES/messages.mo b/translations/af/LC_MESSAGES/messages.mo index 2ceda5bf1ba90477592f6e4a4be69d525df846f6..9a5cb66e74be7d78216a80cefad7aa4eff68e458 100644 GIT binary patch delta 28 kcmeymp7ZN^&W0_F+vB**6%386j19F747MMPV>I~*0H(JJmH+?% delta 28 kcmeymp7ZN^&W0_F+vB*56%0+S3=FgljJ6+)V>I~*0H%QolK=n! diff --git a/translations/af/LC_MESSAGES/messages.po b/translations/af/LC_MESSAGES/messages.po index fd7b066a..9522ac90 100644 --- a/translations/af/LC_MESSAGES/messages.po +++ b/translations/af/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-05 16:56+0200\n" "Last-Translator: FULL NAME \n" "Language: af\n" @@ -42,8 +42,8 @@ msgstr "Opstelling vereis" msgid "Password change required" msgstr "Wagwoordverandering vereis" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Wagwoord moet ten minste 12 karakters wees" @@ -60,9 +60,9 @@ msgstr "Stelsel nie gevind nie of het geen speletjies met prestasies nie" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Speletjie nie gevind nie" @@ -87,8 +87,8 @@ msgstr "RetroAchievements is nie opgestel nie" msgid "Could not fetch achievement data" msgstr "Kon nie prestasiedata verkry nie" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Stelsel nie gevind nie" @@ -102,8 +102,8 @@ msgstr "Geen speletjies met RetroAchievements in hierdie stelsel nie" msgid "Sync already in progress for %(name)s" msgstr "Sinkronisasie is reeds aan die gang vir %(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Daardie taak behoort aan 'n ander gebruiker" @@ -119,7 +119,7 @@ msgstr "Te veel aanmeldpogings. Probeer asseblief later weer." msgid "No user selected" msgstr "Geen gebruiker gekies nie" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Gebruiker nie gevind nie" @@ -153,64 +153,68 @@ msgstr "Ongeldige rol" msgid "Username already exists" msgstr "Gebruikersnaam bestaan reeds" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Kan nie jou eie rekening verwyder nie" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Kan nie die laaste administrateur-gebruiker verwyder nie" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Nie aangemeld nie" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Ongeldige taalinstelling" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Ongeldige tema" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Ongeldige RPCS3-trofeëgids: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Te veel pogings. Probeer asseblief later weer." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Nuwe wagwoord is vereis" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Huidige wagwoord is verkeerd" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Wagwoordverandering nie vereis nie" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Geen lêer verskaf nie" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Geen lêer gekies nie" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Ongeldige lêertipe. Toegelaat: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Lêer te groot. Maksimum grootte is 2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -218,7 +222,7 @@ msgstr "" "Avatar-oplaaie vereis Pillow; vra asseblief die operateur om die projek " "se vereistes te installeer." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Ongeldige beeldlêer" @@ -235,17 +239,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Bonusskyf en ouerspeletjie moet in dieselfde stelsel wees" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "'n Interne fout het voorgekom" @@ -254,7 +258,7 @@ msgstr "'n Interne fout het voorgekom" msgid "Missing bonus_id or parent_id" msgstr "bonus_id of parent_id ontbreek" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "game_id ontbreek" @@ -377,22 +381,22 @@ msgstr "Hierdie speletjie is reeds op jou verlanglys" msgid "Wishlist item not found" msgstr "Verlanglys-item nie gevind nie" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Beheerdernaam is vereis" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Beheerder \"%(name)s\" bestaan reeds" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Beheerder nie gevind nie" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "'n Ander beheerder met die naam \"%(name)s\" bestaan reeds" @@ -479,9 +483,9 @@ msgstr "Kon nie metadata toepas nie." msgid "No fields to update" msgstr "Geen velde om by te werk nie" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Geen speletjies gekies nie" @@ -509,13 +513,13 @@ msgstr "Filtertipe en -waarde vereis" msgid "Invalid filter type" msgstr "Ongeldige filtertipe" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "KI het geen data teruggestuur nie. Kontroleer jou API-sleutel en " "verskafferinstellings." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "KI-skrapermodule nie beskikbaar nie" @@ -533,7 +537,7 @@ msgstr "Ongeldige lêernaam" msgid "ROM file not found on disk: %(path)s" msgstr "ROM-lêer nie op skyf gevind nie: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "Bestemming is buite die opgestelde ROM-wortelgids" @@ -542,23 +546,23 @@ msgstr "Bestemming is buite die opgestelde ROM-wortelgids" msgid "Cannot rename: %(reason)s" msgstr "Kan nie hernoem nie: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "'n Lêer met daardie naam bestaan reeds" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Kon nie lêer hernoem nie" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Geen skermskoot gespesifiseer nie" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Ongeldige skermskoot-lêernaam" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Skermskoot nie gevind nie" @@ -637,11 +641,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Volgende skyf ingesit" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Onbekende beeldtipe" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Geen KI-verskaffer opgestel nie" @@ -720,17 +724,17 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" "Steam API-sleutel nie opgestel nie. Voeg dit by in Instellings → API-" "sleutels." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "Steam-ID nie opgestel nie. Voeg dit by in Instellings → API-sleutels." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -739,7 +743,7 @@ msgstr "" "Kon nie Steam-vanity-URL \"%(steam_id)s\" oplos nie. Gebruik eerder jou " "17-syfer Steam-ID." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -747,35 +751,35 @@ msgstr "" "Geen speletjies gevind nie. Kontroleer dat jou Steam-profiel en " "speletjiebesonderhede op Publiek gestel is." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Kon nie Windows-stelselinskrywing skep nie" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" "Xbox Kliënt-ID nie opgestel nie. Voeg dit by in Instellings → API-" "sleutels." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Xbox-geloofsbriewe nie opgestel nie. Voeg dit by in Instellings → API-" "sleutels." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Xbox-verifikasie het misluk. Herkoppel asseblief jou Xbox-rekening." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Geen speletjies in Xbox-titelgeskiedenis gevind nie." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "PSN NPSSO nie opgestel nie. Voeg dit by in Instellings → API-sleutels." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "Kon nie PSN-speletjielys verkry nie. Probeer eers trofeë sinkroniseer." @@ -784,11 +788,11 @@ msgstr "Kon nie PSN-speletjielys verkry nie. Probeer eers trofeë sinkroniseer." msgid "System %(name)s does not support RetroAchievements" msgstr "Stelsel %(name)s ondersteun nie RetroAchievements nie" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Onbekende stelsel" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "ROM-pad nie opgestel nie. Gaan na Instellings → Paaie om dit te stel." @@ -796,8 +800,8 @@ msgstr "ROM-pad nie opgestel nie. Gaan na Instellings → Paaie om dit te stel." msgid "Missing required parameters" msgstr "Vereiste parameters ontbreek" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Ongeldige pad" @@ -810,31 +814,31 @@ msgstr "Vouer bestaan nie: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "game_id of new_name ontbreek" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Geen ROM-pad gestel nie" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "ROM-lêer nie gevind nie" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "M3U-lêers moet die .m3u-uitbreiding behou" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "'n Vouer met die nuwe naam bestaan reeds" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Geen geskraapte titel beskikbaar nie" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Titel is leeg na opskoning" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Gegenereerde lêernaam bevat ongeldige karakters" @@ -843,7 +847,7 @@ msgstr "Gegenereerde lêernaam bevat ongeldige karakters" msgid "Invalid log file" msgstr "Ongeldige loglêer" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Lêer nie gevind nie" @@ -852,56 +856,56 @@ msgstr "Lêer nie gevind nie" msgid "Log file not found" msgstr "Loglêer nie gevind nie" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "ongeldige skraper-instellings: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "ongeldige API-sleutels: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Geen TGDB API-sleutel opgestel nie" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Geen ScreenScraper-geloofsbriewe opgestel nie" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Ongeldige JSON-antwoord van ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Fout tydens ontleding van API-antwoord" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper het HTTP %(code)s geantwoord" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Geen RAWG API-sleutel opgestel nie" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Onbekende KI-verskaffer: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Geen antwoord van KI-verskaffer nie" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Verbindingsfout: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Toelaagkontrole word nie vir %(scraper)s ondersteun nie" @@ -932,7 +936,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "Ongeldige %(field)s: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Kon nie instellings stoor nie" @@ -940,12 +944,12 @@ msgstr "Kon nie instellings stoor nie" msgid "Request body must be a JSON object" msgstr "Versoekliggaam moet 'n JSON-objek wees" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Onbekende instellingsleutel: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Ongeldige waarde vir '%(key)s': %(reason)s" @@ -983,87 +987,87 @@ msgstr "Stelsel-ID vereis" msgid "ES-DE scraper not available" msgstr "ES-DE-skraper nie beskikbaar nie" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Administrateurregte vereis" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Instellingslading moet 'n objek wees" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Pad bestaan nie: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Toestemming geweier" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Taak nie gevind nie" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Taak nie gevind nie of loop nie" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Taak nie gevind nie of is nie gepouseer nie" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Ongeldige skandeerpad" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Geen pad verskaf nie" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Geen argiefpad verskaf nie" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Ongeldige argiefpad" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Geen lêers om te verwyder nie" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Geen paaie verskaf nie" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Ongeldige pad: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman nie gevind nie. Installeer asseblief MAME-gereedskap." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman nie gevind nie" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Onbekende duplikaatmetode: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Hierdie skanderingsresultate het verval. Skandeer weer en verwyder dan." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Hou ten minste een kopie van elke speletjie. Niks is verwyder nie." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1071,60 +1075,60 @@ msgstr "" "Stel asseblief jou RPCS3-trofeepad in jou profielinstellings op om trofeë" " te sien" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Trofeestel nie gevind nie" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH nie opgestel nie" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Trofeepad nie gevind nie: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "PSN-speletjie nie gevind nie" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Sinkronisasie is reeds aan die gang" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Speletjie nie in jou PSN-trofeelys gevind nie" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Speletjie nie op PSN gevind nie" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "npwr_id ontbreek" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "npwr_id of group_id ontbreek" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Naam kan nie leeg wees nie" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "PSNAWP-biblioteek nie geïnstalleer nie. Voer uit: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Geen NPSSO-token verskaf nie" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Kon nie aan PSN koppel nie" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "PSN NPSSO nie opgestel nie" diff --git a/translations/de/LC_MESSAGES/messages.mo b/translations/de/LC_MESSAGES/messages.mo index 93855f0efe0b96c6551673b30b40699578130b42..7b70668860a7dd629491508c25af2932841c8c6f 100644 GIT binary patch delta 32 ocmaFyl=HXH?$D}R-0Ni2=o&W#< delta 32 ocmaFyl=HXH?$D}R-0Nfx8n*aa+ diff --git a/translations/de/LC_MESSAGES/messages.po b/translations/de/LC_MESSAGES/messages.po index 613f6f48..0bb745bc 100644 --- a/translations/de/LC_MESSAGES/messages.po +++ b/translations/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-06-12 20:10+0200\n" "Last-Translator: FULL NAME \n" "Language: de\n" @@ -42,8 +42,8 @@ msgstr "Einrichtung erforderlich" msgid "Password change required" msgstr "Passwortänderung erforderlich" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Das Passwort muss mindestens 12 Zeichen lang sein" @@ -60,9 +60,9 @@ msgstr "System nicht gefunden oder hat keine Spiele mit Achievements" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Spiel nicht gefunden" @@ -89,8 +89,8 @@ msgstr "RetroAchievements nicht konfiguriert" msgid "Could not fetch achievement data" msgstr "Achievement-Daten konnten nicht abgerufen werden" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "System nicht gefunden" @@ -104,8 +104,8 @@ msgstr "Keine Spiele mit RetroAchievements in diesem System" msgid "Sync already in progress for %(name)s" msgstr "Synchronisierung für %(name)s läuft bereits" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Dieser Auftrag gehört einem anderen Benutzer" @@ -121,7 +121,7 @@ msgstr "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut." msgid "No user selected" msgstr "Kein Benutzer ausgewählt" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Benutzer nicht gefunden" @@ -155,64 +155,68 @@ msgstr "Ungültige Rolle" msgid "Username already exists" msgstr "Benutzername existiert bereits" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Sie können Ihr eigenes Konto nicht löschen" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Der letzte Administrator-Benutzer kann nicht gelöscht werden" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Nicht angemeldet" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Ungültige Sprache" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Ungültiges Theme" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Ungültiger RPCS3-Trophäenordner: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Zu viele Versuche. Bitte versuchen Sie es später erneut." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Neues Passwort ist erforderlich" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Das aktuelle Passwort ist falsch" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Passwortänderung nicht erforderlich" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Keine Datei angegeben" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Keine Datei ausgewählt" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Ungültiger Dateityp. Erlaubt: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Datei zu groß. Die maximale Größe beträgt 2 MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -220,7 +224,7 @@ msgstr "" "Avatar-Uploads erfordern Pillow; bitten Sie den Betreiber, die " "Projektabhängigkeiten zu installieren." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Ungültige Bilddatei" @@ -237,17 +241,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Bonus-Disc und Hauptspiel müssen sich im selben System befinden" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Ein interner Fehler ist aufgetreten" @@ -256,7 +260,7 @@ msgstr "Ein interner Fehler ist aufgetreten" msgid "Missing bonus_id or parent_id" msgstr "bonus_id oder parent_id fehlt" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "game_id fehlt" @@ -379,22 +383,22 @@ msgstr "Dieses Spiel steht bereits auf Ihrer Wunschliste" msgid "Wishlist item not found" msgstr "Wunschlisten-Eintrag nicht gefunden" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Controller-Name ist erforderlich" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Controller \"%(name)s\" existiert bereits" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Controller nicht gefunden" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Ein anderer Controller mit dem Namen \"%(name)s\" existiert bereits" @@ -481,9 +485,9 @@ msgstr "Metadaten konnten nicht angewendet werden." msgid "No fields to update" msgstr "Keine Felder zum Aktualisieren" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Keine Spiele ausgewählt" @@ -511,13 +515,13 @@ msgstr "Filtertyp und -wert erforderlich" msgid "Invalid filter type" msgstr "Ungültiger Filtertyp" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "Die AI hat keine Daten zurückgegeben. Überprüfen Sie Ihren API-Schlüssel " "und die Anbietereinstellungen." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "AI-Scraper-Modul nicht verfügbar" @@ -535,7 +539,7 @@ msgstr "Ungültiger Dateiname" msgid "ROM file not found on disk: %(path)s" msgstr "ROM-Datei nicht auf dem Datenträger gefunden: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "Das Ziel liegt außerhalb des konfigurierten ROM-Stammverzeichnisses" @@ -544,23 +548,23 @@ msgstr "Das Ziel liegt außerhalb des konfigurierten ROM-Stammverzeichnisses" msgid "Cannot rename: %(reason)s" msgstr "Umbenennen nicht möglich: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Eine Datei mit diesem Namen existiert bereits" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Datei konnte nicht umbenannt werden" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Kein Screenshot angegeben" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Ungültiger Screenshot-Dateiname" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Screenshot nicht gefunden" @@ -639,11 +643,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Nächste Disc eingelegt" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Unbekannter Bildtyp" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Kein AI-Anbieter konfiguriert" @@ -722,19 +726,19 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" "Steam-API-Schlüssel nicht konfiguriert. Fügen Sie ihn unter Einstellungen" " → API-Schlüssel hinzu." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "" "Steam-ID nicht konfiguriert. Fügen Sie sie unter Einstellungen → API-" "Schlüssel hinzu." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -743,7 +747,7 @@ msgstr "" "Steam-Vanity-URL \"%(steam_id)s\" konnte nicht aufgelöst werden. " "Verwenden Sie stattdessen Ihre 17-stellige Steam-ID." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -751,39 +755,39 @@ msgstr "" "Keine Spiele gefunden. Stellen Sie sicher, dass Ihr Steam-Profil und Ihre" " Spieldetails auf öffentlich gesetzt sind." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Windows-Systemeintrag konnte nicht erstellt werden" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" "Xbox-Client-ID nicht konfiguriert. Fügen Sie sie unter Einstellungen → " "API-Schlüssel hinzu." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Xbox-Anmeldedaten nicht konfiguriert. Fügen Sie sie unter Einstellungen →" " API-Schlüssel hinzu." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "" "Xbox-Authentifizierung fehlgeschlagen. Bitte verbinden Sie Ihr Xbox-Konto" " erneut." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Keine Spiele im Xbox-Titelverlauf gefunden." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "" "PSN-NPSSO nicht konfiguriert. Fügen Sie es unter Einstellungen → API-" "Schlüssel hinzu." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "PSN-Spieleliste konnte nicht abgerufen werden. Versuchen Sie zuerst, die " @@ -794,11 +798,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "System %(name)s unterstützt keine RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Unbekanntes System" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" "ROM-Pfad nicht konfiguriert. Gehen Sie zu Einstellungen → Pfade, um ihn " @@ -808,8 +812,8 @@ msgstr "" msgid "Missing required parameters" msgstr "Erforderliche Parameter fehlen" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Ungültiger Pfad" @@ -822,31 +826,31 @@ msgstr "Ordner existiert nicht: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "game_id oder new_name fehlt" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Kein ROM-Pfad festgelegt" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "ROM-Datei nicht gefunden" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "M3U-Dateien müssen die Endung .m3u behalten" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Ein Ordner mit dem neuen Namen existiert bereits" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Kein gescrapter Titel verfügbar" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Titel ist nach der Bereinigung leer" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Der generierte Dateiname enthält ungültige Zeichen" @@ -855,7 +859,7 @@ msgstr "Der generierte Dateiname enthält ungültige Zeichen" msgid "Invalid log file" msgstr "Ungültige Protokolldatei" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Datei nicht gefunden" @@ -864,56 +868,56 @@ msgstr "Datei nicht gefunden" msgid "Log file not found" msgstr "Protokolldatei nicht gefunden" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "ungültige Scraper-Einstellungen: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "ungültige API-Schlüssel: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Kein TGDB-API-Schlüssel konfiguriert" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Keine ScreenScraper-Anmeldedaten konfiguriert" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Ungültige JSON-Antwort von ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Fehler beim Parsen der API-Antwort" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper antwortete mit HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Kein RAWG-API-Schlüssel konfiguriert" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Unbekannter AI-Anbieter: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Keine Antwort vom AI-Anbieter" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Verbindungsfehler: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Kontingentprüfung für %(scraper)s nicht unterstützt" @@ -948,7 +952,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "Ungültig %(field)s: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Einstellungen konnten nicht gespeichert werden" @@ -956,12 +960,12 @@ msgstr "Einstellungen konnten nicht gespeichert werden" msgid "Request body must be a JSON object" msgstr "Der Anfragetext muss ein JSON-Objekt sein" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Unbekannter Einstellungsschlüssel: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Ungültiger Wert für '%(key)s': %(reason)s" @@ -999,89 +1003,89 @@ msgstr "System-ID erforderlich" msgid "ES-DE scraper not available" msgstr "ES-DE-Scraper nicht verfügbar" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Administratorrechte erforderlich" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Die Einstellungsdaten müssen ein Objekt sein" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Pfad existiert nicht: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Zugriff verweigert" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Aufgabe nicht gefunden" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Aufgabe nicht gefunden oder läuft nicht" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Aufgabe nicht gefunden oder nicht pausiert" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Ungültiger Scan-Pfad" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Kein Pfad angegeben" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Kein Archivpfad angegeben" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Ungültiger Archivpfad" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Keine Dateien zum Entfernen" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Keine Pfade angegeben" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Ungültiger Pfad: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman nicht gefunden. Bitte installieren Sie die MAME-Tools." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman nicht gefunden" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Unbekannte Duplikatmethode: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "" "Diese Scan-Ergebnisse sind abgelaufen. Scannen Sie erneut und löschen Sie" " dann." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Behalten Sie mindestens eine Kopie jedes Spiels. Es wurde nichts gelöscht." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1089,60 +1093,60 @@ msgstr "" "Bitte konfigurieren Sie Ihren RPCS3-Trophäenpfad in Ihren " "Profileinstellungen, um Trophäen anzuzeigen" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Trophäensatz nicht gefunden" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH nicht konfiguriert" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Trophäenpfad nicht gefunden: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "PSN-Spiel nicht gefunden" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Synchronisierung läuft bereits" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Spiel nicht in Ihrer PSN-Trophäenliste gefunden" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Spiel nicht auf PSN gefunden" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "npwr_id fehlt" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "npwr_id oder group_id fehlt" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Der Name darf nicht leer sein" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "PSNAWP-Bibliothek nicht installiert. Führen Sie aus: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Kein NPSSO-Token angegeben" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Verbindung zu PSN fehlgeschlagen" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "PSN-NPSSO nicht konfiguriert" diff --git a/translations/eo/LC_MESSAGES/messages.mo b/translations/eo/LC_MESSAGES/messages.mo index a7a39ec01af302c28eeec40e9d528df3d71a8a4a..54c1ac5231e028db2a70cbd275bbffdc2ab556df 100644 GIT binary patch delta 35577 zcmYk_dA!Zl{`m2I&M{;jGY`i+&y=wYnTJSGh7eJaBvTr8h(tm%CP}3-MMB9~D9v*k zh!jOBLz24neZBTtkH_zK|G3Y!KKt`o!+X8g`fSH>@2mgI{l>q!lZOgsnV;Z4^Dapw zO5rP4S^Izg>GE(QQJQL3EQrG~Ki-c8a5h%Lr?DKqg)Q-CERWS^Bog_sE0)Cm=zVu1 z?IsgXP^id-*U$kRLLc}W?I738M4}0nMz8lq2QUm7b7BJK#YtEV=b+DR#ccQ<=D<&} z10KYjSosm!rvF4e3Wd4Q0t?|a=uC&A4d0DeE@<$jDp4g3;)Fk^NIWmWWnYtR{vLuWP(oxxnZ0vBUhJb*TQ z0lhEloDjkMXavflOI8=1K$AJd-w}6@H}t}u)Q4dnd>!p*fBgJobf({HVqx5g-v1do@#NPO4AmjD;or~)|3PPzjnknh7C;-S z7Hx#y*9uEuXPkmVu>hVxXZjENT+Rhy&5K|y>a~!iOeXqLZ~zm~5Iu}OxEKxj^Js(X zaWcMv&amym(D5*=Mtw3mpf%VAH)DOgh^Y=2g&gUNqqu$(wsZe~N}&iBN-XAd!g^?k zW}^d{kB0Ucw1X{pDegv>{F3?7WnO!wKj>KaPHfM(kH~;Qyi9y3mthNgLxV>TNLTntx2e z1`lC(%(X1!LO&cv{Z=gIeV7GLU}5|Xone-z5{crNAA4eTG*b7YGo6loaW?wgNpxU| z<;35H3oj3$tc9+5OEe-K(E;9oZnvT60LI4pM05ZTqXU?aChbeHz7<`f&(MJ!#|C%` zUBdEDC&Ne^KOK^=HQHg%=s@(w+tCnDK|7d>wQwog@HL-3XbeP%*2^!$kw1U*cm^6FV??~9!FqaAj_c6cpj!za**tU&ME zgf8Wq=yv}KbGZM1r(lN{u^i@pK8&Zx8`0};qaA#KweTo9paQGHky{7vpnd~7!2Q?* zKgUj3Wi^S0qp>RP!0LD$yJNvM#J?kj!E3^WwRko4f6=w-wl-w%5OirKpaWfkd2kEX z#=Te{&qb@O<4XqhK4=7HqDwRv-5oEYOS^R)@pp!walw!siJn6Tko~1_@|8v-a~Zmw zI-~awKpP&7Har8p?+G-g)<@sKywpEKpF4`?()lC>v+;je1q-haHpj};hoCc^iI?Ew zczp%tpuRu)Ddwks5S`E|bOL{4PAsq??r2B1VLqG?ZeN!Sa(EZ<rK!B?uysnMejd?S@0~j#|voAwA>m7JPgf|@#walgvIeGEQYUP zvJ!<)C|rXVFcUk!9=?2zK-cU=^tJgH8oFBB!rQJ3mZUxcUDN5<64#*j{fd`k?(Ja! zS79&eH(@D!Z9DNdR9|qx>_3T)tk8}S(wgX!T!l7xEBfGcY=X~WL;MciPK9@d>@SI# z)T^R1?ijBR#cjoS2Lzs(FllHDe4;3Rb5Gth`GK|5ZJCh?B9iNA%9xZnuC$L#nE+R-_5#3lBJ zNOZ)C)Vrf=Jr?co5i}QGM0eAhSQ(F?9bWQIxW6PC`ugYu+9l(KE@*NM#Bz8y8j&Z^ z1~#G{?n9I6Aeu~P(E;UsH!NKP^u7*gM+49$z6;CZ>{x#Zy)XGLg-iqlXLbH0>tno>RToZIx z^~9uEIF3R=ychH0Y;-M`p&dStuIU;y0-Lb_zJ~?z5c=F{^uENW;kBF->ruTD&6PW` z0?tAEedSZ)Z(%DJoXIY9q#t2!JcO?C$ym?vS?I6`=I44fbU;n95Ozm98iH=)Dfl?f zL_g2`oUdWn5Pkmn&*T1ol?!(G4!(e2#`*(a@aHt@v#>C3$NTUTG&0@4457acZLlBa z!rRgPe>b+oN@NH5e1Xu7#jMEXaov=8y>8Iu6--C;a=$4 z4nadX6McRKn(dp>0se#z=qwuH^n>C1L?J9iy(^~P|F=;n$%Q0(;}d9uYtROFVQoBv z&ZOL-Fp$g9j@qIF?2g^=W;9Z-U~$}sM(Qv+fn&G}&tcLKZTgNd;$E~~{&284yt?QBCsy&Kca(fg9>tDr|y3;w&ut17A|o5{b2VDONbf`G8H1hY2NH z{1moBH!RNsebA2XLr1&-o!Qgqh*zS?`Wo8t=je=oL?d_-o$-HI1+)Devb{Dsk&X)o9NGWShBD5EdOmdRE=5OL3jMqxW?)OS zqpQ#cdtyNxf_5|>UAoEf`itoO+tBCVMcPj$KBQo%zrZ~B4cfrZ=o+2FIhf^K_%Ygi zY)$XyyeIh!bDOds*VRzh!EilX9A=_J{U#)IHJ02IEgw?4(f(~p88p*wX6My&hQ7#yw zZ2yFr3nD_O!Ir-w9oY)^Eki zxCniIC#HV>cYuNo{}TOQG;exZY5--?&^1J7-X05K@A&y}bY>4=C43Bf;H&X^PQGtB z!BXfzo1pji#DYl*H&bYY6VQ>a!<+CR7QvQTVux6g`fx0b524BX92%*&(dQ4M6Uv=6 zEm00jqt9K5nb;S-|6WWspfHz$59~!p_6553KgH{bY~g_d=vr5f^{dgD4MCG_657rq zXva@tDreC4zD7fTCVrkPds;GNb@sH7)!2y}7Ng1Z1Kx+ZF9{FMz)b4TpdIbO`uI5- z+DmezrT)UAJi26~(d{-B&8ZpK3KyXV*4H_bA&HL18_uH#QkI-)sdqwdbY{iS$W%ZZ zYJ@h>20b^rqr0S6{CouZPML`2%rtbnE{HBh&zGf13VA6kkG>Rd*nt(fz8j6iG4#mI zn=5pDDLTXQ*ccn3As&eiWEz?~OVN;TL?`kdn!I145lo(>-~<1l`@UfAw8RoDiAG{G zmcbop1P-Dz{|)cKf6$0b$P;IZCffouGB2a|zlu&|FWS$qNG>H4=P4MPvU$@|zdTYN zYf>MFHv9}akPXq-qPx+E?2mqghWtx(=10-cUqqkJnJMwJ!3uQ1Ytaep zL7#sgz5gpr{rUeW1=spK+Ca(TX{lemtclidMF%nw4fPE4$XG(5xZl-WJW7-e{8D ziyqDM&2KcO?~QZX&{%Om}8 z67|RNGb~amE%kT3CviCShLyvTEk`4?4rk$B?1nw7!~+b?fz{~t+l1NZKe3g<6o$)MmDV~d;zkzn}4jQ=+(cC(LCi$Oeg!5EmN!1P)Bs+{m^Z6D;kN>Xl~qx_v38zzPvTU^Ci*GtD!HiX6QhBM@Pi#lWMU4 z4c(Jma7J6P3VwnO@FG^i+BHM{S|kk;)6to&tra>th=%$%G_*Nu2aDi!)XSq0nGj8) z6PZ>!8Meh-F1V&E(B#{IHncm|-^cpY4`M6KUnk_oHR#gxK_ho3R>ve7u~pHH=-O{X z+xY~YXx3!i@L(-;TQoyQHV{40hNGdo7Y*rLbYRb*5qk-p*<0wqKftP*I>Z7}+IeKti zg)Yr7Gy;>+=N2IYPbSt-aK; z*Y+B;or!1!)?jL>(9nN?Rowr-QZRIvULFQg7Hz058tN<2HSK{mJQ>^KDs-3pj;*m` z4!jsYcm+L}-asSs7CNwx(ffZ! z*ZN;H*$OlZ_2TFZYohHnLqBha4!9dS(B9~Yc}p|)zatvS1(RqjI`YZrgLBZ17NH?r zjYe!c+R%Qq!>`fje@1u9dG!8l%|rXe&;gZ2BUl~Hohy>opWPXaCM+cDQ%Fs}L%%ol(U8*){Wcs2@c^jIPcc24Xfb^40ETfPLExJbA(HZYU zZ~PcNIKGPhhGuovmZ5{HXvi-zrxl|of6lxY=0 z(;m(0K3E0sMI-SnI>4Q118<{icpzTCfOeGDIz*}<8llWsuNiHME@20(qm zKClf9-P>p=zeKbCH#9O?+JplnAG+4Hu|76J2YL%Sz$xfj&qE`)5*^^SSbrZ=zyE)f z!Z&m>x6!rz0NphQ(e3P1S#|1+&0PW}&G{oc3-7p1P;99hyljzLPpaaTsLt0`VmO`IDiYD)A^q|VwC%iK< z(RSOQ5$WEC{qJk>+W5gd^ewm?&C)H{8&6_4Y|}S1^cWi2P3Tf>MML@y8j;V@h@6Yp zGx~+k^P&STjz*wfl7dOs4h>xwY=^gEI<7}M+8FDvqW8a!MqoF(grA}_`vGnEG}=+N z{%MKvSO#6%1?bW)L!VFXqF@7`U{ySd?)Tg`hENwlZ!C>=TnC+TBXq`Xu>*ET2f7%| zjb-RSHlUH%hqn7Ux-{P*?Isf!D3}yE2ZWQU47$e6u{gHDhS(pS;bUk=PoV>P7JYsd zy4E|;T>1=+=t1HqsuqD2XNe`5>6xv{+o5Py+MnilPI-_Ceb{UI9 z@osbt4`WOG3w=2?9h#OHf^*RvIgjR6wqYScMbX_+JJ!1nWB=FW!VoTu#kuGjX1^uO zq%_)bbu`4a(FWT_d!f${LkIjgx+IG+m3(ND?u+%0F_Zc+tcclfO@{qk^VZPt)mVcM z2BJy$2v){wls9HEl#_I2StO!e|Fo(e2p+J7I5Zge%Z5G6&Fzokepjt&gMYriVdkB92JtQ;GH3~4bYCd<5hSEy8T|k zyYLJ)qyNOv(P1Vl(9mqhdUy=0VX?cypZ8m%9Zg4Pz8&r00QST`Hfcvt-V zL^R*{Fwll*^7TXqHf}uo-x<&3f+ycASOq^pv;HDhz&sPeKCXw>n@0ztIr1R7wsX({ zEI|jjCtm*7CdOx%Pn-4|$s|Dgk~FexqZ5jH{Z&p9~^peCA2UC`%-;t2QuYzl6tf6y5g zo)RL`1ReSH_y1*5Rth!hWZK|iAAS|iA+P=TZd2LJ~V~G{sv_Z z_MrYYx~)shN=r<|2AK4v@)`x--r5;I$lc?*EVQW&9hB(7HKciFTrI z&F|0|=b9S^Q~@1$Cv1YFuo? zxilOU)$t`RT!FsbPN5+!^<+5nJD?}qedx>Rc{CSxpdtSrP0owx30G`c*nU;e33NmU zI3(7ez{{y8H&bwJPoW_%{#5u5R}&q`T(pCo=vsb>Cg1B13 zSD@{l#}=65>CkR_ke(WUtx`drENq2pHQlHa^O85*9$1!wp) zx&&{dA5d9;JKuoM1>jj`^_@sIKNG4=K65>4KimUse};v?8N z`AYoTYV^BV&rRX1ej4XfKZ`T*;mzT}OSgod<846S@10)_=fXs6Mg3W9ipS7xTJE*9 z)GwvC!~xV7<3Rii`(m=s))0Zs*nkUfq1)qcY=oJwhZ*)lb7f5QN%Up44;$inG^91Q z1$&|QPeR*Wg|>SLJy$Zer;<3Cs22)}8?c-YpeNlkXfnNzhWs%47R$3Eyd4Li5nGQg z#lL9Qm)RMRiMkDhs)^q=#px_Z%YF9{(%hB!CH#!A9I#**Revf8v z#_n)m6}024(a(ost~9<*;Cs~Py%D}u4}3E%aftelcm<#D*^`!-L;s14eQAl;@o`*+ z-QP+}{jcKuhFhpNc{}_p_cS`tG5bTLW}`{F0nP3MXr%tdxme?!wABBs$9C*MJ^#Dm zC#W}|-y3FN(zSSsf?4}MI`eGrr6pQoZM5M#(2nP0D_oB*(HX3ZrQQz_>55*z6Fonk zKqL1)ngdxr2|eaZ6_ zT%)?*h7X3JYdjTQinq`lI1?>)Fl?hM&=B85vQ z14yE|^b}T3z5gkAkeou3sQHnwJ$hj|>ci2Ka29$Xy@}4?3$($D(GuT>88<~c=#M@( z6GhqOiBL`YCaVG^k z+=1@X<7kMBoDDN*h33FDXao0STYMQk4=$k3S3eg{#GYu9O~K1?1A2u2jOJRSKf=In z!fNjSc@zxE_UNBz^3^*Z8oUh+@e*`^hp-*y`7_k7MQ1(*d*A|epg-Z2Sn5Lf!)QNr z$u^>q+>hnxKk+?<;+W;HuusdN189OaJQ|(pRCF!3p!Xk&pI^YC)N@@79gjx0;Y##q z{sw)w6#qN4dmS2~M=|LdZKL3KvtQA#-39*%f2%ziy>Ty^d}q-}mHuD&iq#t(*iv+0 zAE5XDfkvRhzaiwku|D+&qg&8-!-;>{|0Z9a|HAL-)kA0g5Zci$bPX?}1Fg^BiFLri zXi}{~531wncFdZVp4xup&}6QQw%-elT zMniTQ+hL__>8XQg1Ui6w(Gkx=50ID9Z?T_Xb3BbkqIUN5)Nbg4-ai&yk|pR%>1{MA ze@C}*vhF2}hzPVrw^{!j>4^jQFgk;qa)t({qaAF(`rQ9vtf%KrPyO?}OmutQgQxHX zG%^eGgwU@<2lN`cRELmnO3B1U3b$~fN#69-H<}gb3_rnQcm^GLu6&`v258c?#!MW8 zuj735OKOk&>8bzG+7ak3`3wu-DRjva1=3T0YA%YY_kTqSj;J#_;$i54G7fFD0*Lkq9I8eMsGp0eJPqd@1jZhCti(3iiLKEqDT4y9Duvg$doIdo=lCX zS@96!{y2gUrl48R1sF0rekxE^3giFy5SI7F7XvmMD11(T7J@p5Y z%IJXaLI*MpGjSDqBEE~>e-v%^0_MZ)mD2sI*<_+91@~upbi@s#ozZQ06ME*)L}&6D zx2HG0kMI+JsmSZOFKnM06-iaAi(o-+FvG|AY{~sv~O6N6MH9hfp8b5TZ zk)GI$g=&S!yi+@z52x`;8pu;8?EkK4a!p2O_9Xg_Sc8}27g!GS)(yE;7mZ*mbiyO> zYWh#yPoXJ(fX+Baz0gs8^c)zC&R{kA`G@Gc;sW}GqGtUV0kngW*b^6{yW>}Ei}f3X z9GQR)@OezSPrsvZ1J-F6X8bU^_Uq9ZeT!y&wnkz5RYP;;YBbx&p#z&A>pRec=pZ)3 zte1u7TVW>kA;{UEn0gue-)*yp3x@I_noJk4HRifJgti;HJ^Q1{G!i|sr=V;7RIG1D zbK@)Y9Qg~4$k4{&JeiA5=mQ*r7aAu+2mPCb5syGmrpM74uR{m;F`AqwVm+;C=%@r5 ziKggtgV2H96J3Z7a3iMLiPuk{5z3Zq7G_)<4Ph(ng}0$6-EQoH$I*rxHV+~1j!mfF zfiB^T_yYcgo);@xge3b2J<`8NBljOV;KEmg_LI#h7?MHQ182q$4x&kyab?)YRnVom z5JRXhMGw8vyKY9est-sI+7rrVCxCPd7{|}&G7SBT0 za1DBP??XF0i4Lq#+pq)`(RxcXB0aD!4vC-7$K2F6ppn^uM)EK^fGq7oKee!v`@cN} zNB$U^y|1F7J%Fy+am8T$`_Q0job94<2t&DC(lj{R) ziGQH&T-Ghz*8yix?}yjn@owz@Ybdn1I^@6{bgeg_Yxo%&!e7vnE^qg+Bo)z6c0unS zi$-P5`d!Y5P=)ttEM>0Hkf(wo`$2DP1OQH?dMt4V7bRgr=q+5(` zvyagG{za3lbk8uL#%PH9qDSo==zY(lIkpco@o_Z3oBYMJ}L6;)`_2Kgd=zy+4CwMlnD-Q?*>V=J{&qJ4BKf2aG;cC2eV2H$SY)Jh$nzW?` zh4!z&(bUJH1NjM4|Ng(}P2taQk6|Z1_!V8N#)Culk3d7b09}G@(IeH;Ke?K^UbI8hUL&L~hp^+GeF3FR48}7yaSa(=@;#GVU zJ$l>U5(YW}-EJ$;{rxqz#Ui(cf%d~b)Mua*`SI3d_@a^Xws0_X#g=?94V~FrI0XMd z2ij+N7|;V)kNOK}Ltmr!|A*bM)`(EQ7ahndbeA1N?@!zwzM54^Qt(8&4NazJ(6##_ z-jHi#$mZ@img}o=pQ-;3U9#h&LXwueGj!A+Z{qr7G#R={xiQIg8FL-k+vmb1}hUXGdg z5%PR8ahihLCC}KfU+YGjp$AMmbO1f0W8>!wu|3yUpdmkj1+mGv@UrTHCgH7U?mUhz z*?;K4RByZkV*mB0VCZL|GuegS@HM*T*(QXq%{9?|-xo`y@wFU1if7%Op87G{3QWD{ z(GxMt#Pq}oERRMm&plzHwXiAm?wI=bzcVTLz*2N3Td^4CxHo){FONB?kHaN62|bca z-4~u4j0L~e`ntLfiRO>(f$4q4#Sr)9~PYywo?@} z#MhyrzZ;$5qv+B+h0Sm?x{JA37m-6|3W8LczWpga&-IkiuGyHA`b_vVQ1>i(V0w-uEb8%_n=FWZ$|ivRuQc?MF(~@I^&_yM{ywa&DhudUvy>& z#a-x{<$olcRBf?0^?T4=@hM(|3@jrCz@hf~mVU?n<(4e|P6^hnNlEIeNn^H6VqZrfJq z0J@Oq} zby*k&FbrL?Iq3QDI{H$2FMfU!%TX`9i2d(@a@nFV!aivI0rZAvu>u8z6j#E!KQCyx<Wz^TA*MC5FL-ps<6Z^3r z_Hh4KUP;K)_{(K%O8t@N!%O8YY{B)MFQlh_H?+fx;al$oG$I>Th1colc$#{v)#-_c zxxc}h^u%=P+t-GU+N}%QG5-1`P{ucD}=}8LC zcs-h>htZ_V`dSECJv6ySpfh{{yW-b41gmZh2h%LHegJc)^AnZV!+o8$hmIDZ-~V@^ zNqjz<%)cXisjQBMWN-8UdgDoS##wiUeP0PpzV_%Y7#`~np#xZr6EXj;co3oYFGt(i zgO}qENW}R4uiYWk^`jlpBXl5k#GUAaIo=3=`&A9yuD7E}I2}D|*P#c}H)!r;c{9|@ zp%d$bMz}u?z{!}IdjC`K+iAw0@WAC*oBBY!0%xOZ{{gy&7ts5Q><#<76J}DMgynDr z+R+|#KtH1gQo(&8M_QmKY)`x@NnscTcflI;!K3K6)Jxt9Bd>!lMQgOdewc|<(fd}S zk$4}C)LC?(h29R2yfL6AF%)Xa$znP%+6oYwW{!8cD$7u=88 zKMf7n!N;lhK$Gnl`qD}JEF3IF(1CZvHuxNN$Dh%eHvK%T_08Cp`aNg_cVk^Vjz+v# z@{4fRH%8Bad(h-qjoz>meXV|n4kXW);d)i{VCsM#JiXEL;db==cnW>~U3B0l(FrCF zgxsl$=2WsH1v?y!-Z&l&@gwMwxdC00H_)W|9Bue4`dp!}!qHs??VugH{YIiQo{8mg z1N!`D=y`AkN#10l$k(BR7U&GSqca$eo_q_@bKp~4p2jz&Z$j4Z_%<~7BRZ3egJGMN zMkCQ4y?%Fe9y+15=!AA)VYA}n_`xw8%?E#?Yk1qCFr)c+EA{Q@xlrZ1&~Yy`X~#rY zp^-TpO&ks7Qt_5=u0und#Gbev&7lIngIhv z-i8^!v&+(n3~r~MeE3Xy>VI~x{n>DWtwxW~fACuDcrFZR8FowK{J^WZUgdmfa6G!L zzQ;T9ERMvR|BU+`@1uSO@4!(P!axrrOOi}9{3|5aJnX~`$FM2ZyO^H(S85~hAsav= zF!As3)9dxvfqMEs{EaCC>x@gOulQej>Q}6<{5L)I>-U@SL#|KxFZ`6eXCfm}lKcO| zKi&T&(=$^4NUTssMxqQKyo!eY%PbkGtSyu^BlQbP)o>8kA47BF9GZL;vt^`SPQB2j z9Eonz`_RZdj-G(e;Z^uHx}Ec6&j`Q&*N{R^F7(1za1ai`B9~;OvicsZPyGcn`@ca4 z_$QiN4RU0pelpS?`%_0eV6{kM8qN(1Bfu7S5fKI_vAA2URyr?f2+(EWq^_a%Ut{A8h4ww4K`ug?8^l zbLUxfmwbsnU;EN9uw*w1sgnxL&JAd&&!Z73SR^C$_A85a_%NFN&to6_65V$7i)N&L z!T3Y8oqokaWS>TpZao^&&(VndhMDgFip9eRZPAl%06O9c=$XD6O}YbU*8hw%uu_Sz zYt~~v>aWN8yXgI2ppiX}E@9r1Y;SCcPPivFaQ{!DaD)q+(6wDyD$HOF+Q4aa2E|He zq~3Nd(4>3>4fV5Vl5Igd{tivX-_aQtER&IV0BfS>!&PeJGa54A-#cu zYdaq~Jrf(zNNlJWvUndlpx^L*EM6%iu^bnnOVp%tM(R7?P&7%WppjdG?uOlH$7eAU zD^v-Qx~fV>G8MAxxL^lEaVpM1Ls+D0m`P)F#_iGl-v{mBF?8Usq1)|C9D|8!A&JML z5tUWj-7Poc zP8^MPT)$RmXDF_qeiXOjwA%4JNY)9-Fc@p|!E|hiTVnkJ_NHE?Zbsr0OrqI)O}&sS z51<_{!n*hz8ksN9B>Nv;kLBx!`|iP1KWN00TPPUXBk{sHY(l+EgN)Q)GF^`*-BVZ| z4`B~XYZ#vEiEXJ5MUUh)=q~sO?cfL+iCm3>SE2{dUC6+biFp(pz;g5@vk^_2{b=a_ zj<&cg?1m&dqvz0>{)^7E(&ZV6wb%(w-V4z@jYGXW+WzI}Zt9wo=eW)<`t#A40d^GIYCbN0;PBG^vX;%}9N7T8a+*7fk*8pF+(- zR@XSzcU)Cak0KFdK}&F1+U!L^x=6sD~=d+*O<&f zLvFuyWajN-Dc&_^(A}AXMrIBgJm%JMH-}HQyxk#fedU~&XV$CVv_ZWZb?ZJf`Hr+L zlkZ5sIHk>=ZEr5$`}E#tGT&VO=KB2`-dywMhD_L&y5@gzeY+oS@V}m?zUKQ4ue|x< U|9Ahw{O8h3Zh81@dY#Vy2h~TYs{jB1 delta 35456 zcmYM+cihg^|G@FNr(3MsS^B_u?H zuQn+n4ZqjxI_L5D_0RL1&-MA7@jmZ!K9}3AcmK=1;oscJqXn}pNbujh?1@BaTy>?j z|NpP~%tWFL)fQL~2V#C4jRi1?mGDt4k8fftJcyTK$%hh&eAog@VK?-?kx0AA#4HLG zx$rVNfZgZ=KcgM|jZHAmtZ=;pI)J{&m=hx~FOJ2kcpv)Q3e1X|F$cbf9dQ@t#Gi7}*;2&rxi$5G5XoJph7&@~%(HTs^mN*m3;m2shKcM%W#k`n! zBt#$|x@4K?1gbtl{2g(tctd;aMZGWP!Ifx78{_BO(3yUYM(PyWVU|b3S{J~4)Qe+& zybKFq6Ldgb(DwSE0~_)v@z07(HZ}Py)ei8aQ|Q| zMEwSIdrd$)UWmDHDHg_8(1^c}PCU7Tf}z@tHvBdE;4kQm{={OK#wlS#C8Cwk`|4sz zY=-w^A1r`}(3$>%K6e3K^Q@1P`B(;7%4DJo1qUz!4bffbgEP^PKY=#*EKbFB=nNY^ z5jyURRjH3d2eb&=;xep_6(N5#FCJdCDBNYMrS$^2VxR^?l3yA-`&Fi2MX@U-<6T02{paZxGt>26e;4X9k528uC1g)UbJ2s9e9-_ zg@zRBqYsWm8@v}Q1g58x7eabOx`)&o{^Vj_3h&2FLMp{0(jAy%)kj_MkI8jO+0Pmc>OchU;%3 zOP@@9NWq4_j{b~}^c-ejj%DG2JkjE4j#NN9tbxwFDYnNeF)PkOCo&Jc??rSeUqiS1 zcFf`a|AvAc{)FZ6A9SQ;mxoZbz>?JaVpbf7c6e8;-;aj!A+*Ei(d1o^cKiW)|6Xj3 zN3aQ&dx`ewKhdAUC3qd$z%Vo??!q$o3L2qZXoCl%-=j-(7M(!)%c0}KXgd|rb{nIg zw?HS>9dqDlOxoZ?3Z-!xnuJTy>l@GxKE#^17adUAig4tX!!gu5p#$8AJ@EtVjKx;+ z%Eh608Lq)D`zeXU6OEFbDOG z(f2Sv^2vmuLEO-n%;16id)Y%jU+!xJ};pnyfv zc61@uqP`5B=zgUAWa5{2!v(bCZ105#6i2iEQZ&hG#_OHp^b4e@DoW_zMP zpi6TRP4XfigoCIWW>Rm5h3P*rj6&)p!_?ZKkywr9!aL{z^l`lYW%NgMCKu3b&h}wg z!cu65m*OSZI9_iN>sO-pcgCcl9YVo|CZP{bM?zmL6=^iu}7NWc9HLQ$#&<@X|_vhLXLVqbbfkw%Ap*ec;^}zBt5{<|#w1K5)18<;7 zwF^t)F?2xxp-Wd`XSnZjbd9^COMC;C!(^;4LEB5dMIn>I=V-%!pdrukNw}c~W~JU1 zt72z#rgxwx;aseVt1&ws#vFJYJz!48`oEZydbVBR`GQEh$wUnb&a^%{(=OP#;8>;2b*B!n?!3>!9s+K-d0SY>KyGCa%OH?*C6H7^?5khWa?n-xu~0 ze+w(P;0#|yN4gbr<8E|~56AjnSdeKya*cEf(V08bF#8&tS z7R5biq<%mnbuLN48Kr$08Z3;(sFy%*tcydj6}l^yVk2CGcJx0qf~Vu>=P-kM`oS0} z^u7}4fGeQ`sfMF>D=g(=)j(jpRYxq+l(gFc5H>e#OpN= zh4z}F&-Fr+bSM&`Wa3^5MY!-H+Q3`Uqgau8+ToClmC>ZDjgGuEI`gja`qgL$H=;Ro zJDO8d(Doli2f7@M$m^-=tp8RDCdU_O=zl^Zkp5M8uoSxXbUyKfD8G8SEbf7!2A%2B+T=2UPv0~q`{$8lT1qVEz5{LO zZgi%z(OvNrdjAV(JFC!8uf=qH6McRY8o_Poz&}B^{r)5cJNf~gS-xYTgVLDVr)Y#) zqeARUkbBSwJ%mm1DKyFU;~dO)oEKGEBC!~YV4)KvAXfc8Oek6Nhp-)5;-x&$ z8SQ8kI^t>Q%pOHY{5YDdFQXlQfX;Y78o|Tpj89<|{1Z!K*&oA1nqejCJuuV#e)rth=z6#mcfJQ0M6lMnDeJF;QDAseb7kVi)Q^}SQS@eZ9IT3VYZ*cK#OBR z`cKrLVA5QH-f&g)S~S$Rp-VA2*5{)GeGZMpYv}WzqDyiV9ndeB0}KBWB3u@YOf{^4 zbukP5C$6Dj2nS$xybg2WNX(7nV|^;x!CZ8W7oj2l0*%m5XoRx=8veLm3SGMH=zx2p z?G8gDJ`R(Pa4H2mo{iQQU>;n8Mr1YS#COmJwnz74ZtCC0&rindf1yiw$;mL|GH3)V zVs&hWF4gdp#NTANhYL3JeDoDGw42f0@g?@bi|B)WPKAiwf(~dbnk$ph0Y4ah9F53w zwBt?j`U%WO{oEQ^V`u5 zA4Eew9}D72Of4a%`is|-hbTC*U(tsCi1mwTwqEi_7-??w^NN^(b4nE=ifrwPbRidFw`Gn9^8pGa1dRi zhd#F-9r!m`3%^HmtLUF$A{EdHH^YkV|JD?o*)Y5w$KgQShc;B_T(B{^1TE1e>VY_>evR>d!(iSuCq)zAnuMt56#>-3-KPQmOQf`)P!+R+4Wz?tX_o<#Tgi)dut zLwCvESU(l(7o(;A3hgyU2ig&x&{b%A*I?2$8%n{@kH->tKbp;p(HqyHq1}QGXb0NR zK`epap!Z$C)NZ;E+ADxQR}NkCYSCKg1nXTO{?7bLE|>#d(a`inXE-3%Z$<}nJC?*5 z*aMeh3p|5GvEJX|Wzz}mcvy5SR-=9oI??55B-j5<{N2}kxnPL?L}&63I^rDvgnAxy zX-c9ERL3INB!1oGtk1oMPEQV9i znJz>_{|b8l4s_s$@G?A(zV-544EHxg2i66BZV;xD8ePhnNW00zGZad5;dL~Gd(aU7 zgx+`_2VkB=TB^Yj=zVvgA)SZL{5fofZ(%JwhxM>>TB!HO%G76|&%c7HpZ|SK!I6Fy z{Wba@I)HrXX{pdvL}%U@3tV_qWA@Neb6c zXow@wkw1e&aTl7^b+W_`u@v=zSOzDc$-4j@$OiQJUFd}V!Sa~rlJHz@%%t80z5iBB z)~7Iqf)A`mNA@AQ_Fu;9zo8GLWlc-1by2i_1v<0dXtIq(+qnnrcn+p=25oN#8v5_z z=YMBSM2HU0n%?Ro5u1#^TY8;WkX z@n}v>!qzwg-R3)TBtsG%h&P--52Q2bJK-O6X4!Lw$P_{ws)ROBA3Zl(p}VAg{Cp7l z{LN_2+=*`2Y0;VJ`7%36!7P3xx+LDP1}k!XEgFe0&?EOhwBt*1g&7va%c)mFLp%f> z$en2J%tj-(6rIRsGb}xmOxc_@na0&XNYcdKA(Y@Y)(_?)Oy4@Z}2lg5o z(ofK(IfHhbwLtj11R8;Q=q_l3o*Ny|$lip>z7%e!U`IQnd!h%>eS8E>u8U{~#R`TF ztDzBVj?T0r`uR233rC>2vl$)mVf3AG28ZB(1=;`3ba0^%fnjI^_n=GgF#7pZ=nR&| z>o1`*dkvk*d+3_)iS-|%f1*p3rErLBaWrSDM(Y%2|NByC%mrsKBzgry|49>qoYGj~tC5eb7Y!rqiiW%w zx}9!8XE+@l=ribmR-!X}J^C?vG=GIg=rnp?TCud$pMY|rk!^%7MY1&oALxRH@;Y=0 zMx)6%1#Ms+I)JCq2iKv`ZI1OlXlM_k?R*#OzoEHx4$YN+(E;Qro~rZn9}0#r6Fmp& zp}V0WI)g6g5)4EexB=bAW1_dC1Db%T0izwvLkGMVoxtnp^KYZ~Z^zW1|MyaGtxup0 ziAT_Z{)a{;y<`|b4m6pIU>o;; zNebRD813jrbjEk!V7vz%`5|;^PM`z%3k_|aQX%Ap(9g@G18jh{*A|^fuUNk^etrig zEAYYn6m&V-@lN!Cy=W+pquF~3&FUPb!`fdS?S@8RD0;x$fevIIIFCgJ6>%%r{})_+FN^sME>^>XNLXoX|3A6E5#T!3fL z0Y7wUh}?2q>HGf^3fJ<%#0qJt-){X9ol*0OX{lcx>55aRKY;r%Yo)Z*-}N5GTc}s8 z9G2`6G(ykd99)lAVcRP407G-&X>|L&h*{}Bv4TQNF094w_#3)rtu6~o(F={tz*rxK zhIlkOlN!a zqaELY=E7`r?VpP-N0)Rp*2dS-?Ry+;@4smFYV3bUT9AVKvlu#n>SzR-pd;^!ZlnHa zB!;57F$(X+BzoU}=<~U%htEr(FR$w8Ks!VS#p`3Mv;Pg<94HD1 zLj6i44H6U4nJumvI@*PX`fD__7ou5fr6sPRUJ#AQi0Bw}B6rqGhHWv03$E!rH2I!G z8(JIdZ(}{`yRbDTYKPotgDy>HbmrG%H5`LR?5XHdbnRE6?YxUl^lXxX50U` zbPcDY`+7dQcB|2bx1$XnLfM(X!yqJF67MF(CQeZDICT=Qr*^tmC?TceXO_5Pnh!3Unk z%D4*a;$Ad#mo!LA%p>W_VR`E78iqCAhvleWL}yg4Q5blAbZy(9?c9t;U=gO43Jv`` zSjGK+go69>lEz^m`OpYtqM@#hu4!wu;c?gwpF(%ZH`oRXUmlXKFFKLy&?T6TwzC3l zcQba!otQM4N;L^p^;)9`!&G~`t=6C0yR z*AI=rnE3gfP1*k@-veAQ2NuQ;UO*3~b!cSXL??XG9frjvDG-9jKhBl%d?m(YEi0+mX z=>319?PqTu29yttU`aH0Y9%Rn!d->O@D8-$el0?SBhi`Mi4J56rnV=h1`^$f&gcWQ z!=2~^K9AQAp)a{_(00nT3p>R>o`5flWjDNhan}NQD+%qt)n)-#~BNh8`T-qhF(0eHQJYc&iZd zD%hENWArQ7RP_00(TKi;hJGWuM7yw#@BbqdF5^Pp)*&>F(X8%_Rq$3c67$gkzJfNe z0bRq7#}XbOQjMz(0=FuZ%5bqel&uQqXS$O>u+P~_y6}&ILrs%#v9(gGW_XuHyXmf zqZ#c&1G&%!OQR8~hnd(69q1r*#$(a7Pofig0Dav*hAz>o?b!eB@7-K5BwwNroJL3f zCpyy{?Zc6K8D>)Nh3=X=upvH$jqo$%+gl=AhqS~^)N6MPC*%TjDYs)aJdF;dbf;um z;&lp5I)xlKiOr}N>m0UIFLZbGk3NR(j$P=HdnjHn(9SP{?Rn^?MATIz2;_hD=5)vroR+=`=cG#BTr^vs5aMF! zK~ovsUhUBt4n%Wh6gr@J=n1zNU9z>9fp4G#-GtuvF*?D$nDl|;6b$)!bS)EoL&KM% z4|YHgqHE9yjY7BOeP|9WL9=^JtnWkzcnFQiFK9o1pcBZ}FGQepKlZ;NsmujCs*Q%Y z1-ctLV+*_kZD<|Z@J4h%JMlw2hCaWde@Na9=mE6{eOG*swwrT6h)6N?ZCGjm``-@+ zaKX3Va5PKr#lE->ufm)ILqq-1(B6YC)f6%%}NVe0Sy=1{nj51zwbcmi#(>Cn(X547RI=*WkmA)bil%p+*>Ek_5o zKKd3qz-`z8_oERhb3>R&4b1NTuS>xQG(|(#4;}d^w8Kew155G z43@yuu0rpxjqajm*b47L&y!7xGAh?-$jFv;W8QK|d}S^4;i6j-wr)MjJSbHkf-v zung9qUJK2Mf#{N4hpFU4Cp0J4pTtb+tFa>PM$d&mMkGUnMMs7ok5@sHuoqUw0hrq7 z=-NJtuIUqKBvwY>ME8h~V6>Wre&=Q?_hgctk4&bKft>}zzM+fpa+FtTC3Wo5lcwsBLUq410{v4h0 zVYGu&X!2#bHGJJJiw&udL|k9lWYgNv_Iixm>3lzRTb$cnP?meiPq6BXx3hX z=EC(@4M(F9T8MV^5xSkeLnC+^9ccRKaJ?ux!%FCcYNAQl7|p3ZnA-oNDA>RhG>PV+ zYqSEr@l$jLC*tS-;KLj!g~x;g>V?}vavelNdjajJ*w}EcG(xxE-FQ20M89L!9LGf5 z|05|Fng_5huEwhPHMYYXw}+0pqceX1?O+M^!gu2Jf_H=;I@d&(swcVx!>}7pLnpQm zo$z0nbY?lnr=|WU6q#sNzlg5!J80;?M0Z2RouPqh=uEq#1G*I*@bvik+UOVPK>tRQ zujGU8~CL35-Fy0(4M0bGyHa8|tj zDmJ3N3C)$$*aQ=Ig|}hTyV(Czs1M>oOFW9ssLZ5r{c<$Ko3JD1y*vDUUSDia{bekV zzo8K;cuz>e8tCh{6}H9==tM7|_Z6QUB2zC(!O-+Xm*Nhrg%6=?v=N=r4qSw%&^4WX zZwTcotVDeqI)U%e=PNTY@4E(#=y)`uGti}6g+@HNmx3eu8%>%b_k{sfL$_ZybcO>k z6YoLSZZX>6hnU*m_!;%YlyLtZbO3*#?H8IFo~wzsQtyN8wq)Wx3eNB_nkeE8U&9MXZo6rHR#!`O&|A>Mw zlVj)u`KO1Omq!Oy3thWj==m@fZD1z4U6-PNaM>R{joz1MM*P(bJV$*98u~sn!vrT| zDf&+=rcfIB=zTpzhY^RcT+FLG3Wk&0@vZYXoSW;5@s+Bt5aWw z&iHe5KtG{LS>VyI-5R4O-dHquR-+O8;!*a$h2OZ~fs%7x7)TYgp{D2(T#xLW#5BBu zddB>;)ITn7i=(JNf*bKabV3^zq$OU!e2<5vdmT;2&(U1@9UVx?C&+q}qU{qQM6)oR zj$S~|f#pwz{r@hGr(SenXmB>ZPkjY?z>Ik+95Bn!Tw0Ij#CO;b|HJ38!P8;p2hf+* z=_G|$C|t5AyxrELAw7l-FyG>EvR#QqsgFi;;XyRyFQdu14L#w$Mz`xpbOQOG2?MN- z)`wzaoQy7Qay}oFcZH=2bz6FTI&BJkclqIay0ulVbU2Mqu^R*tPCC3L)Y>~bjFjh zExv%hWPU)Gq{^zWzk8t#&q1GiHr6*{J?fvJ9bQC}_p;StfL&L!|9xN-7bfF09D`Zb zga+?G51i@fT0e=m;5%qiReL2wU<77TpBi0?F3tPsbKjvIXMZ(heGRnTzONF0XE=fj zF2TcS$UZ@{{W#iD#@ev2TcZ&hjdm~>JL4+69M55Utg|jHu?HuhOVsJLw8YbR1J1^a zI0t7ZUk~5SO0ExQ^$1+R2b*vf_IM*a_!TasKJm@4R0TJLbD=f1=K3wz6j!4I{{j19 zwvFL$*RR9tsBguASbkH8KyosL`dpZcZjX1-UGP0R!!mD$wQCl=5q(+BK@X_6(Szjo zXqnC7{*Gw7W6^eBMECy=G>Oj#lZni?!+!0EeuujmO{V$iQMw#`i|s?-j+NgD5u1Q6 z#Rq8C{|~Rkf6ygty(J`LfAqd_Xk_N0yK60$bpIctU~>G2j_lH{!OrN}J`OYSW%Rxs z@$-{t$3?e=&ue0?H2x|FKjHd-cf+^pD(|Hwj#6KRE%|)b`)R3v(6Hl!w8UHVpBVUI zTH+Zj_EB2u|AzAhzDYgtarjy82K4N1wmn3u51OwZM!!b4(IrPhh%2EdV^=JK51|b{i+)`{fbRPX=yTqdDVQ7xb#cuBZ^%P2QA>-SyJ<4Et>h;i*us3=jJ%ncaVsu8^qTix3PWvu&P!WBu zE4oBem`+&CT0{l9SLSHZ$uzZ_Hl|Br6*!bmi9_oB(S2z|SKjE=m}i4f{K=u6}Z zw4Uh#eSYq+uKfpZ{w$q(TisQ&}|-(TwBlqpGHr_l4nAab;icjC!%NmtLRJT zA9P?<&xVK&z$Whh2T}#TRHDgu9&NDhpCQE8qX)^0*dF)AdZ}|^=AE%8*9W5meFd+; zW7r2PoDWMj2~FBZ(DUVGEJ6Q?ofO=s|3e3m_$xHr1f6MDbS>}268K{Ld?T({gvC94% z26h8Fum$M;-i!|9Cp6?`{|j%^F423@lX5MZeEa@m|6fDlJQp0{RTo1?)6q5DhK}?v z?1 zo8ep32cf&Cc>eU%|E#t?x=WtM0=OPsvX3w~9z~btXH5P4uV8`DK`k_dEzkysp^=z^ zMqoMmmir)negR$6A_apru{`y5=m3VJ&rLy>b_q_v6Y=_xLQIJH^RHdPjMkvZ_bGaE zWiK2;+Z#RWr$j$MKhIGl%|?vXoN4K+pt{G^hDB=t_=lm z9EUdWFq%9o&>O!*ll1Rs?P4L@Z$Mwu^U$Px2fO1DwB4G;(^E(KV7!+43^X!7paV)T zkskj2f5j5%sUHe=Mzi`&G^x&DQ!HOHEX5%79WfO>h*qNw|Bl{&b*T{YBs!7B=tMT) zJ$M3*)D5N6!}pES?0-Y`E*A{VUK~u8|A8JLJCsX!3Q6_2Fp9??sp9S#%d9KcjF9g>&c#Z>SJD9EaAQK|{V89q5Z_+e>FOjr_m+a zh|b_38kroG!@1B9y>B>X;)7^}mf=|3fn%_FmGsn4%U9v`AI+_EXauv@2?tVr>`uKM zHl_c>0ty~HpP?Q7g`NXV>V_GNLqC5U-G*Dx?|^?`DgyOF2Mw_o*RMl&$LrV*|H4{$ zMg1_q(dYn{VX{AkKPi|DJsN}tC!jN0ie~*T^d$TZy{~A)knJtdfeniF2hr{I0{X?{ z6ZHA)jlz;tN6-GQ=&qU7i1@otU*Lktv<2JX=V)k)G!7xGh$d4*^d-_6UF%`7{s5Mv z{w&^#ThWl$ygZyI{m=<5z#DN3+J40*$uQ#jO~T1E5FPP&bc9c$$+wCl{_pKX?J%HapOLd=edL z)+@qxEP;k@D4OMCu^m2!h1fj@aTxVqTZImLw+=aVH@YOt(Sd%BnW^`Go3O^^(NNXH z+SmnMio4N}J%g?ALu`cE+J=T(q65Df9q=R3m(bkWibnV_I^ZlL;b&+=qq+sBpvkoW zTj6H3oqyx!`L0S&{jXXopkMdb;MJI=JNrMALf`ITttX;u_%s^A*U*!0Ke{A8qoFL+ zBi!E{jm)*!9PdR#{uW-2U&ng!o}u0xn{j zA6w#CY>y54h6vq_&SVZc&{gOOw-KHBf%y3abU-Egg&DTP`qZyOmvBD1w40IVl8K}7 zLR$YY(+cPs)+s+7U+`Ph&SVG9E|61 z170&c9KCr*gn?dxjk!J&-QUk)J3N98w8F^nht;0wL{?!N+%qy64u--vrzcu*p&R=4WDCwEH*kl^@|5%@nP!UV^^FoCOz>ZzK<^1n%hE>eus8cacp{GD0aezxC)=Z z@6aWiG%oD6xMaG;%xfC;SnO+`dWSTsVU!d$GGiWUj{4&;M_r;7q1qar_K>;g9HyTilbL z`d_kiM33ZSXh+p1hoiY;bU^f0H1zjIA4Ws|EIOgh=zxF5)bIc2yEnXqDnx5Xo1tfY z2eg6SXryjKL;6s3bNu`yvZjfg>?-fCi6&=v$#PyarvGVb}~Oqq}G$x=T)?$=PgbI9F~!kM4)ifxbPJk>sWD z4Hq2gDa^zQ_ot`+In9;mKn|lF{v7=W+fdK*K!`*SbY@f04p*WR+l%gs)A4%#2g5h0 z%h3VvPf{?H1*V0UMq6A*eIPosywk%;R|)N)50=Cv`uS7n1lGpuThW;vMmzow&Gs@g zLcLq`Ry5g@vnZJDZ=&1iNc1=CLOo+@mj0!yw22cxKvcBkcor>;)$KvPfusrp{=)nJdg8lCZ%Rd<|bU<&o z1v}y#G~2&GJ1)O4+}{XYszK;`{7xK#uV4+#`&6(QI)D*q$fu*W*It=kMSBjjSi^8Ghx8v(W7@Ux-EY~BUEZh{3{srB{mls zSTeDnf+y79=vr2PHsnHkbd82ar=c@k5#54*Q`(1iRN}d?_MNdA_3`Kg)}p!a1-hHQ zM<AJ`O|yb!*2-+^9V ziSCBexE(9J7`}%8iiMJC{9JBXc(B*<@KTwJEx5i1U*Um#FNbfvThNG1ToGQUi|{n{ z>?_j~Gr9i)&Y=Fl>d;Z1HDNm*KnHLhb8=s`SHjouhOe^!Jt}uoaNnOmx8FrHWO>)7 zr+%5F96nBcE*hyy>%vlWL7%@a)+b^n^*Ly^uR$a7ZT!6SYvFBL7aMWC^=s^ZlWQUu z%z-D+gXkmlPejMjQ0IC*G~6)S2OaPPbY_pl`nzbyhtYxNT^}aW01HrWhi<#RI1q1M zpA7H)54qrWxDYMyMu@~^=l~j{-{r24^(p8!TZz8?wxh}WHM(st;7Ppn&2V3V4dMCP z=;z(h2~S8;FiV%CN%sjFvh!$i)!!IqI0m~>e-3ZNQ|Q6edsC<{!QAQmNad|?UxBwn zN7tfn)#+#wza2e*Pf<^vreH{BzY|=7-nb5(@h4awe?gNk@0PF&>Y??k&;gCZNq7KL z2hrB>`EazIS=bm?q7gfWL_C@JD-;s>w}m6L3U=a#Y3PHWVO#tS4RM2a!`gO7kJ|C* zf%H6@J3C|j2XtZu-U|_~h}Tl@gqip<)_4E!pkM?4p+{|%_rp&>`k-sS0A1@X=y${; zX!aKPAT-nwjnGJRDQBVWy^6k!4x%&8@?kh(OX8K(Yhmio|F=`{!4+uMe~OO$Pjo4A zd=wh2fSJ_0qW6tLvwS`psZHoW4@J*Mi+&si(g3~x+USFr`uX2V3O2Aa`ZGF%T-(C~ z4bU@v0A7d7u>oe^k)Gg8NwmP0)PMOTyc4SLN>6=nn1V*&T{J?!Vn@ukJ8ZvxyV?I{ z=j&W>#y?>z{1?skmY;?LW;Pn37tn^bqciy#UDE=eg#nJmsnp-Wq1bv4GsG9r?OpNn za9=ZQM}5ZU$*@g!bHRrH#Q9i!Z%DS)Xf}V0_3#LKaOB??lJZvULH$*9rfK`bTGv23 zXoC)P2Ktg(gGTymY=ak*6g&vpd=Zjk9D2hv^tHMS&H8=u`YH5?&UYYmR2Dq}8=&XM zF!cF(=)l*Z2hK;BiKoz<%Kv5PFIkO(H?~AW+zUPXC!$L-6HTf`Xv3S(?+J&{qx&S< zL7szQ`!z&od^KK*6Vd0NM$e0lX!0IG+D|63911fmhR&cKdh!iH&w;1#*)-k-=z|Y_ z6&hTH&SVF=O^>6I$a^GQZxtPYPUsGFLJy)7elm4E$@o*kYL9=!RUWLD6Q*8df_&P=h@(9||8|cY*2F(U zg8gskZiyevLXX&$*cQLWo3O(7;enaxd;djrt=~Zh{u}y5q|6T?hZ>@5eLW7rJJ2to zd(jEz{xM9bjz5WTZToY<5zatEx*8qW5xg&r11Wxf@6YL}U#nk=?t*{NrO5wFc)k+4 zZ9Ai%PsCO@8%^$A=&tz--Nwa}zlLO}f=#&45zYRo=uF>1Psju419?t{Y%h(5xIOm5 z2hbe)65TZ=Pld?ch&NN;fhE)VdjDH`;%(|Zeos&R-}aJu{|HCoIPA>_ThM_NKEoO_ z^Q*8s^~GmHL%*Wiso$UBuhmE5XzGWt40b-3p8BU8BXA7$6X*c@oDWN}0?nx$f2F>_ zBol)um_*C*LHrJ<XrWvFOP}XhkmHS71*31Yf8B!~qI7;_Pf0 zscb%t^{AK4o{`!nJ<*&Pg(lU@Xb9iN!I(csM(XGJqtT@O5FN+~G^x*_?~u|tGg8~L zF?xM4ruP3-3LY%Wu>o#F_wAo(L%DNhq)x^%=>G1A?%&bThtRcOhPLxQI*_B$|F8h{ zvbn?cI_QLZQ z8L3^+1|86B^k`p(F4-Q;jo;)=hLQgoZ_Jf1%&-#LU>9_MPe6CYY;1)eqBG2vKjcIe z^cz!uG=fvn0lta#@nEdyDG+j@HhMtyNm8(*k!aE^K==IyG$MP@j($KxoV{QeKxLdr zeK^|CH)xXmf#y!hLK&%D(hYt7g~DNA@1qCQUw9=Zs}u>Lz7-A04D_wH0PXN1dLou7 znvwc_zi#NZdkJS_yJDfC&(Y8pD;|=oG8+0WXhd$pOnfR{e+!u?fBvW7h)-e-EMFod z-BoDTUyBdn)9ALTTrwl|BiXuWy)~MoUC{^(Mw9db^r&5d&Uibz6zA|;tX|5cO>(4` z4l}qEZD2UMJ?7$7_!^p&>19HwOQK0u6YcmKbghS>GoFF>;qz!jDwGZF)kAZnFJ|I> zSd9J?&r$Fj%tmzN$IzU}Q7$9(wYnj?#&@7In2y8oEp&zzGDAmgFdy}v=m4)rmu@Dy zoj0Np{S;l=T;VlC?bB`LI`P_uTpFdF+( zUyPsQSu}e;t`m~*PqgE_bu&_bpeTh#rYoAXw_`ti620#YM-QMM(E;UX7}_a{ zEJZR=m4dI;E3h?=jlPENhO_95N;L{Iy#t-;)3_S9qRBhDaq#}=ljvHnMt9SDv3?4j z$R(F6`>!|!XH*@H#8|Y!mFSvpjrC8F6Dx5X&H8`Q?N_)-*lzW)4)uX(QqRWjSg>gr z_)zrznP^hKgyr4;-%#+u^kyO1>Z1d=Dtbe7B07LM=**U3XWWb~S+?e33EHFY``gga z&x)=@vwkP~I{qH7vZKE!w7|SALa4f;2gpRs#AmS@ZbKvWORU#x8LkgT_xp^NuXUdJ y*p@f5c1l}WIcNRMx(%DwsZ+g9z3Fd_Nqh6HG3giXf8wPx>3QBPbSAxam;VDM1$R;a diff --git a/translations/eo/LC_MESSAGES/messages.po b/translations/eo/LC_MESSAGES/messages.po index 9c01eeab..87727e15 100644 --- a/translations/eo/LC_MESSAGES/messages.po +++ b/translations/eo/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: eo\n" @@ -35,8 +35,8 @@ msgstr "⟦Šéţüþ ŕéɋüìŕéð⟧" msgid "Password change required" msgstr "⟦Þàššŵöŕð çĥàñĝé ŕéɋüìŕéð⟧" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "⟦Þàššŵöŕð ɱüšţ ƀé àţ ļéàšţ 12 çĥàŕàçţéŕš⟧" @@ -53,9 +53,9 @@ msgstr "⟦Šýšţéɱ ñöţ ƒöüñð öŕ ĥàš ñö àçĥìéⱴéɱéñ #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "⟦Ĝàɱé ñöţ ƒöüñð⟧" @@ -80,8 +80,8 @@ msgstr "⟦ŔéţŕöÀçĥìéⱴéɱéñţš ñöţ çöñƒìĝüŕéð⟧" msgid "Could not fetch achievement data" msgstr "⟦Çöüļð ñöţ ƒéţçĥ àçĥìéⱴéɱéñţ ðàţà⟧" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "⟦Šýšţéɱ ñöţ ƒöüñð⟧" @@ -95,8 +95,8 @@ msgstr "⟦Ñö ĝàɱéš ŵìţĥ ŔéţŕöÀçĥìéⱴéɱéñţš ìñ ţ msgid "Sync already in progress for %(name)s" msgstr "⟦Šýñç àļŕéàðý ìñ þŕöĝŕéšš ƒöŕ %(name)s⟧" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "⟦Ţĥàţ ĵöƀ ƀéļöñĝš ţö àñöţĥéŕ üšéŕ⟧" @@ -112,7 +112,7 @@ msgstr "⟦Ţöö ɱàñý ļöĝìñ àţţéɱþţš. Þļéàšé ţŕý àĝ msgid "No user selected" msgstr "⟦Ñö üšéŕ šéļéçţéð⟧" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "⟦Üšéŕ ñöţ ƒöüñð⟧" @@ -144,68 +144,72 @@ msgstr "⟦Ìñⱴàļìð ŕöļé⟧" msgid "Username already exists" msgstr "⟦Üšéŕñàɱé àļŕéàðý éжìšţš⟧" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "⟦Ţĥé ļàšţ àðɱìñ ɱüšţ šţàý àñ àçţìⱴé àðɱìñ⟧" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "⟦Çàññöţ ðéļéţé ýöüŕ öŵñ àççöüñţ⟧" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "⟦Çàññöţ ðéļéţé ţĥé ļàšţ àðɱìñ üšéŕ⟧" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "⟦Ñöţ ļöĝĝéð ìñ⟧" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "⟦Ìñⱴàļìð ļöçàļé⟧" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "⟦Ìñⱴàļìð ţĥéɱé⟧" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "⟦Ìñⱴàļìð ŔÞÇŠ3 ţŕöþĥý ƒöļðéŕ: %(reason)s⟧" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "⟦Ţöö ɱàñý àţţéɱþţš. Þļéàšé ţŕý àĝàìñ ļàţéŕ.⟧" -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "⟦Ñéŵ þàššŵöŕð ìš ŕéɋüìŕéð⟧" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "⟦Çüŕŕéñţ þàššŵöŕð ìš ìñçöŕŕéçţ⟧" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "⟦Þàššŵöŕð çĥàñĝé ñöţ ŕéɋüìŕéð⟧" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "⟦Ñö ƒìļé þŕöⱴìðéð⟧" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "⟦Ñö ƒìļé šéļéçţéð⟧" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "⟦Ìñⱴàļìð ƒìļé ţýþé. Àļļöŵéð: ĵþĝ, þñĝ, ĝìƒ, ŵéƀþ⟧" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "⟦Ƒìļé ţöö ļàŕĝé. Ɱàжìɱüɱ šìžé ìš 2ⱮƁ⟧" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "Avatar uploads require Pillow; please ask the operator to install the project requirements." msgstr "⟦Àⱴàţàŕ üþļöàðš ŕéɋüìŕé Þìļļöŵ; þļéàšé àšķ ţĥé öþéŕàţöŕ ţö ìñšţàļļ ţĥé þŕöĵéçţ ŕéɋüìŕéɱéñţš.⟧" -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "⟦Ìñⱴàļìð ìɱàĝé ƒìļé⟧" @@ -222,17 +226,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "⟦Ɓöñüš ðìšç àñð þàŕéñţ ĝàɱé ɱüšţ ƀé ìñ ţĥé šàɱé šýšţéɱ⟧" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "⟦Àñ ìñţéŕñàļ éŕŕöŕ öççüŕŕéð⟧" @@ -241,7 +245,7 @@ msgstr "⟦Àñ ìñţéŕñàļ éŕŕöŕ öççüŕŕéð⟧" msgid "Missing bonus_id or parent_id" msgstr "⟦Ɱìššìñĝ ƀöñüš_ìð öŕ þàŕéñţ_ìð⟧" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "⟦Ɱìššìñĝ ĝàɱé_ìð⟧" @@ -360,22 +364,22 @@ msgstr "⟦Ţĥìš ĝàɱé ìš àļŕéàðý öñ ýöüŕ ŵìšĥļìšţ msgid "Wishlist item not found" msgstr "⟦Ŵìšĥļìšţ ìţéɱ ñöţ ƒöüñð⟧" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "⟦Çöñţŕöļļéŕ ñàɱé ìš ŕéɋüìŕéð⟧" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "⟦Çöñţŕöļļéŕ \"%(name)s\" àļŕéàðý éжìšţš⟧" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "⟦Çöñţŕöļļéŕ ñöţ ƒöüñð⟧" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "⟦Àñöţĥéŕ çöñţŕöļļéŕ ñàɱéð \"%(name)s\" àļŕéàðý éжìšţš⟧" @@ -454,9 +458,9 @@ msgstr "⟦Ƒàìļéð ţö àþþļý ɱéţàðàţà.⟧" msgid "No fields to update" msgstr "⟦Ñö ƒìéļðš ţö üþðàţé⟧" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "⟦Ñö ĝàɱéš šéļéçţéð⟧" @@ -484,11 +488,11 @@ msgstr "⟦Ƒìļţéŕ ţýþé àñð ⱴàļüé ŕéɋüìŕéð⟧" msgid "Invalid filter type" msgstr "⟦Ìñⱴàļìð ƒìļţéŕ ţýþé⟧" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "⟦ÀÌ ŕéţüŕñéð ñö ðàţà. Çĥéçķ ýöüŕ ÀÞÌ ķéý àñð þŕöⱴìðéŕ šéţţìñĝš.⟧" -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "⟦ÀÌ šçŕàþéŕ ɱöðüļé ñöţ àⱴàìļàƀļé⟧" @@ -506,7 +510,7 @@ msgstr "⟦Ìñⱴàļìð ƒìļéñàɱé⟧" msgid "ROM file not found on disk: %(path)s" msgstr "⟦ŔÖⱮ ƒìļé ñöţ ƒöüñð öñ ðìšķ: %(path)s⟧" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "⟦Ðéšţìñàţìöñ ìš öüţšìðé ţĥé çöñƒìĝüŕéð ŔÖⱮ ŕööţ⟧" @@ -515,23 +519,23 @@ msgstr "⟦Ðéšţìñàţìöñ ìš öüţšìðé ţĥé çöñƒìĝüŕé msgid "Cannot rename: %(reason)s" msgstr "⟦Çàññöţ ŕéñàɱé: %(reason)s⟧" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "⟦À ƒìļé ŵìţĥ ţĥàţ ñàɱé àļŕéàðý éжìšţš⟧" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "⟦Ƒàìļéð ţö ŕéñàɱé ƒìļé⟧" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "⟦Ñö šçŕééñšĥöţ šþéçìƒìéð⟧" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "⟦Ìñⱴàļìð šçŕééñšĥöţ ƒìļéñàɱé⟧" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "⟦Šçŕééñšĥöţ ñöţ ƒöüñð⟧" @@ -602,11 +606,11 @@ msgstr "⟦ŔéţŕöÀŕçĥ ìš ñöţ àççéþţìñĝ çöɱɱàñðš. msgid "Next disk inserted" msgstr "⟦Ñéжţ ðìšķ ìñšéŕţéð⟧" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "⟦Üñķñöŵñ ìɱàĝé ţýþé⟧" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "⟦Ñö ÀÌ þŕöⱴìðéŕ çöñƒìĝüŕéð⟧" @@ -685,48 +689,48 @@ msgstr "⟦Àñ öƒƒìçìàļ ñàɱéš ŕüñ ìš àļŕéàðý ŕüññ msgid "No official names run to cancel." msgstr "⟦Ñö öƒƒìçìàļ ñàɱéš ŕüñ ţö çàñçéļ.⟧" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "⟦Šţéàɱ ÀÞÌ ķéý ñöţ çöñƒìĝüŕéð. Àðð ìţ ìñ Šéţţìñĝš → ÀÞÌ Ķéýš.⟧" -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "⟦Šţéàɱ ÌÐ ñöţ çöñƒìĝüŕéð. Àðð ìţ ìñ Šéţţìñĝš → ÀÞÌ Ķéýš.⟧" -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit Steam ID instead." msgstr "⟦Çöüļð ñöţ ŕéšöļⱴé Šţéàɱ ⱴàñìţý ÜŔĻ \"%(steam_id)s\". Üšé ýöüŕ 17-ðìĝìţ Šţéàɱ ÌÐ ìñšţéàð.⟧" -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "No games found. Check that your Steam profile and game details are set to Public." msgstr "⟦Ñö ĝàɱéš ƒöüñð. Çĥéçķ ţĥàţ ýöüŕ Šţéàɱ þŕöƒìļé àñð ĝàɱé ðéţàìļš àŕé šéţ ţö Þüƀļìç.⟧" -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "⟦Çöüļð ñöţ çŕéàţé Ŵìñðöŵš šýšţéɱ éñţŕý⟧" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "⟦Жƀöж Çļìéñţ ÌÐ ñöţ çöñƒìĝüŕéð. Àðð ìţ ìñ Šéţţìñĝš → ÀÞÌ Ķéýš.⟧" -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "⟦Жƀöж çŕéðéñţìàļš ñöţ çöñƒìĝüŕéð. Àðð ţĥéɱ ìñ Šéţţìñĝš → ÀÞÌ Ķéýš.⟧" -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "⟦Жƀöж àüţĥéñţìçàţìöñ ƒàìļéð. Þļéàšé ŕé-çöññéçţ ýöüŕ Жƀöж àççöüñţ.⟧" -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "⟦Ñö ĝàɱéš ƒöüñð ìñ Жƀöж ţìţļé ĥìšţöŕý.⟧" -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "⟦ÞŠÑ ÑÞŠŠÖ ñöţ çöñƒìĝüŕéð. Àðð ìţ ìñ Šéţţìñĝš → ÀÞÌ Ķéýš.⟧" -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "⟦Ƒàìļéð ţö ƒéţçĥ ÞŠÑ ĝàɱé ļìšţ. Ţŕý šýñçìñĝ ţŕöþĥìéš ƒìŕšţ.⟧" @@ -735,11 +739,11 @@ msgstr "⟦Ƒàìļéð ţö ƒéţçĥ ÞŠÑ ĝàɱé ļìšţ. Ţŕý šýñ msgid "System %(name)s does not support RetroAchievements" msgstr "⟦Šýšţéɱ %(name)s ðöéš ñöţ šüþþöŕţ ŔéţŕöÀçĥìéⱴéɱéñţš⟧" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "⟦Üñķñöŵñ šýšţéɱ⟧" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "⟦ŔÖⱮ þàţĥ ñöţ çöñƒìĝüŕéð. Ĝö ţö Šéţţìñĝš → Þàţĥš ţö šéţ ìţ.⟧" @@ -747,8 +751,8 @@ msgstr "⟦ŔÖⱮ þàţĥ ñöţ çöñƒìĝüŕéð. Ĝö ţö Šéţţìñ msgid "Missing required parameters" msgstr "⟦Ɱìššìñĝ ŕéɋüìŕéð þàŕàɱéţéŕš⟧" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "⟦Ìñⱴàļìð þàţĥ⟧" @@ -761,31 +765,31 @@ msgstr "⟦Ƒöļðéŕ ðöéš ñöţ éжìšţ: %(folder_path)s⟧" msgid "Missing game_id or new_name" msgstr "⟦Ɱìššìñĝ ĝàɱé_ìð öŕ ñéŵ_ñàɱé⟧" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "⟦Ñö ŔÖⱮ þàţĥ šéţ⟧" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "⟦ŔÖⱮ ƒìļé ñöţ ƒöüñð⟧" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "⟦Ɱ3Ü ƒìļéš ɱüšţ ķééþ ţĥé .ɱ3ü éжţéñšìöñ⟧" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "⟦À ƒöļðéŕ ŵìţĥ ţĥé ñéŵ ñàɱé àļŕéàðý éжìšţš⟧" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "⟦Ñö šçŕàþéð ţìţļé àⱴàìļàƀļé⟧" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "⟦Ţìţļé ìš éɱþţý àƒţéŕ šàñìţìžàţìöñ⟧" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "⟦Ĝéñéŕàţéð ƒìļéñàɱé çöñţàìñš ìñⱴàļìð çĥàŕàçţéŕš⟧" @@ -794,7 +798,7 @@ msgstr "⟦Ĝéñéŕàţéð ƒìļéñàɱé çöñţàìñš ìñⱴàļìð msgid "Invalid log file" msgstr "⟦Ìñⱴàļìð ļöĝ ƒìļé⟧" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "⟦Ƒìļé ñöţ ƒöüñð⟧" @@ -803,56 +807,56 @@ msgstr "⟦Ƒìļé ñöţ ƒöüñð⟧" msgid "Log file not found" msgstr "⟦Ļöĝ ƒìļé ñöţ ƒöüñð⟧" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "⟦ìñⱴàļìð šçŕàþéŕ šéţţìñĝš: %(reason)s⟧" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "⟦ìñⱴàļìð àþì ķéýš: %(reason)s⟧" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "⟦Ñö ŢĜÐƁ ÀÞÌ ķéý çöñƒìĝüŕéð⟧" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "⟦Ñö ŠçŕééñŠçŕàþéŕ çŕéðéñţìàļš çöñƒìĝüŕéð⟧" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "⟦Ìñⱴàļìð ĴŠÖÑ ŕéšþöñšé ƒŕöɱ ŠçŕééñŠçŕàþéŕ⟧" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "⟦Éŕŕöŕ þàŕšìñĝ ÀÞÌ ŕéšþöñšé⟧" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "⟦ŠçŕééñŠçŕàþéŕ àñšŵéŕéð ĤŢŢÞ %(code)s⟧" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "⟦Ñö ŔÀŴĜ ÀÞÌ ķéý çöñƒìĝüŕéð⟧" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "⟦Üñķñöŵñ ÀÌ þŕöⱴìðéŕ: %(provider)s⟧" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "⟦Ñö ŕéšþöñšé ƒŕöɱ ÀÌ þŕöⱴìðéŕ⟧" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "⟦Çöññéçţìöñ éŕŕöŕ: %(error)s⟧" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "⟦Àļļöŵàñçé çĥéçķ ñöţ šüþþöŕţéð ƒöŕ %(scraper)s⟧" @@ -883,7 +887,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "⟦Ìñⱴàļìð %(field)s: %(result)s⟧" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "⟦Ƒàìļéð ţö šàⱴé šéţţìñĝš⟧" @@ -891,12 +895,12 @@ msgstr "⟦Ƒàìļéð ţö šàⱴé šéţţìñĝš⟧" msgid "Request body must be a JSON object" msgstr "⟦Ŕéɋüéšţ ƀöðý ɱüšţ ƀé à ĴŠÖÑ öƀĵéçţ⟧" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "⟦Üñķñöŵñ šéţţìñĝ ķéý: %(key)s⟧" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "⟦Ìñⱴàļìð ⱴàļüé ƒöŕ '%(key)s': %(reason)s⟧" @@ -934,144 +938,144 @@ msgstr "⟦Šýšţéɱ ÌÐ ŕéɋüìŕéð⟧" msgid "ES-DE scraper not available" msgstr "⟦ÉŠ-ÐÉ šçŕàþéŕ ñöţ àⱴàìļàƀļé⟧" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "⟦Àðɱìñìšţŕàţöŕ þŕìⱴìļéĝéš ŕéɋüìŕéð⟧" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "⟦Šéţţìñĝš þàýļöàð ɱüšţ ƀé àñ öƀĵéçţ⟧" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "⟦Þàţĥ ðöéš ñöţ éжìšţ: %(path)s⟧" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "⟦Þéŕɱìššìöñ ðéñìéð⟧" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "⟦Ţàšķ ñöţ ƒöüñð⟧" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "⟦Ţàšķ ñöţ ƒöüñð öŕ ñöţ ŕüññìñĝ⟧" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "⟦Ţàšķ ñöţ ƒöüñð öŕ ñöţ þàüšéð⟧" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "⟦Ìñⱴàļìð šçàñ þàţĥ⟧" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "⟦Ñö þàţĥ þŕöⱴìðéð⟧" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "⟦Ñö àŕçĥìⱴé þàţĥ þŕöⱴìðéð⟧" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "⟦Ìñⱴàļìð àŕçĥìⱴé þàţĥ⟧" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "⟦Ñö ƒìļéš ţö ŕéɱöⱴé⟧" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "⟦Ñö þàţĥš þŕöⱴìðéð⟧" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "⟦Ìñⱴàļìð þàţĥ: %(path)s⟧" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "⟦çĥðɱàñ ñöţ ƒöüñð. Þļéàšé ìñšţàļļ ⱮÀⱮÉ ţööļš.⟧" -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "⟦çĥðɱàñ ñöţ ƒöüñð⟧" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "⟦Üñķñöŵñ ðüþļìçàţé ɱéţĥöð: %(method)s⟧" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "⟦Ţĥéšé šçàñ ŕéšüļţš ĥàⱴé éжþìŕéð. Šçàñ àĝàìñ, ţĥéñ ðéļéţé.⟧" -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "⟦Ķééþ àţ ļéàšţ öñé çöþý öƒ éàçĥ ĝàɱé. Ñöţĥìñĝ ŵàš ðéļéţéð.⟧" -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "Please configure your RPCS3 Trophy Path in your profile settings to view trophies" msgstr "⟦Þļéàšé çöñƒìĝüŕé ýöüŕ ŔÞÇŠ3 Ţŕöþĥý Þàţĥ ìñ ýöüŕ þŕöƒìļé šéţţìñĝš ţö ⱴìéŵ ţŕöþĥìéš⟧" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "⟦Ţŕöþĥý šéţ ñöţ ƒöüñð⟧" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "⟦ŔÞÇŠ3_ŢŔÖÞĤÝ_ÞÀŢĤ ñöţ çöñƒìĝüŕéð⟧" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "⟦Ţŕöþĥý þàţĥ ñöţ ƒöüñð: %(path)s⟧" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "⟦ÞŠÑ ĝàɱé ñöţ ƒöüñð⟧" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "⟦Šýñç àļŕéàðý ìñ þŕöĝŕéšš⟧" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "⟦Ĝàɱé ñöţ ƒöüñð ìñ ýöüŕ ÞŠÑ ţŕöþĥý ļìšţ⟧" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "⟦Ĝàɱé ñöţ ƒöüñð öñ ÞŠÑ⟧" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "⟦Ɱìššìñĝ ñþŵŕ_ìð⟧" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "⟦Ɱìššìñĝ ñþŵŕ_ìð öŕ ĝŕöüþ_ìð⟧" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "⟦Ñàɱé çàññöţ ƀé éɱþţý⟧" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "⟦ÞŠÑÀŴÞ ļìƀŕàŕý ñöţ ìñšţàļļéð. Ŕüñ: þìþ ìñšţàļļ þšñàŵþ⟧" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "⟦Ñö ÑÞŠŠÖ ţöķéñ þŕöⱴìðéð⟧" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "⟦Çöüļð ñöţ çöññéçţ ţö ÞŠÑ⟧" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "⟦ÞŠÑ ÑÞŠŠÖ ñöţ çöñƒìĝüŕéð⟧" diff --git a/translations/es/LC_MESSAGES/messages.mo b/translations/es/LC_MESSAGES/messages.mo index 39e16095de5b0b694d7419d501d8c023c2a20c5f..c577fc0e12fcf90b7d6e22c8e453b45d4f555847 100644 GIT binary patch delta 32 ocmaF!nDfnJ&W0_F+vB**6%386j19F74B8LIF>XH?$D}O`0Nm#br2qf` delta 32 ocmaF!nDfnJ&W0_F+vB*56%0+S3=FgljM@*zF>XH?$D}O`0NkYuq5uE@ diff --git a/translations/es/LC_MESSAGES/messages.po b/translations/es/LC_MESSAGES/messages.po index cd4128a4..2bcb43d9 100644 --- a/translations/es/LC_MESSAGES/messages.po +++ b/translations/es/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-06-12 20:10+0200\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -42,8 +42,8 @@ msgstr "Configuración necesaria" msgid "Password change required" msgstr "Es necesario cambiar la contraseña" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "La contraseña debe tener al menos 12 caracteres" @@ -60,9 +60,9 @@ msgstr "Sistema no encontrado o sin juegos con logros" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Juego no encontrado" @@ -87,8 +87,8 @@ msgstr "RetroAchievements no está configurado" msgid "Could not fetch achievement data" msgstr "No se pudieron obtener los datos de logros" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Sistema no encontrado" @@ -102,8 +102,8 @@ msgstr "No hay juegos con RetroAchievements en este sistema" msgid "Sync already in progress for %(name)s" msgstr "Ya hay una sincronización en curso para %(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Esa tarea pertenece a otro usuario" @@ -119,7 +119,7 @@ msgstr "Demasiados intentos de inicio de sesión. Inténtalo de nuevo más tarde msgid "No user selected" msgstr "Ningún usuario seleccionado" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Usuario no encontrado" @@ -153,64 +153,68 @@ msgstr "Rol no válido" msgid "Username already exists" msgstr "El nombre de usuario ya existe" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "No puedes eliminar tu propia cuenta" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "No se puede eliminar el último usuario administrador" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Sesión no iniciada" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Idioma no válido" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Tema no válido" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Carpeta de trofeos de RPCS3 no válida: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Demasiados intentos. Inténtalo de nuevo más tarde." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Se requiere una nueva contraseña" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "La contraseña actual es incorrecta" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "No se requiere cambiar la contraseña" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "No se proporcionó ningún archivo" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "No se seleccionó ningún archivo" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Tipo de archivo no válido. Permitidos: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Archivo demasiado grande. El tamaño máximo es 2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -218,7 +222,7 @@ msgstr "" "La subida de avatares requiere Pillow; pide al operador que instale las " "dependencias del proyecto." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Archivo de imagen no válido" @@ -235,17 +239,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "El disco extra y el juego principal deben estar en el mismo sistema" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Se produjo un error interno" @@ -254,7 +258,7 @@ msgstr "Se produjo un error interno" msgid "Missing bonus_id or parent_id" msgstr "Falta bonus_id o parent_id" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "Falta game_id" @@ -377,22 +381,22 @@ msgstr "Este juego ya está en tu lista de deseos" msgid "Wishlist item not found" msgstr "Elemento de la lista de deseos no encontrado" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "El nombre del controlador es obligatorio" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "El controlador \"%(name)s\" ya existe" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Controlador no encontrado" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Ya existe otro controlador llamado \"%(name)s\"" @@ -475,9 +479,9 @@ msgstr "Error al aplicar los metadatos." msgid "No fields to update" msgstr "No hay campos que actualizar" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Ningún juego seleccionado" @@ -505,13 +509,13 @@ msgstr "Se requieren el tipo y el valor del filtro" msgid "Invalid filter type" msgstr "Tipo de filtro no válido" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "La IA no devolvió datos. Comprueba tu clave de API y los ajustes del " "proveedor." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "El módulo del scraper de IA no está disponible" @@ -529,7 +533,7 @@ msgstr "Nombre de archivo no válido" msgid "ROM file not found on disk: %(path)s" msgstr "Archivo ROM no encontrado en el disco: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "El destino está fuera de la raíz de ROMs configurada" @@ -538,23 +542,23 @@ msgstr "El destino está fuera de la raíz de ROMs configurada" msgid "Cannot rename: %(reason)s" msgstr "No se puede renombrar: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Ya existe un archivo con ese nombre" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "No se pudo renombrar el archivo" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "No se especificó ninguna captura de pantalla" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Nombre de archivo de captura de pantalla no válido" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Captura de pantalla no encontrada" @@ -633,11 +637,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Siguiente disco insertado" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Tipo de imagen desconocido" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "No hay ningún proveedor de IA configurado" @@ -716,17 +720,17 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" "La clave de API de Steam no está configurada. Añádela en Ajustes → Claves" " de API." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "El ID de Steam no está configurado. Añádelo en Ajustes → Claves de API." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -735,7 +739,7 @@ msgstr "" "No se pudo resolver la URL personalizada de Steam \"%(steam_id)s\". Usa " "tu ID de Steam de 17 dígitos en su lugar." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -743,35 +747,35 @@ msgstr "" "No se encontraron juegos. Comprueba que tu perfil de Steam y los detalles" " de los juegos estén configurados como Públicos." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "No se pudo crear la entrada del sistema Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" "El ID de cliente de Xbox no está configurado. Añádelo en Ajustes → Claves" " de API." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Las credenciales de Xbox no están configuradas. Añádelas en Ajustes → " "Claves de API." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Falló la autenticación de Xbox. Vuelve a conectar tu cuenta de Xbox." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "No se encontraron juegos en el historial de títulos de Xbox." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "El NPSSO de PSN no está configurado. Añádelo en Ajustes → Claves de API." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "No se pudo obtener la lista de juegos de PSN. Prueba a sincronizar " @@ -782,11 +786,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "El sistema %(name)s no admite RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Sistema desconocido" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" "La ruta de ROMs no está configurada. Ve a Ajustes → Rutas para " @@ -796,8 +800,8 @@ msgstr "" msgid "Missing required parameters" msgstr "Faltan parámetros obligatorios" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Ruta no válida" @@ -810,31 +814,31 @@ msgstr "La carpeta no existe: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "Falta game_id o new_name" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "No se ha establecido ninguna ruta de ROMs" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "Archivo ROM no encontrado" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "Los archivos M3U deben conservar la extensión .m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Ya existe una carpeta con el nuevo nombre" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "No hay ningún título obtenido disponible" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "El título queda vacío tras la depuración" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "El nombre de archivo generado contiene caracteres no válidos" @@ -843,7 +847,7 @@ msgstr "El nombre de archivo generado contiene caracteres no válidos" msgid "Invalid log file" msgstr "Archivo de registro no válido" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Archivo no encontrado" @@ -852,56 +856,56 @@ msgstr "Archivo no encontrado" msgid "Log file not found" msgstr "Archivo de registro no encontrado" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "ajustes del scraper no válidos: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "claves de API no válidas: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "No hay ninguna clave de API de TGDB configurada" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "No hay credenciales de ScreenScraper configuradas" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Respuesta JSON no válida de ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Error al analizar la respuesta de la API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper respondió HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "No hay ninguna clave de API de RAWG configurada" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Proveedor de IA desconocido: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Sin respuesta del proveedor de IA" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Error de conexión: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "La comprobación de cuota no es compatible con %(scraper)s" @@ -936,7 +940,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "%(field)s no válido: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "No se pudieron guardar los ajustes" @@ -944,12 +948,12 @@ msgstr "No se pudieron guardar los ajustes" msgid "Request body must be a JSON object" msgstr "El cuerpo de la solicitud debe ser un objeto JSON" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Clave de ajuste desconocida: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Valor no válido para '%(key)s': %(reason)s" @@ -987,89 +991,89 @@ msgstr "Se requiere el ID del sistema" msgid "ES-DE scraper not available" msgstr "El scraper de ES-DE no está disponible" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Se requieren privilegios de administrador" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Los datos de los ajustes deben ser un objeto" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "La ruta no existe: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Permiso denegado" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Tarea no encontrada" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Tarea no encontrada o no en ejecución" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Tarea no encontrada o no pausada" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Ruta de análisis no válida" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "No se proporcionó ninguna ruta" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "No se proporcionó ninguna ruta de archivo comprimido" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Ruta de archivo comprimido no válida" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "No hay archivos que eliminar" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "No se proporcionaron rutas" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Ruta no válida: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "No se encontró chdman. Instala las herramientas de MAME." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "No se encontró chdman" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Método de duplicados desconocido: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "" "Estos resultados del escaneo han caducado. Vuelve a escanear y luego " "elimina." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Conserva al menos una copia de cada juego. No se eliminó nada." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1077,60 +1081,60 @@ msgstr "" "Configura la ruta de trofeos de RPCS3 en los ajustes de tu perfil para " "ver los trofeos" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Conjunto de trofeos no encontrado" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH no está configurado" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Ruta de trofeos no encontrada: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "Juego de PSN no encontrado" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Ya hay una sincronización en curso" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Juego no encontrado en tu lista de trofeos de PSN" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Juego no encontrado en PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "Falta npwr_id" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "Falta npwr_id o group_id" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "El nombre no puede estar vacío" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "La biblioteca PSNAWP no está instalada. Ejecuta: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "No se proporcionó ningún token NPSSO" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "No se pudo conectar con PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "El NPSSO de PSN no está configurado" diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo index c3b0271c960a9dab8123021373e8cacb556bee11..d03032a2b84902f59b36bb652d85abcaa664e5c6 100644 GIT binary patch delta 32 ocmX^9pY!;C&W0_F+vB**6%386j19F74B8LIF>XH?$Hb-x0NPFqd;kCd delta 32 ocmX^9pY!;C&W0_F+vB*56%0+S3=FgljM@*zF>XH?$Hb-x0NM--c>n+a diff --git a/translations/fr/LC_MESSAGES/messages.po b/translations/fr/LC_MESSAGES/messages.po index 37058622..4fa13a37 100644 --- a/translations/fr/LC_MESSAGES/messages.po +++ b/translations/fr/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-06-12 20:10+0200\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -42,8 +42,8 @@ msgstr "Configuration requise" msgid "Password change required" msgstr "Changement de mot de passe requis" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Le mot de passe doit comporter au moins 12 caractères" @@ -60,9 +60,9 @@ msgstr "Système introuvable ou sans jeux à succès" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Jeu introuvable" @@ -87,8 +87,8 @@ msgstr "RetroAchievements n'est pas configuré" msgid "Could not fetch achievement data" msgstr "Impossible de récupérer les données de succès" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Système introuvable" @@ -102,8 +102,8 @@ msgstr "Aucun jeu avec RetroAchievements dans ce système" msgid "Sync already in progress for %(name)s" msgstr "Synchronisation déjà en cours pour %(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Cette tâche appartient à un autre utilisateur" @@ -119,7 +119,7 @@ msgstr "Trop de tentatives de connexion. Veuillez réessayer plus tard." msgid "No user selected" msgstr "Aucun utilisateur sélectionné" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Utilisateur introuvable" @@ -153,64 +153,68 @@ msgstr "Rôle invalide" msgid "Username already exists" msgstr "Ce nom d'utilisateur existe déjà" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Impossible de supprimer votre propre compte" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Impossible de supprimer le dernier administrateur" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Vous n'êtes pas connecté" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Langue invalide" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Thème non valide" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Dossier de trophées RPCS3 non valide : %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Trop de tentatives. Veuillez réessayer plus tard." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Le nouveau mot de passe est requis" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Le mot de passe actuel est incorrect" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Aucun changement de mot de passe requis" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Aucun fichier fourni" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Aucun fichier sélectionné" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Type de fichier invalide. Autorisés : jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Fichier trop volumineux. La taille maximale est de 2 Mo" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -218,7 +222,7 @@ msgstr "" "L'envoi d'avatars nécessite Pillow ; veuillez demander à l'opérateur " "d'installer les dépendances du projet." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Fichier image invalide" @@ -235,17 +239,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Le disque bonus et le jeu parent doivent appartenir au même système" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Une erreur interne s'est produite" @@ -254,7 +258,7 @@ msgstr "Une erreur interne s'est produite" msgid "Missing bonus_id or parent_id" msgstr "bonus_id ou parent_id manquant" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "game_id manquant" @@ -377,22 +381,22 @@ msgstr "Ce jeu figure déjà dans votre liste de souhaits" msgid "Wishlist item not found" msgstr "Élément de la liste de souhaits introuvable" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Le nom de la manette est requis" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "La manette « %(name)s » existe déjà" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Manette introuvable" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Une autre manette nommée « %(name)s » existe déjà" @@ -479,9 +483,9 @@ msgstr "Échec de l'application des métadonnées." msgid "No fields to update" msgstr "Aucun champ à mettre à jour" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Aucun jeu sélectionné" @@ -509,13 +513,13 @@ msgstr "Type et valeur de filtre requis" msgid "Invalid filter type" msgstr "Type de filtre invalide" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "L'IA n'a renvoyé aucune donnée. Vérifiez votre clé API et les paramètres " "du fournisseur." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "Module de récupération par IA non disponible" @@ -533,7 +537,7 @@ msgstr "Nom de fichier invalide" msgid "ROM file not found on disk: %(path)s" msgstr "Fichier ROM introuvable sur le disque : %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "La destination se trouve en dehors du dossier racine ROM configuré" @@ -542,23 +546,23 @@ msgstr "La destination se trouve en dehors du dossier racine ROM configuré" msgid "Cannot rename: %(reason)s" msgstr "Impossible de renommer : %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Un fichier portant ce nom existe déjà" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Échec du renommage du fichier" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Aucune capture d'écran spécifiée" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Nom de fichier de capture d'écran invalide" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Capture d'écran introuvable" @@ -637,11 +641,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Disque suivant inséré" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Type d'image inconnu" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Aucun fournisseur d'IA configuré" @@ -720,19 +724,19 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" "La clé API Steam n'est pas configurée. Ajoutez-la dans Paramètres → Clés " "API." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "" "L'identifiant Steam n'est pas configuré. Ajoutez-le dans Paramètres → " "Clés API." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -741,7 +745,7 @@ msgstr "" "Impossible de résoudre l'URL personnalisée Steam « %(steam_id)s ». " "Utilisez plutôt votre identifiant Steam à 17 chiffres." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -749,35 +753,35 @@ msgstr "" "Aucun jeu trouvé. Vérifiez que votre profil Steam et les détails de vos " "jeux sont définis sur Public." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Impossible de créer l'entrée du système Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" "L'identifiant client Xbox n'est pas configuré. Ajoutez-le dans Paramètres" " → Clés API." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Les identifiants Xbox ne sont pas configurés. Ajoutez-les dans Paramètres" " → Clés API." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Échec de l'authentification Xbox. Veuillez reconnecter votre compte Xbox." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Aucun jeu trouvé dans l'historique des titres Xbox." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "Le NPSSO PSN n'est pas configuré. Ajoutez-le dans Paramètres → Clés API." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "Échec de la récupération de la liste des jeux PSN. Essayez d'abord de " @@ -788,11 +792,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "Le système %(name)s ne prend pas en charge RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Système inconnu" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" "Le chemin des ROM n'est pas configuré. Accédez à Paramètres → Chemins " @@ -802,8 +806,8 @@ msgstr "" msgid "Missing required parameters" msgstr "Paramètres requis manquants" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Chemin invalide" @@ -816,31 +820,31 @@ msgstr "Le dossier n'existe pas : %(folder_path)s" msgid "Missing game_id or new_name" msgstr "game_id ou new_name manquant" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Aucun chemin ROM défini" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "Fichier ROM introuvable" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "Les fichiers M3U doivent conserver l'extension .m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Un dossier portant le nouveau nom existe déjà" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Aucun titre récupéré disponible" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Le titre est vide après nettoyage" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Le nom de fichier généré contient des caractères invalides" @@ -849,7 +853,7 @@ msgstr "Le nom de fichier généré contient des caractères invalides" msgid "Invalid log file" msgstr "Fichier journal invalide" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Fichier introuvable" @@ -858,56 +862,56 @@ msgstr "Fichier introuvable" msgid "Log file not found" msgstr "Fichier journal introuvable" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "paramètres de récupération invalides : %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "clés API invalides : %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Aucune clé API TGDB configurée" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Aucun identifiant ScreenScraper configuré" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Réponse JSON invalide de ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Erreur lors de l'analyse de la réponse de l'API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper a répondu HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Aucune clé API RAWG configurée" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Fournisseur d'IA inconnu : %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Aucune réponse du fournisseur d'IA" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Erreur de connexion : %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Vérification du quota non prise en charge pour %(scraper)s" @@ -942,7 +946,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "%(field)s invalide : %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Échec de l'enregistrement des paramètres" @@ -950,12 +954,12 @@ msgstr "Échec de l'enregistrement des paramètres" msgid "Request body must be a JSON object" msgstr "Le corps de la requête doit être un objet JSON" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Clé de paramètre inconnue : %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Valeur invalide pour « %(key)s » : %(reason)s" @@ -993,87 +997,87 @@ msgstr "Identifiant de système requis" msgid "ES-DE scraper not available" msgstr "Récupérateur ES-DE non disponible" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Privilèges d'administrateur requis" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Les données de paramètres doivent être un objet" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Le chemin n'existe pas : %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Autorisation refusée" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Tâche introuvable" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Tâche introuvable ou non en cours d'exécution" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Tâche introuvable ou non en pause" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Chemin d'analyse invalide" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Aucun chemin fourni" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Aucun chemin d'archive fourni" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Chemin d'archive invalide" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Aucun fichier à supprimer" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Aucun chemin fourni" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Chemin invalide : %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman introuvable. Veuillez installer les outils MAME." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman introuvable" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Méthode de doublons inconnue : %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Ces résultats d'analyse ont expiré. Relancez l'analyse, puis supprimez." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Conservez au moins une copie de chaque jeu. Rien n'a été supprimé." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1081,60 +1085,60 @@ msgstr "" "Veuillez configurer le chemin des trophées RPCS3 dans les paramètres de " "votre profil pour afficher les trophées" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Ensemble de trophées introuvable" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH n'est pas configuré" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Chemin des trophées introuvable : %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "Jeu PSN introuvable" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Synchronisation déjà en cours" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Jeu introuvable dans votre liste de trophées PSN" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Jeu introuvable sur PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "npwr_id manquant" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "npwr_id ou group_id manquant" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Le nom ne peut pas être vide" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "Bibliothèque PSNAWP non installée. Exécutez : pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Aucun jeton NPSSO fourni" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Impossible de se connecter au PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "Le NPSSO PSN n'est pas configuré" diff --git a/translations/he/LC_MESSAGES/messages.mo b/translations/he/LC_MESSAGES/messages.mo index 2251088d272678e86f1b45bfbaf3067f117ce018..29c5e057bd085b50f686c3f6e0fc6a5a2f2c3877 100644 GIT binary patch delta 32 ocmeynkL&L~u7)j)+vB**6%386j19F74B8LIF>XH?$F#=*0NM2nJpcdz delta 32 ocmeynkL&L~u7)j)+vB*56%0+S3=FgljM@*zF>XH?$F#=*0NJw)IsgCw diff --git a/translations/he/LC_MESSAGES/messages.po b/translations/he/LC_MESSAGES/messages.po index 6136b692..6f8ee396 100644 --- a/translations/he/LC_MESSAGES/messages.po +++ b/translations/he/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-05 16:56+0200\n" "Last-Translator: FULL NAME \n" "Language: he\n" @@ -41,8 +41,8 @@ msgstr "נדרשת הגדרה ראשונית" msgid "Password change required" msgstr "נדרש שינוי סיסמה" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "הסיסמה חייבת להכיל לפחות 12 תווים" @@ -59,9 +59,9 @@ msgstr "המערכת לא נמצאה או שאין בה משחקים עם היש #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "המשחק לא נמצא" @@ -86,8 +86,8 @@ msgstr "RetroAchievements לא הוגדר" msgid "Could not fetch achievement data" msgstr "לא ניתן לאחזר נתוני הישגים" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "המערכת לא נמצאה" @@ -101,8 +101,8 @@ msgstr "אין משחקים עם RetroAchievements במערכת זו" msgid "Sync already in progress for %(name)s" msgstr "סנכרון כבר מתבצע עבור %(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "משימה זו שייכת למשתמש אחר" @@ -118,7 +118,7 @@ msgstr "יותר מדי ניסיונות התחברות. נסה שוב מאוח msgid "No user selected" msgstr "לא נבחר משתמש" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "המשתמש לא נמצא" @@ -150,70 +150,74 @@ msgstr "תפקיד לא חוקי" msgid "Username already exists" msgstr "שם המשתמש כבר קיים" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "לא ניתן למחוק את החשבון שלך" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "לא ניתן למחוק את משתמש המנהל האחרון" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "לא מחובר" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "אזור/שפה לא חוקיים" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "ערכת נושא לא חוקית" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "תיקיית גביעי RPCS3 לא חוקית: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "יותר מדי ניסיונות. נסה שוב מאוחר יותר." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "נדרשת סיסמה חדשה" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "הסיסמה הנוכחית שגויה" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "אין צורך בשינוי סיסמה" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "לא סופק קובץ" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "לא נבחר קובץ" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "סוג קובץ לא חוקי. מותר: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "הקובץ גדול מדי. הגודל המרבי הוא 2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." msgstr "העלאת תמונות פרופיל דורשת את Pillow; בקש מהמפעיל להתקין את דרישות הפרויקט." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "קובץ תמונה לא חוקי" @@ -230,17 +234,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "דיסק הבונוס ומשחק האב חייבים להיות באותה מערכת" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "אירעה שגיאה פנימית" @@ -249,7 +253,7 @@ msgstr "אירעה שגיאה פנימית" msgid "Missing bonus_id or parent_id" msgstr "חסר bonus_id או parent_id" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "חסר game_id" @@ -372,22 +376,22 @@ msgstr "המשחק כבר נמצא ברשימת המשאלות שלך" msgid "Wishlist item not found" msgstr "פריט רשימת המשאלות לא נמצא" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "נדרש שם בקר" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "הבקר \"%(name)s\" כבר קיים" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "הבקר לא נמצא" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "בקר אחר בשם \"%(name)s\" כבר קיים" @@ -470,9 +474,9 @@ msgstr "החלת המטא-נתונים נכשלה." msgid "No fields to update" msgstr "אין שדות לעדכון" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "לא נבחרו משחקים" @@ -500,11 +504,11 @@ msgstr "נדרשים סוג וערך סינון" msgid "Invalid filter type" msgstr "סוג סינון לא חוקי" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "ה-AI לא החזיר נתונים. בדוק את מפתח ה-API והגדרות הספק שלך." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "מודול מגרד ה-AI אינו זמין" @@ -522,7 +526,7 @@ msgstr "שם קובץ לא חוקי" msgid "ROM file not found on disk: %(path)s" msgstr "קובץ ה-ROM לא נמצא בדיסק: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "היעד נמצא מחוץ לתיקיית השורש המוגדרת של ה-ROM" @@ -531,23 +535,23 @@ msgstr "היעד נמצא מחוץ לתיקיית השורש המוגדרת של msgid "Cannot rename: %(reason)s" msgstr "לא ניתן לשנות שם: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "קובץ בשם זה כבר קיים" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "שינוי שם הקובץ נכשל" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "לא צוין צילום מסך" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "שם קובץ צילום מסך לא חוקי" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "צילום המסך לא נמצא" @@ -624,11 +628,11 @@ msgstr "" msgid "Next disk inserted" msgstr "הדיסק הבא הוכנס" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "סוג תמונה לא ידוע" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "לא הוגדר ספק AI" @@ -707,15 +711,15 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "מפתח ה-API של Steam לא הוגדר. הוסף אותו בהגדרות ← מפתחות API." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "מזהה ה-Steam לא הוגדר. הוסף אותו בהגדרות ← מפתחות API." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -724,37 +728,37 @@ msgstr "" "לא ניתן לפענח את כתובת ה-Steam המותאמת \"%(steam_id)s\". השתמש במזהה " "ה-Steam בן 17 הספרות במקום." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." msgstr "לא נמצאו משחקים. ודא שפרופיל ה-Steam ופרטי המשחקים שלך מוגדרים כציבוריים." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "לא ניתן ליצור רשומת מערכת Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "מזהה הלקוח של Xbox לא הוגדר. הוסף אותו בהגדרות ← מפתחות API." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "פרטי ההזדהות של Xbox לא הוגדרו. הוסף אותם בהגדרות ← מפתחות API." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "אימות Xbox נכשל. חבר מחדש את חשבון ה-Xbox שלך." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "לא נמצאו משחקים בהיסטוריית הכותרים של Xbox." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "ה-NPSSO של PSN לא הוגדר. הוסף אותו בהגדרות ← מפתחות API." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "אחזור רשימת המשחקים של PSN נכשל. נסה לסנכרן גביעים תחילה." @@ -763,11 +767,11 @@ msgstr "אחזור רשימת המשחקים של PSN נכשל. נסה לסנכ msgid "System %(name)s does not support RetroAchievements" msgstr "המערכת %(name)s אינה תומכת ב-RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "מערכת לא ידועה" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "נתיב ה-ROM לא הוגדר. עבור להגדרות ← נתיבים כדי להגדירו." @@ -775,8 +779,8 @@ msgstr "נתיב ה-ROM לא הוגדר. עבור להגדרות ← נתיבי msgid "Missing required parameters" msgstr "חסרים פרמטרים נדרשים" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "נתיב לא חוקי" @@ -789,31 +793,31 @@ msgstr "התיקייה אינה קיימת: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "חסר game_id או new_name" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "לא הוגדר נתיב ROM" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "קובץ ה-ROM לא נמצא" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "קבצי M3U חייבים לשמור על הסיומת .m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "תיקייה בשם החדש כבר קיימת" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "אין כותרת מגורדת זמינה" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "הכותרת ריקה לאחר הניקוי" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "שם הקובץ שנוצר מכיל תווים לא חוקיים" @@ -822,7 +826,7 @@ msgstr "שם הקובץ שנוצר מכיל תווים לא חוקיים" msgid "Invalid log file" msgstr "קובץ יומן לא חוקי" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "הקובץ לא נמצא" @@ -831,56 +835,56 @@ msgstr "הקובץ לא נמצא" msgid "Log file not found" msgstr "קובץ היומן לא נמצא" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "הגדרות מגרד לא חוקיות: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "מפתחות API לא חוקיים: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "לא הוגדר מפתח API של TGDB" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "לא הוגדרו פרטי הזדהות של ScreenScraper" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "תגובת JSON לא חוקית מ-ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "שגיאה בניתוח תגובת ה-API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper השיב HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "לא הוגדר מפתח API של RAWG" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "ספק AI לא ידוע: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "אין תגובה מספק ה-AI" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "שגיאת חיבור: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "בדיקת מכסה אינה נתמכת עבור %(scraper)s" @@ -911,7 +915,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "%(field)s לא חוקי: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "שמירת ההגדרות נכשלה" @@ -919,12 +923,12 @@ msgstr "שמירת ההגדרות נכשלה" msgid "Request body must be a JSON object" msgstr "גוף הבקשה חייב להיות אובייקט JSON" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "מפתח הגדרה לא ידוע: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "ערך לא חוקי עבור '%(key)s': %(reason)s" @@ -962,146 +966,146 @@ msgstr "נדרש מזהה מערכת" msgid "ES-DE scraper not available" msgstr "מגרד ES-DE אינו זמין" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "נדרשות הרשאות מנהל" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "מטען ההגדרות חייב להיות אובייקט" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "הנתיב אינו קיים: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "ההרשאה נדחתה" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "המשימה לא נמצאה" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "המשימה לא נמצאה או אינה פועלת" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "המשימה לא נמצאה או אינה מושהית" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "נתיב סריקה לא חוקי" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "לא סופק נתיב" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "לא סופק נתיב ארכיון" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "נתיב ארכיון לא חוקי" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "אין קבצים להסרה" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "לא סופקו נתיבים" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "נתיב לא חוקי: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman לא נמצא. התקן את כלי MAME." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman לא נמצא" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "שיטת כפילויות לא ידועה: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "תוצאות סריקה אלה פגו. סרוק שוב ואז מחק." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "השאר לפחות עותק אחד של כל משחק. דבר לא נמחק." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" msgstr "הגדר את נתיב הגביעים של RPCS3 בהגדרות הפרופיל שלך כדי לצפות בגביעים" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "מערך הגביעים לא נמצא" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH לא הוגדר" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "נתיב הגביעים לא נמצא: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "משחק ה-PSN לא נמצא" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "סנכרון כבר מתבצע" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "המשחק לא נמצא ברשימת גביעי ה-PSN שלך" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "המשחק לא נמצא ב-PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "חסר npwr_id" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "חסר npwr_id או group_id" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "השם אינו יכול להיות ריק" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "ספריית PSNAWP אינה מותקנת. הרץ: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "לא סופק אסימון NPSSO" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "לא ניתן להתחבר ל-PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "ה-NPSSO של PSN לא הוגדר" diff --git a/translations/id/LC_MESSAGES/messages.mo b/translations/id/LC_MESSAGES/messages.mo index 42de8b4be0e0aaee7117621b8d1ac608cf09a5a2..441b9fee55aadb24fb520dda59a28f088c2e3dc8 100644 GIT binary patch delta 28 kcmeygob%Ii&W0_F+vB**6%386j19F747MMPV>J8<0Hk&bc>n+a delta 28 kcmeygob%Ii&W0_F+vB*56%0+S3=FgljJ6+)V>J8<0Hi<)b^rhX diff --git a/translations/id/LC_MESSAGES/messages.po b/translations/id/LC_MESSAGES/messages.po index 5834a6d3..022c225b 100644 --- a/translations/id/LC_MESSAGES/messages.po +++ b/translations/id/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-05 16:56+0200\n" "Last-Translator: FULL NAME \n" "Language: id\n" @@ -41,8 +41,8 @@ msgstr "Penyiapan diperlukan" msgid "Password change required" msgstr "Penggantian kata sandi diperlukan" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Kata sandi harus terdiri dari minimal 12 karakter" @@ -59,9 +59,9 @@ msgstr "Sistem tidak ditemukan atau tidak memiliki game achievement" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Game tidak ditemukan" @@ -86,8 +86,8 @@ msgstr "RetroAchievements belum dikonfigurasi" msgid "Could not fetch achievement data" msgstr "Tidak dapat mengambil data achievement" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Sistem tidak ditemukan" @@ -101,8 +101,8 @@ msgstr "Tidak ada game dengan RetroAchievements di sistem ini" msgid "Sync already in progress for %(name)s" msgstr "Sinkronisasi sedang berlangsung untuk %(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Tugas itu milik pengguna lain" @@ -118,7 +118,7 @@ msgstr "Terlalu banyak percobaan login. Silakan coba lagi nanti." msgid "No user selected" msgstr "Tidak ada pengguna yang dipilih" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Pengguna tidak ditemukan" @@ -150,64 +150,68 @@ msgstr "Peran tidak valid" msgid "Username already exists" msgstr "Nama pengguna sudah ada" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Tidak dapat menghapus akun Anda sendiri" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Tidak dapat menghapus pengguna admin terakhir" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Belum login" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Locale tidak valid" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Tema tidak valid" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Folder trofi RPCS3 tidak valid: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Terlalu banyak percobaan. Silakan coba lagi nanti." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Kata sandi baru wajib diisi" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Kata sandi saat ini salah" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Perubahan kata sandi tidak diperlukan" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Tidak ada berkas yang diberikan" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Tidak ada berkas yang dipilih" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Jenis berkas tidak valid. Diizinkan: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Berkas terlalu besar. Ukuran maksimum adalah 2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -215,7 +219,7 @@ msgstr "" "Unggah avatar memerlukan Pillow; mohon minta operator untuk memasang " "requirements proyek." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Berkas gambar tidak valid" @@ -232,17 +236,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Disc bonus dan game induk harus berada di sistem yang sama" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Terjadi kesalahan internal" @@ -251,7 +255,7 @@ msgstr "Terjadi kesalahan internal" msgid "Missing bonus_id or parent_id" msgstr "bonus_id atau parent_id tidak ada" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "game_id tidak ada" @@ -374,22 +378,22 @@ msgstr "Game ini sudah ada di wishlist Anda" msgid "Wishlist item not found" msgstr "Item wishlist tidak ditemukan" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Nama kontroler wajib diisi" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Kontroler \"%(name)s\" sudah ada" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Kontroler tidak ditemukan" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Kontroler lain bernama \"%(name)s\" sudah ada" @@ -476,9 +480,9 @@ msgstr "Gagal menerapkan metadata." msgid "No fields to update" msgstr "Tidak ada kolom untuk diperbarui" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Tidak ada game yang dipilih" @@ -506,13 +510,13 @@ msgstr "Jenis dan nilai filter wajib diisi" msgid "Invalid filter type" msgstr "Jenis filter tidak valid" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "AI tidak mengembalikan data. Periksa kunci API dan pengaturan penyedia " "Anda." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "Modul scraper AI tidak tersedia" @@ -530,7 +534,7 @@ msgstr "Nama berkas tidak valid" msgid "ROM file not found on disk: %(path)s" msgstr "Berkas ROM tidak ditemukan di disk: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "Tujuan berada di luar root ROM yang dikonfigurasi" @@ -539,23 +543,23 @@ msgstr "Tujuan berada di luar root ROM yang dikonfigurasi" msgid "Cannot rename: %(reason)s" msgstr "Tidak dapat mengganti nama: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Berkas dengan nama tersebut sudah ada" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Gagal mengganti nama berkas" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Tidak ada screenshot yang ditentukan" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Nama berkas screenshot tidak valid" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Screenshot tidak ditemukan" @@ -634,11 +638,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Disk berikutnya dimasukkan" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Jenis gambar tidak dikenal" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Tidak ada penyedia AI yang dikonfigurasi" @@ -717,15 +721,15 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "Kunci API Steam belum dikonfigurasi. Tambahkan di Pengaturan → Kunci API." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "Steam ID belum dikonfigurasi. Tambahkan di Pengaturan → Kunci API." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -734,7 +738,7 @@ msgstr "" "Tidak dapat menyelesaikan URL vanity Steam \"%(steam_id)s\". Gunakan " "Steam ID 17 digit Anda sebagai gantinya." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -742,31 +746,31 @@ msgstr "" "Tidak ada game yang ditemukan. Pastikan profil Steam dan detail game Anda" " diatur ke Publik." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Tidak dapat membuat entri sistem Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "Xbox Client ID belum dikonfigurasi. Tambahkan di Pengaturan → Kunci API." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "Kredensial Xbox belum dikonfigurasi. Tambahkan di Pengaturan → Kunci API." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Autentikasi Xbox gagal. Silakan sambungkan kembali akun Xbox Anda." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Tidak ada game yang ditemukan dalam riwayat judul Xbox." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "PSN NPSSO belum dikonfigurasi. Tambahkan di Pengaturan → Kunci API." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "Gagal mengambil daftar game PSN. Coba sinkronkan trofi terlebih dahulu." @@ -775,11 +779,11 @@ msgstr "Gagal mengambil daftar game PSN. Coba sinkronkan trofi terlebih dahulu." msgid "System %(name)s does not support RetroAchievements" msgstr "Sistem %(name)s tidak mendukung RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Sistem tidak dikenal" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "Jalur ROM belum dikonfigurasi. Buka Pengaturan → Jalur untuk mengaturnya." @@ -787,8 +791,8 @@ msgstr "Jalur ROM belum dikonfigurasi. Buka Pengaturan → Jalur untuk mengaturn msgid "Missing required parameters" msgstr "Parameter yang diperlukan tidak ada" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Jalur tidak valid" @@ -801,31 +805,31 @@ msgstr "Folder tidak ada: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "game_id atau new_name tidak ada" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Jalur ROM belum diatur" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "Berkas ROM tidak ditemukan" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "Berkas M3U harus mempertahankan ekstensi .m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Folder dengan nama baru tersebut sudah ada" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Tidak ada judul hasil scrape yang tersedia" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Judul kosong setelah sanitasi" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Nama berkas yang dihasilkan mengandung karakter tidak valid" @@ -834,7 +838,7 @@ msgstr "Nama berkas yang dihasilkan mengandung karakter tidak valid" msgid "Invalid log file" msgstr "Berkas log tidak valid" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Berkas tidak ditemukan" @@ -843,56 +847,56 @@ msgstr "Berkas tidak ditemukan" msgid "Log file not found" msgstr "Berkas log tidak ditemukan" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "pengaturan scraper tidak valid: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "kunci API tidak valid: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Tidak ada kunci API TheGamesDB yang dikonfigurasi" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Tidak ada kredensial ScreenScraper yang dikonfigurasi" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Respons JSON dari ScreenScraper tidak valid" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Kesalahan saat mengurai respons API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper menjawab HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Tidak ada kunci API RAWG yang dikonfigurasi" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Penyedia AI tidak dikenal: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Tidak ada respons dari penyedia AI" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Kesalahan koneksi: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Pemeriksaan kuota tidak didukung untuk %(scraper)s" @@ -925,7 +929,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "%(field)s tidak valid: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Gagal menyimpan pengaturan" @@ -933,12 +937,12 @@ msgstr "Gagal menyimpan pengaturan" msgid "Request body must be a JSON object" msgstr "Isi permintaan harus berupa objek JSON" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Kunci pengaturan tidak dikenal: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Nilai tidak valid untuk '%(key)s': %(reason)s" @@ -976,87 +980,87 @@ msgstr "ID sistem diperlukan" msgid "ES-DE scraper not available" msgstr "Scraper ES-DE tidak tersedia" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Diperlukan hak akses administrator" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Payload pengaturan harus berupa objek" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Jalur tidak ada: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Izin ditolak" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Tugas tidak ditemukan" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Tugas tidak ditemukan atau tidak sedang berjalan" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Tugas tidak ditemukan atau tidak sedang dijeda" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Jalur pemindaian tidak valid" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Tidak ada jalur yang diberikan" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Tidak ada jalur arsip yang diberikan" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Jalur arsip tidak valid" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Tidak ada berkas untuk dihapus" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Tidak ada jalur yang diberikan" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Jalur tidak valid: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman tidak ditemukan. Silakan pasang MAME tools." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman tidak ditemukan" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Metode duplikat tidak dikenal: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Hasil pindaian ini sudah kedaluwarsa. Pindai lagi, lalu hapus." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Sisakan setidaknya satu salinan setiap game. Tidak ada yang dihapus." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1064,60 +1068,60 @@ msgstr "" "Silakan konfigurasikan Jalur Trofi RPCS3 Anda di pengaturan profil untuk " "melihat trofi" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Set trofi tidak ditemukan" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH belum dikonfigurasi" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Jalur trofi tidak ditemukan: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "Game PSN tidak ditemukan" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Sinkronisasi sedang berlangsung" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Game tidak ditemukan dalam daftar trofi PSN Anda" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Game tidak ditemukan di PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "npwr_id tidak ada" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "npwr_id atau group_id tidak ada" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Nama tidak boleh kosong" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "Pustaka PSNAWP belum terpasang. Jalankan: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Token NPSSO tidak diberikan" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Tidak dapat terhubung ke PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "PSN NPSSO belum dikonfigurasi" diff --git a/translations/it/LC_MESSAGES/messages.mo b/translations/it/LC_MESSAGES/messages.mo index abb8b8215c471b0bba561554bf934835cde65661..4587ce3399c14b407243f8d79db4b3e56a887b06 100644 GIT binary patch delta 32 ocmZ2~lXLw|&W0_F+vB**6%386j19F74B8LIF>XH?$MlXD0L_pK)Bpeg delta 32 ocmZ2~lXLw|&W0_F+vB*56%0+S3=FgljM@*zF>XH?$MlXD0L@Md(EtDd diff --git a/translations/it/LC_MESSAGES/messages.po b/translations/it/LC_MESSAGES/messages.po index 129aad06..3005cdf5 100644 --- a/translations/it/LC_MESSAGES/messages.po +++ b/translations/it/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-06-12 20:10+0200\n" "Last-Translator: FULL NAME \n" "Language: it\n" @@ -42,8 +42,8 @@ msgstr "Configurazione richiesta" msgid "Password change required" msgstr "Cambio password richiesto" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "La password deve contenere almeno 12 caratteri" @@ -60,9 +60,9 @@ msgstr "Sistema non trovato o senza giochi con obiettivi" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Gioco non trovato" @@ -87,8 +87,8 @@ msgstr "RetroAchievements non configurato" msgid "Could not fetch achievement data" msgstr "Impossibile recuperare i dati degli obiettivi" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Sistema non trovato" @@ -102,8 +102,8 @@ msgstr "Nessun gioco con RetroAchievements in questo sistema" msgid "Sync already in progress for %(name)s" msgstr "Sincronizzazione già in corso per %(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Questa operazione appartiene a un altro utente" @@ -119,7 +119,7 @@ msgstr "Troppi tentativi di accesso. Riprova più tardi." msgid "No user selected" msgstr "Nessun utente selezionato" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Utente non trovato" @@ -153,64 +153,68 @@ msgstr "Ruolo non valido" msgid "Username already exists" msgstr "Il nome utente esiste già" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Impossibile eliminare il proprio account" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Impossibile eliminare l'ultimo utente amministratore" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Non hai effettuato l'accesso" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Lingua non valida" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Tema non valido" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Cartella trofei RPCS3 non valida: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Troppi tentativi. Riprova più tardi." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "La nuova password è obbligatoria" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "La password attuale non è corretta" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Cambio password non necessario" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Nessun file fornito" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Nessun file selezionato" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Tipo di file non valido. Consentiti: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "File troppo grande. La dimensione massima è 2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -218,7 +222,7 @@ msgstr "" "Il caricamento degli avatar richiede Pillow; chiedi all'operatore di " "installare i requisiti del progetto." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "File immagine non valido" @@ -235,17 +239,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Il disco bonus e il gioco principale devono essere nello stesso sistema" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Si è verificato un errore interno" @@ -254,7 +258,7 @@ msgstr "Si è verificato un errore interno" msgid "Missing bonus_id or parent_id" msgstr "bonus_id o parent_id mancante" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "game_id mancante" @@ -377,22 +381,22 @@ msgstr "Questo gioco è già nella tua lista dei desideri" msgid "Wishlist item not found" msgstr "Elemento della lista dei desideri non trovato" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Il nome del controller è obbligatorio" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Il controller \"%(name)s\" esiste già" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Controller non trovato" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Esiste già un altro controller di nome \"%(name)s\"" @@ -479,9 +483,9 @@ msgstr "Applicazione dei metadati non riuscita." msgid "No fields to update" msgstr "Nessun campo da aggiornare" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Nessun gioco selezionato" @@ -509,13 +513,13 @@ msgstr "Tipo di filtro e valore obbligatori" msgid "Invalid filter type" msgstr "Tipo di filtro non valido" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "L'AI non ha restituito dati. Controlla la tua chiave API e le " "impostazioni del provider." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "Modulo scraper AI non disponibile" @@ -533,7 +537,7 @@ msgstr "Nome file non valido" msgid "ROM file not found on disk: %(path)s" msgstr "File ROM non trovato su disco: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "La destinazione è al di fuori della cartella ROM configurata" @@ -542,23 +546,23 @@ msgstr "La destinazione è al di fuori della cartella ROM configurata" msgid "Cannot rename: %(reason)s" msgstr "Impossibile rinominare: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Esiste già un file con quel nome" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Impossibile rinominare il file" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Nessuno screenshot specificato" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Nome file dello screenshot non valido" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Screenshot non trovato" @@ -637,11 +641,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Disco successivo inserito" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Tipo di immagine sconosciuto" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Nessun provider AI configurato" @@ -720,15 +724,15 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "Chiave API Steam non configurata. Aggiungila in Impostazioni → Chiavi API." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "Steam ID non configurato. Aggiungilo in Impostazioni → Chiavi API." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -737,7 +741,7 @@ msgstr "" "Impossibile risolvere l'URL personalizzato Steam \"%(steam_id)s\". Usa " "invece il tuo Steam ID a 17 cifre." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -745,31 +749,31 @@ msgstr "" "Nessun gioco trovato. Verifica che il tuo profilo Steam e i dettagli dei " "giochi siano impostati come pubblici." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Impossibile creare la voce di sistema Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "Xbox Client ID non configurato. Aggiungilo in Impostazioni → Chiavi API." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "Credenziali Xbox non configurate. Aggiungile in Impostazioni → Chiavi API." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Autenticazione Xbox non riuscita. Riconnetti il tuo account Xbox." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Nessun gioco trovato nella cronologia dei titoli Xbox." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "NPSSO PSN non configurato. Aggiungilo in Impostazioni → Chiavi API." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "Impossibile recuperare la lista dei giochi PSN. Prova prima a " @@ -780,11 +784,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "Il sistema %(name)s non supporta RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Sistema sconosciuto" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" "Percorso ROM non configurato. Vai in Impostazioni → Percorsi per " @@ -794,8 +798,8 @@ msgstr "" msgid "Missing required parameters" msgstr "Parametri obbligatori mancanti" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Percorso non valido" @@ -808,31 +812,31 @@ msgstr "La cartella non esiste: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "game_id o new_name mancante" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Nessun percorso ROM impostato" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "File ROM non trovato" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "I file M3U devono mantenere l'estensione .m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Esiste già una cartella con il nuovo nome" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Nessun titolo recuperato disponibile" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Il titolo è vuoto dopo la sanificazione" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Il nome file generato contiene caratteri non validi" @@ -841,7 +845,7 @@ msgstr "Il nome file generato contiene caratteri non validi" msgid "Invalid log file" msgstr "File di log non valido" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "File non trovato" @@ -850,56 +854,56 @@ msgstr "File non trovato" msgid "Log file not found" msgstr "File di log non trovato" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "impostazioni dello scraper non valide: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "chiavi API non valide: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Nessuna chiave API TGDB configurata" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Nessuna credenziale ScreenScraper configurata" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Risposta JSON non valida da ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Errore nell'analisi della risposta API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper ha risposto HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Nessuna chiave API RAWG configurata" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Provider AI sconosciuto: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Nessuna risposta dal provider AI" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Errore di connessione: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Controllo del limite non supportato per %(scraper)s" @@ -932,7 +936,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "%(field)s non valido: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Impossibile salvare le impostazioni" @@ -940,12 +944,12 @@ msgstr "Impossibile salvare le impostazioni" msgid "Request body must be a JSON object" msgstr "Il corpo della richiesta deve essere un oggetto JSON" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Chiave di impostazione sconosciuta: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Valore non valido per '%(key)s': %(reason)s" @@ -983,89 +987,89 @@ msgstr "ID sistema obbligatorio" msgid "ES-DE scraper not available" msgstr "Scraper ES-DE non disponibile" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Privilegi di amministratore richiesti" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Il payload delle impostazioni deve essere un oggetto" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Il percorso non esiste: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Autorizzazione negata" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Attività non trovata" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Attività non trovata o non in esecuzione" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Attività non trovata o non in pausa" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Percorso di scansione non valido" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Nessun percorso fornito" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Nessun percorso di archivio fornito" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Percorso di archivio non valido" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Nessun file da rimuovere" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Nessun percorso fornito" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Percorso non valido: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman non trovato. Installa gli strumenti MAME." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman non trovato" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Metodo duplicati sconosciuto: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "" "Questi risultati della scansione sono scaduti. Esegui di nuovo la " "scansione, poi elimina." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Conserva almeno una copia di ogni gioco. Non è stato eliminato nulla." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1073,60 +1077,60 @@ msgstr "" "Configura il percorso dei trofei RPCS3 nelle impostazioni del profilo per" " visualizzare i trofei" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Set di trofei non trovato" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH non configurato" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Percorso dei trofei non trovato: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "Gioco PSN non trovato" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Sincronizzazione già in corso" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Gioco non trovato nella tua lista dei trofei PSN" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Gioco non trovato su PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "npwr_id mancante" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "npwr_id o group_id mancante" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Il nome non può essere vuoto" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "Libreria PSNAWP non installata. Esegui: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Nessun token NPSSO fornito" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Impossibile connettersi a PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "NPSSO PSN non configurato" diff --git a/translations/ja/LC_MESSAGES/messages.mo b/translations/ja/LC_MESSAGES/messages.mo index 868307babfe9ae693e2502514633daf3b6dcb730..b0a5d3ecd584f580cea79b8d90f29f0a5d9a9df6 100644 GIT binary patch delta 32 ocmX@}jO)xZu7)j)+vB**6%386j19F74B8LIF>XH?$J8AH0M{}M4gdfE delta 32 ocmX@}jO)xZu7)j)+vB*56%0+S3=FgljM@*zF>XH?$J8AH0M_sf3jhEB diff --git a/translations/ja/LC_MESSAGES/messages.po b/translations/ja/LC_MESSAGES/messages.po index 0d929607..5a9de964 100644 --- a/translations/ja/LC_MESSAGES/messages.po +++ b/translations/ja/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-06-12 20:10+0200\n" "Last-Translator: FULL NAME \n" "Language: ja\n" @@ -40,8 +40,8 @@ msgstr "セットアップが必要です" msgid "Password change required" msgstr "パスワードの変更が必要です" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "パスワードは12文字以上で入力してください" @@ -58,9 +58,9 @@ msgstr "システムが見つからないか、実績対応ゲームがありま #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "ゲームが見つかりません" @@ -85,8 +85,8 @@ msgstr "RetroAchievementsが設定されていません" msgid "Could not fetch achievement data" msgstr "実績データを取得できませんでした" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "システムが見つかりません" @@ -100,8 +100,8 @@ msgstr "このシステムにRetroAchievements対応のゲームがありませ msgid "Sync already in progress for %(name)s" msgstr "%(name)sの同期はすでに実行中です" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "そのジョブは別のユーザーのものです" @@ -117,7 +117,7 @@ msgstr "ログイン試行回数が多すぎます。しばらくしてから再 msgid "No user selected" msgstr "ユーザーが選択されていません" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "ユーザーが見つかりません" @@ -149,70 +149,74 @@ msgstr "ロールが無効です" msgid "Username already exists" msgstr "そのユーザー名はすでに存在します" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "自分のアカウントは削除できません" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "最後の管理者ユーザーは削除できません" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "ログインしていません" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "ロケールが無効です" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "無効なテーマです" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "無効な RPCS3 トロフィーフォルダーです: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "試行回数が多すぎます。しばらくしてから再度お試しください。" -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "新しいパスワードが必要です" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "現在のパスワードが正しくありません" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "パスワードの変更は不要です" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "ファイルが指定されていません" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "ファイルが選択されていません" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "ファイル形式が無効です。許可される形式: jpg、png、gif、webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "ファイルが大きすぎます。最大サイズは2MBです" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." msgstr "アバターのアップロードにはPillowが必要です。運用担当者にプロジェクトの依存パッケージのインストールを依頼してください。" -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "画像ファイルが無効です" @@ -229,17 +233,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "ボーナスディスクと親ゲームは同じシステム内にある必要があります" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "内部エラーが発生しました" @@ -248,7 +252,7 @@ msgstr "内部エラーが発生しました" msgid "Missing bonus_id or parent_id" msgstr "bonus_idまたはparent_idがありません" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "game_idがありません" @@ -371,22 +375,22 @@ msgstr "このゲームはすでにウィッシュリストに登録されてい msgid "Wishlist item not found" msgstr "ウィッシュリストの項目が見つかりません" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "コントローラー名が必要です" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "コントローラー「%(name)s」はすでに存在します" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "コントローラーが見つかりません" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "「%(name)s」という名前の別のコントローラーがすでに存在します" @@ -467,9 +471,9 @@ msgstr "メタデータの適用に失敗しました。" msgid "No fields to update" msgstr "更新するフィールドがありません" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "ゲームが選択されていません" @@ -497,11 +501,11 @@ msgstr "フィルターの種類と値が必要です" msgid "Invalid filter type" msgstr "フィルターの種類が無効です" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "AIからデータが返されませんでした。API キーとプロバイダー設定を確認してください。" -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "AIスクレイパーモジュールが利用できません" @@ -519,7 +523,7 @@ msgstr "ファイル名が無効です" msgid "ROM file not found on disk: %(path)s" msgstr "ディスク上にROMファイルが見つかりません: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "保存先が設定済みのROMルートの外にあります" @@ -528,23 +532,23 @@ msgstr "保存先が設定済みのROMルートの外にあります" msgid "Cannot rename: %(reason)s" msgstr "名前を変更できません: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "その名前のファイルはすでに存在します" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "ファイル名の変更に失敗しました" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "スクリーンショットが指定されていません" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "スクリーンショットのファイル名が無効です" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "スクリーンショットが見つかりません" @@ -621,11 +625,11 @@ msgstr "" msgid "Next disk inserted" msgstr "次のディスクを挿入しました" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "不明な画像タイプです" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "AIプロバイダーが設定されていません" @@ -704,52 +708,52 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "Steam API キーが設定されていません。「設定」→「API キー」で追加してください。" -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "Steam IDが設定されていません。「設定」→「API キー」で追加してください。" -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " "Steam ID instead." msgstr "Steamのカスタム URL「%(steam_id)s」を解決できませんでした。代わりに17桁のSteam IDを使用してください。" -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." msgstr "ゲームが見つかりません。Steamのプロフィールとゲーム詳細が「公開」に設定されているか確認してください。" -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Windowsシステムのエントリを作成できませんでした" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "Xbox Client IDが設定されていません。「設定」→「API キー」で追加してください。" -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "Xboxの認証情報が設定されていません。「設定」→「API キー」で追加してください。" -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Xboxの認証に失敗しました。Xboxアカウントを再接続してください。" -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Xboxのタイトル履歴にゲームが見つかりません。" -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "PSN NPSSOが設定されていません。「設定」→「API キー」で追加してください。" -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "PSNのゲームリストの取得に失敗しました。まずトロフィーを同期してみてください。" @@ -758,11 +762,11 @@ msgstr "PSNのゲームリストの取得に失敗しました。まずトロフ msgid "System %(name)s does not support RetroAchievements" msgstr "システム%(name)sはRetroAchievementsに対応していません" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "不明なシステムです" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "ROMパスが設定されていません。「設定」→「パス」で設定してください。" @@ -770,8 +774,8 @@ msgstr "ROMパスが設定されていません。「設定」→「パス」で msgid "Missing required parameters" msgstr "必須パラメーターがありません" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "パスが無効です" @@ -784,31 +788,31 @@ msgstr "フォルダーが存在しません: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "game_idまたはnew_nameがありません" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "ROMパスが設定されていません" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "ROMファイルが見つかりません" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "M3Uファイルは.m3u拡張子を保持する必要があります" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "その新しい名前のフォルダーはすでに存在します" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "スクレイピングされたタイトルがありません" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "サニタイズ後にタイトルが空になりました" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "生成されたファイル名に無効な文字が含まれています" @@ -817,7 +821,7 @@ msgstr "生成されたファイル名に無効な文字が含まれています msgid "Invalid log file" msgstr "ログファイルが無効です" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "ファイルが見つかりません" @@ -826,56 +830,56 @@ msgstr "ファイルが見つかりません" msgid "Log file not found" msgstr "ログファイルが見つかりません" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "スクレイパー設定が無効です: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "API キーが無効です: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "TGDB API キーが設定されていません" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "ScreenScraperの認証情報が設定されていません" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "ScreenScraperからのJSON応答が無効です" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "API応答の解析中にエラーが発生しました" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper が HTTP %(code)s を返しました" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "RAWG API キーが設定されていません" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "不明なAIプロバイダーです: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "AIプロバイダーからの応答がありません" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "接続エラー: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "%(scraper)sでは利用上限の確認はサポートされていません" @@ -906,7 +910,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "%(field)sが無効です: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "設定の保存に失敗しました" @@ -914,12 +918,12 @@ msgstr "設定の保存に失敗しました" msgid "Request body must be a JSON object" msgstr "リクエスト本文はJSONオブジェクトである必要があります" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "不明な設定キーです: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "「%(key)s」の値が無効です: %(reason)s" @@ -957,146 +961,146 @@ msgstr "システムIDが必要です" msgid "ES-DE scraper not available" msgstr "ES-DEスクレイパーが利用できません" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "管理者権限が必要です" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "設定ペイロードはオブジェクトである必要があります" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "パスが存在しません: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "アクセスが拒否されました" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "タスクが見つかりません" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "タスクが見つからないか、実行中ではありません" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "タスクが見つからないか、一時停止していません" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "スキャンパスが無効です" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "パスが指定されていません" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "アーカイブパスが指定されていません" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "アーカイブパスが無効です" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "削除するファイルがありません" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "パスが指定されていません" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "パスが無効です: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdmanが見つかりません。MAMEツールをインストールしてください。" -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdmanが見つかりません" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "不明な重複検出方法です: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "このスキャン結果は期限切れです。再度スキャンしてから削除してください。" -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "各ゲームのコピーを少なくとも1つ残してください。何も削除されませんでした。" -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" msgstr "トロフィーを表示するには、プロフィール設定でRPCS3トロフィーパスを設定してください" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "トロフィーセットが見つかりません" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATHが設定されていません" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "トロフィーパスが見つかりません: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "PSNゲームが見つかりません" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "同期はすでに実行中です" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "PSNのトロフィーリストにゲームが見つかりません" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "PSNにゲームが見つかりません" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "npwr_idがありません" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "npwr_idまたはgroup_idがありません" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "名前を空にすることはできません" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "PSNAWPライブラリがインストールされていません。次を実行してください: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "NPSSOトークンが指定されていません" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "PSN に接続できませんでした" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "PSN NPSSOが設定されていません" diff --git a/translations/ko/LC_MESSAGES/messages.mo b/translations/ko/LC_MESSAGES/messages.mo index 5576a7a475670397d628fa24857ddabf177f5fd2..a9448723ee07eb448d0af49bc0b8225eb2c79fe5 100644 GIT binary patch delta 32 ocmbQY{_Bbwc1w$h%V?%8NgZ6`QjN1>!F@-4t0J3rlZ~y=R delta 32 ocmbQY{_BbwM1w&IS0|RXXqxOSwjN1>!F@-4t0J1O&Z2$lO diff --git a/translations/ko/LC_MESSAGES/messages.po b/translations/ko/LC_MESSAGES/messages.po index 2633de92..21b56a3c 100644 --- a/translations/ko/LC_MESSAGES/messages.po +++ b/translations/ko/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-06-30 14:26+0200\n" "Last-Translator: FULL NAME \n" "Language: ko\n" @@ -41,8 +41,8 @@ msgstr "설정이 필요합니다" msgid "Password change required" msgstr "비밀번호 변경이 필요합니다" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "비밀번호는 최소 12자 이상이어야 합니다" @@ -59,9 +59,9 @@ msgstr "시스템을 찾을 수 없거나 업적이 있는 게임이 없습니 #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "게임을 찾을 수 없습니다" @@ -86,8 +86,8 @@ msgstr "RetroAchievements가 설정되지 않았습니다" msgid "Could not fetch achievement data" msgstr "업적 데이터를 가져올 수 없습니다" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "시스템을 찾을 수 없습니다" @@ -101,8 +101,8 @@ msgstr "이 시스템에 RetroAchievements가 있는 게임이 없습니다" msgid "Sync already in progress for %(name)s" msgstr "%(name)s에 대한 동기화가 이미 진행 중입니다" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "해당 작업은 다른 사용자의 것입니다" @@ -118,7 +118,7 @@ msgstr "로그인 시도가 너무 많습니다. 잠시 후 다시 시도하세 msgid "No user selected" msgstr "선택된 사용자가 없습니다" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "사용자를 찾을 수 없습니다" @@ -150,70 +150,74 @@ msgstr "잘못된 역할입니다" msgid "Username already exists" msgstr "사용자 이름이 이미 존재합니다" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "자신의 계정은 삭제할 수 없습니다" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "마지막 관리자 사용자는 삭제할 수 없습니다" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "로그인되어 있지 않습니다" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "잘못된 로캘입니다" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "잘못된 테마입니다" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "잘못된 RPCS3 트로피 폴더: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "시도가 너무 많습니다. 잠시 후 다시 시도하세요." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "새 비밀번호가 필요합니다" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "현재 비밀번호가 올바르지 않습니다" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "비밀번호 변경이 필요하지 않습니다" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "제공된 파일이 없습니다" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "선택된 파일이 없습니다" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "잘못된 파일 형식입니다. 허용 형식: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "파일이 너무 큽니다. 최대 크기는 2MB입니다" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." msgstr "아바타 업로드에는 Pillow가 필요합니다. 운영자에게 프로젝트 요구 사항 설치를 요청하세요." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "잘못된 이미지 파일입니다" @@ -230,17 +234,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "보너스 디스크와 상위 게임은 같은 시스템에 있어야 합니다" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "내부 오류가 발생했습니다" @@ -249,7 +253,7 @@ msgstr "내부 오류가 발생했습니다" msgid "Missing bonus_id or parent_id" msgstr "bonus_id 또는 parent_id가 누락되었습니다" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "game_id가 누락되었습니다" @@ -372,22 +376,22 @@ msgstr "이 게임은 이미 위시리스트에 있습니다" msgid "Wishlist item not found" msgstr "위시리스트 항목을 찾을 수 없습니다" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "컨트롤러 이름은 필수입니다" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "컨트롤러 \"%(name)s\"이(가) 이미 존재합니다" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "컨트롤러를 찾을 수 없습니다" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "\"%(name)s\"(이)라는 다른 컨트롤러가 이미 존재합니다" @@ -468,9 +472,9 @@ msgstr "메타데이터 적용에 실패했습니다." msgid "No fields to update" msgstr "업데이트할 필드가 없습니다" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "선택된 게임이 없습니다" @@ -498,11 +502,11 @@ msgstr "필터 유형과 값이 필요합니다" msgid "Invalid filter type" msgstr "잘못된 필터 유형입니다" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "AI가 데이터를 반환하지 않았습니다. API 키와 공급자 설정을 확인하세요." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "AI 스크레이퍼 모듈을 사용할 수 없습니다" @@ -520,7 +524,7 @@ msgstr "잘못된 파일 이름입니다" msgid "ROM file not found on disk: %(path)s" msgstr "디스크에서 ROM 파일을 찾을 수 없습니다: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "대상이 설정된 ROM 루트 외부에 있습니다" @@ -529,23 +533,23 @@ msgstr "대상이 설정된 ROM 루트 외부에 있습니다" msgid "Cannot rename: %(reason)s" msgstr "이름을 바꿀 수 없습니다: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "해당 이름의 파일이 이미 존재합니다" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "파일 이름 변경에 실패했습니다" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "지정된 스크린샷이 없습니다" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "잘못된 스크린샷 파일 이름입니다" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "스크린샷을 찾을 수 없습니다" @@ -622,11 +626,11 @@ msgstr "" msgid "Next disk inserted" msgstr "다음 디스크를 넣었습니다" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "알 수 없는 이미지 유형" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "설정된 AI 공급자가 없습니다" @@ -705,52 +709,52 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "Steam API 키가 설정되지 않았습니다. 설정 → API 키에서 추가하세요." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "Steam ID가 설정되지 않았습니다. 설정 → API 키에서 추가하세요." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " "Steam ID instead." msgstr "Steam 커스텀 URL \"%(steam_id)s\"을(를) 확인할 수 없습니다. 대신 17자리 Steam ID를 사용하세요." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." msgstr "게임을 찾을 수 없습니다. Steam 프로필과 게임 세부 정보가 공개로 설정되어 있는지 확인하세요." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Windows 시스템 항목을 생성할 수 없습니다" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "Xbox 클라이언트 ID가 설정되지 않았습니다. 설정 → API 키에서 추가하세요." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "Xbox 자격 증명이 설정되지 않았습니다. 설정 → API 키에서 추가하세요." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Xbox 인증에 실패했습니다. Xbox 계정을 다시 연결하세요." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Xbox 타이틀 기록에서 게임을 찾을 수 없습니다." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "PSN NPSSO가 설정되지 않았습니다. 설정 → API 키에서 추가하세요." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "PSN 게임 목록을 가져오지 못했습니다. 먼저 트로피를 동기화해 보세요." @@ -759,11 +763,11 @@ msgstr "PSN 게임 목록을 가져오지 못했습니다. 먼저 트로피를 msgid "System %(name)s does not support RetroAchievements" msgstr "시스템 %(name)s은(는) RetroAchievements를 지원하지 않습니다" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "알 수 없는 시스템입니다" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "ROM 경로가 설정되지 않았습니다. 설정 → 경로에서 설정하세요." @@ -771,8 +775,8 @@ msgstr "ROM 경로가 설정되지 않았습니다. 설정 → 경로에서 설 msgid "Missing required parameters" msgstr "필수 매개변수가 누락되었습니다" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "잘못된 경로입니다" @@ -785,31 +789,31 @@ msgstr "폴더가 존재하지 않습니다: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "game_id 또는 new_name이 누락되었습니다" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "설정된 ROM 경로가 없습니다" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "ROM 파일을 찾을 수 없습니다" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "M3U 파일은 .m3u 확장자를 유지해야 합니다" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "새 이름의 폴더가 이미 존재합니다" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "사용 가능한 스크래핑된 제목이 없습니다" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "정리 후 제목이 비어 있습니다" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "생성된 파일 이름에 잘못된 문자가 포함되어 있습니다" @@ -818,7 +822,7 @@ msgstr "생성된 파일 이름에 잘못된 문자가 포함되어 있습니다 msgid "Invalid log file" msgstr "잘못된 로그 파일입니다" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "파일을 찾을 수 없습니다" @@ -827,56 +831,56 @@ msgstr "파일을 찾을 수 없습니다" msgid "Log file not found" msgstr "로그 파일을 찾을 수 없습니다" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "잘못된 스크레이퍼 설정입니다: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "잘못된 API 키입니다: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "설정된 TGDB API 키가 없습니다" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "설정된 ScreenScraper 자격 증명이 없습니다" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "ScreenScraper에서 잘못된 JSON 응답을 받았습니다" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "API 응답 파싱 중 오류가 발생했습니다" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper가 HTTP %(code)s 응답을 반환했습니다" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "설정된 RAWG API 키가 없습니다" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "알 수 없는 AI 공급자: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "AI 공급자로부터 응답이 없습니다" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "연결 오류: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "%(scraper)s에 대해서는 허용량 확인이 지원되지 않습니다" @@ -907,7 +911,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "잘못된 %(field)s: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "설정 저장에 실패했습니다" @@ -915,12 +919,12 @@ msgstr "설정 저장에 실패했습니다" msgid "Request body must be a JSON object" msgstr "요청 본문은 JSON 객체여야 합니다" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "알 수 없는 설정 키: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "'%(key)s'에 대한 잘못된 값: %(reason)s" @@ -958,146 +962,146 @@ msgstr "시스템 ID가 필요합니다" msgid "ES-DE scraper not available" msgstr "ES-DE 스크레이퍼를 사용할 수 없습니다" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "관리자 권한이 필요합니다" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "설정 페이로드는 객체여야 합니다" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "경로가 존재하지 않습니다: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "권한이 거부되었습니다" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "작업을 찾을 수 없습니다" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "작업을 찾을 수 없거나 실행 중이 아닙니다" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "작업을 찾을 수 없거나 일시정지되어 있지 않습니다" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "잘못된 스캔 경로입니다" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "제공된 경로가 없습니다" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "제공된 아카이브 경로가 없습니다" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "잘못된 아카이브 경로입니다" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "제거할 파일이 없습니다" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "제공된 경로가 없습니다" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "잘못된 경로: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman을 찾을 수 없습니다. MAME 도구를 설치하세요." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman을 찾을 수 없습니다" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "알 수 없는 중복 검사 방법: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "이 스캔 결과는 만료되었습니다. 다시 스캔한 후 삭제하세요." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "각 게임의 사본을 하나 이상 남겨 두세요. 아무것도 삭제되지 않았습니다." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" msgstr "트로피를 보려면 프로필 설정에서 RPCS3 트로피 경로를 설정하세요" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "트로피 세트를 찾을 수 없습니다" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH가 설정되지 않았습니다" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "트로피 경로를 찾을 수 없습니다: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "PSN 게임을 찾을 수 없습니다" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "동기화가 이미 진행 중입니다" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "PSN 트로피 목록에서 게임을 찾을 수 없습니다" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "PSN에서 게임을 찾을 수 없습니다" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "npwr_id가 누락되었습니다" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "npwr_id 또는 group_id가 누락되었습니다" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "이름은 비워 둘 수 없습니다" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "PSNAWP 라이브러리가 설치되어 있지 않습니다. 실행: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "제공된 NPSSO 토큰이 없습니다" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "PSN에 연결할 수 없습니다" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "PSN NPSSO가 설정되지 않았습니다" diff --git a/translations/nb/LC_MESSAGES/messages.mo b/translations/nb/LC_MESSAGES/messages.mo index d635b1e2509ba281ab318cf257b87c8021c9699b..b5870b906e19b58c9b6696752ac75652f65cdc2b 100644 GIT binary patch delta 28 kcmaDnm-F#l&W0_F+vB**6%386j19F747MMPV^sP80G~PvGXMYp delta 28 kcmaDnm-F#l&W0_F+vB*56%0+S3=FgljJ6+)V^sP80G|X3FaQ7m diff --git a/translations/nb/LC_MESSAGES/messages.po b/translations/nb/LC_MESSAGES/messages.po index add52a22..8db746eb 100644 --- a/translations/nb/LC_MESSAGES/messages.po +++ b/translations/nb/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-05 16:56+0200\n" "Last-Translator: FULL NAME \n" "Language: nb\n" @@ -42,8 +42,8 @@ msgstr "Oppsett kreves" msgid "Password change required" msgstr "Passordbytte kreves" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Passordet må være minst 12 tegn" @@ -60,9 +60,9 @@ msgstr "System ikke funnet eller har ingen spill med prestasjoner" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Spill ikke funnet" @@ -87,8 +87,8 @@ msgstr "RetroAchievements er ikke konfigurert" msgid "Could not fetch achievement data" msgstr "Kunne ikke hente prestasjonsdata" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "System ikke funnet" @@ -102,8 +102,8 @@ msgstr "Ingen spill med RetroAchievements i dette systemet" msgid "Sync already in progress for %(name)s" msgstr "Synkronisering pågår allerede for %(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Den jobben tilhører en annen bruker" @@ -119,7 +119,7 @@ msgstr "For mange innloggingsforsøk. Prøv igjen senere." msgid "No user selected" msgstr "Ingen bruker valgt" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Bruker ikke funnet" @@ -153,64 +153,68 @@ msgstr "Ugyldig rolle" msgid "Username already exists" msgstr "Brukernavnet finnes allerede" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Kan ikke slette din egen konto" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Kan ikke slette den siste administratorbrukeren" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Ikke logget inn" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Ugyldig språkinnstilling" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Ugyldig tema" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Ugyldig RPCS3-trofémappe: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "For mange forsøk. Prøv igjen senere." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Nytt passord kreves" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Nåværende passord er feil" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Passordendring er ikke nødvendig" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Ingen fil angitt" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Ingen fil valgt" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Ugyldig filtype. Tillatt: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Filen er for stor. Maksimal størrelse er 2 MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -218,7 +222,7 @@ msgstr "" "Opplasting av avatar krever Pillow; be operatøren om å installere " "prosjektets avhengigheter." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Ugyldig bildefil" @@ -235,17 +239,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Bonusplaten og det overordnede spillet må være i samme system" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "En intern feil oppstod" @@ -254,7 +258,7 @@ msgstr "En intern feil oppstod" msgid "Missing bonus_id or parent_id" msgstr "Mangler bonus_id eller parent_id" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "Mangler game_id" @@ -377,22 +381,22 @@ msgstr "Dette spillet er allerede på ønskelisten din" msgid "Wishlist item not found" msgstr "Ønskelisteelementet ble ikke funnet" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Kontrollernavn kreves" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Kontrolleren «%(name)s» finnes allerede" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Kontrolleren ble ikke funnet" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "En annen kontroller med navnet «%(name)s» finnes allerede" @@ -479,9 +483,9 @@ msgstr "Kunne ikke bruke metadata." msgid "No fields to update" msgstr "Ingen felt å oppdatere" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Ingen spill valgt" @@ -509,13 +513,13 @@ msgstr "Filtertype og verdi kreves" msgid "Invalid filter type" msgstr "Ugyldig filtertype" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "AI-en returnerte ingen data. Kontroller API-nøkkelen og " "leverandørinnstillingene." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "AI-skrapemodulen er ikke tilgjengelig" @@ -533,7 +537,7 @@ msgstr "Ugyldig filnavn" msgid "ROM file not found on disk: %(path)s" msgstr "ROM-filen ble ikke funnet på disk: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "Målet er utenfor den konfigurerte ROM-rotmappen" @@ -542,23 +546,23 @@ msgstr "Målet er utenfor den konfigurerte ROM-rotmappen" msgid "Cannot rename: %(reason)s" msgstr "Kan ikke gi nytt navn: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "En fil med det navnet finnes allerede" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Kunne ikke endre navn på filen" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Ingen skjermbilde angitt" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Ugyldig filnavn for skjermbilde" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Skjermbildet ble ikke funnet" @@ -637,11 +641,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Neste disk satt inn" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Ukjent bildetype" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Ingen AI-leverandør konfigurert" @@ -720,19 +724,19 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" "Steam API-nøkkel er ikke konfigurert. Legg den til under Innstillinger → " "API-nøkler." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "" "Steam-ID er ikke konfigurert. Legg den til under Innstillinger → API-" "nøkler." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -741,7 +745,7 @@ msgstr "" "Kunne ikke finne Steam-tilpasset URL «%(steam_id)s». Bruk din 17-sifrede " "Steam-ID i stedet." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -749,37 +753,37 @@ msgstr "" "Ingen spill funnet. Kontroller at Steam-profilen og spilldetaljene dine " "er satt til Offentlig." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Kunne ikke opprette systemoppføring for Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" "Xbox Client ID er ikke konfigurert. Legg den til under Innstillinger → " "API-nøkler." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Xbox-legitimasjon er ikke konfigurert. Legg den til under Innstillinger →" " API-nøkler." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Xbox-autentisering mislyktes. Koble til Xbox-kontoen din på nytt." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Ingen spill funnet i Xbox-tittelhistorikken." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "" "PSN NPSSO er ikke konfigurert. Legg den til under Innstillinger → API-" "nøkler." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "Kunne ikke hente PSN-spillisten. Prøv å synkronisere trofeer først." @@ -788,11 +792,11 @@ msgstr "Kunne ikke hente PSN-spillisten. Prøv å synkronisere trofeer først." msgid "System %(name)s does not support RetroAchievements" msgstr "Systemet %(name)s støtter ikke RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Ukjent system" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "ROM-sti er ikke konfigurert. Gå til Innstillinger → Stier for å angi den." @@ -800,8 +804,8 @@ msgstr "ROM-sti er ikke konfigurert. Gå til Innstillinger → Stier for å angi msgid "Missing required parameters" msgstr "Mangler nødvendige parametere" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Ugyldig sti" @@ -814,31 +818,31 @@ msgstr "Mappen finnes ikke: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "Mangler game_id eller new_name" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Ingen ROM-sti angitt" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "ROM-filen ble ikke funnet" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "M3U-filer må beholde .m3u-filendelsen" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "En mappe med det nye navnet finnes allerede" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Ingen skrapet tittel tilgjengelig" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Tittelen er tom etter sanering" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Det genererte filnavnet inneholder ugyldige tegn" @@ -847,7 +851,7 @@ msgstr "Det genererte filnavnet inneholder ugyldige tegn" msgid "Invalid log file" msgstr "Ugyldig loggfil" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Filen ble ikke funnet" @@ -856,56 +860,56 @@ msgstr "Filen ble ikke funnet" msgid "Log file not found" msgstr "Loggfilen ble ikke funnet" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "ugyldige skrapeinnstillinger: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "ugyldige API-nøkler: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Ingen TGDB API-nøkkel konfigurert" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Ingen ScreenScraper-legitimasjon konfigurert" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Ugyldig JSON-svar fra ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Feil ved tolkning av API-svar" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper svarte HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Ingen RAWG API-nøkkel konfigurert" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Ukjent AI-leverandør: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Ingen respons fra AI-leverandøren" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Tilkoblingsfeil: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Kvotesjekk støttes ikke for %(scraper)s" @@ -936,7 +940,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "Ugyldig %(field)s: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Kunne ikke lagre innstillinger" @@ -944,12 +948,12 @@ msgstr "Kunne ikke lagre innstillinger" msgid "Request body must be a JSON object" msgstr "Forespørselsinnholdet må være et JSON-objekt" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Ukjent innstillingsnøkkel: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Ugyldig verdi for «%(key)s»: %(reason)s" @@ -987,146 +991,146 @@ msgstr "System-ID kreves" msgid "ES-DE scraper not available" msgstr "ES-DE-skraper er ikke tilgjengelig" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Administratorrettigheter kreves" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Innstillingsdataene må være et objekt" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Stien finnes ikke: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Tilgang nektet" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Oppgaven ble ikke funnet" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Oppgaven ble ikke funnet eller kjører ikke" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Oppgaven ble ikke funnet eller er ikke satt på pause" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Ugyldig skannesti" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Ingen sti angitt" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Ingen arkivsti angitt" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Ugyldig arkivsti" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Ingen filer å fjerne" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Ingen stier angitt" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Ugyldig sti: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman ble ikke funnet. Installer MAME-verktøyene." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman ble ikke funnet" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Ukjent duplikatmetode: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Disse skanneresultatene er utløpt. Skann på nytt, og slett deretter." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Behold minst én kopi av hvert spill. Ingenting ble slettet." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" msgstr "Konfigurer RPCS3-trofésti i profilinnstillingene dine for å se trofeer" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Trofésettet ble ikke funnet" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH er ikke konfigurert" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Trofésti ikke funnet: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "PSN-spillet ble ikke funnet" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Synkronisering pågår allerede" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Spillet ble ikke funnet i PSN-trofélisten din" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Spillet ble ikke funnet på PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "Mangler npwr_id" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "Mangler npwr_id eller group_id" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Navnet kan ikke være tomt" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "PSNAWP-biblioteket er ikke installert. Kjør: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Ingen NPSSO-token angitt" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Kunne ikke koble til PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "PSN NPSSO er ikke konfigurert" diff --git a/translations/nl/LC_MESSAGES/messages.mo b/translations/nl/LC_MESSAGES/messages.mo index 50912a314a55c1c2b26e5d71e5391c6fcf47287c..7a5810f5cc50096427e3da39f8b0bb94d34cf674 100644 GIT binary patch delta 31 ncmX@Rm-Fmi&W0_F+vB**6%386j19F74B8LIF-|`i$HW5wz~~Ce delta 31 ncmX@Rm-Fmi&W0_F+vB*56%0+S3=FgljM@*zF-|`i$HW5wz^Mwx diff --git a/translations/nl/LC_MESSAGES/messages.po b/translations/nl/LC_MESSAGES/messages.po index 68894b45..f82aa97e 100644 --- a/translations/nl/LC_MESSAGES/messages.po +++ b/translations/nl/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-05 16:56+0200\n" "Last-Translator: FULL NAME \n" "Language: nl\n" @@ -42,8 +42,8 @@ msgstr "Installatie vereist" msgid "Password change required" msgstr "Wachtwoord wijzigen vereist" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Wachtwoord moet minimaal 12 tekens bevatten" @@ -60,9 +60,9 @@ msgstr "Systeem niet gevonden of heeft geen games met prestaties" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Game niet gevonden" @@ -87,8 +87,8 @@ msgstr "RetroAchievements niet geconfigureerd" msgid "Could not fetch achievement data" msgstr "Kon prestatiegegevens niet ophalen" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Systeem niet gevonden" @@ -102,8 +102,8 @@ msgstr "Geen games met RetroAchievements in dit systeem" msgid "Sync already in progress for %(name)s" msgstr "Synchronisatie al bezig voor %(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Die taak is van een andere gebruiker" @@ -119,7 +119,7 @@ msgstr "Te veel inlogpogingen. Probeer het later opnieuw." msgid "No user selected" msgstr "Geen gebruiker geselecteerd" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Gebruiker niet gevonden" @@ -153,64 +153,68 @@ msgstr "Ongeldige rol" msgid "Username already exists" msgstr "Gebruikersnaam bestaat al" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Je kunt je eigen account niet verwijderen" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Kan de laatste beheerder niet verwijderen" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Niet ingelogd" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Ongeldige taalinstelling" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Ongeldig thema" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Ongeldige RPCS3-trofeeënmap: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Te veel pogingen. Probeer het later opnieuw." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Nieuw wachtwoord is vereist" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Huidig wachtwoord is onjuist" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Wachtwoordwijziging niet vereist" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Geen bestand opgegeven" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Geen bestand geselecteerd" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Ongeldig bestandstype. Toegestaan: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Bestand te groot. Maximale grootte is 2 MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -218,7 +222,7 @@ msgstr "" "Avatar-uploads vereisen Pillow; vraag de beheerder om de projectvereisten" " te installeren." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Ongeldig afbeeldingsbestand" @@ -235,17 +239,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Bonusschijf en hoofdgame moeten in hetzelfde systeem staan" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Er is een interne fout opgetreden" @@ -254,7 +258,7 @@ msgstr "Er is een interne fout opgetreden" msgid "Missing bonus_id or parent_id" msgstr "bonus_id of parent_id ontbreekt" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "game_id ontbreekt" @@ -377,22 +381,22 @@ msgstr "Deze game staat al op je verlanglijst" msgid "Wishlist item not found" msgstr "Verlanglijst-item niet gevonden" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Controllernaam is vereist" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Controller \"%(name)s\" bestaat al" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Controller niet gevonden" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Er bestaat al een andere controller met de naam \"%(name)s\"" @@ -479,9 +483,9 @@ msgstr "Toepassen van metadata mislukt." msgid "No fields to update" msgstr "Geen velden om bij te werken" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Geen games geselecteerd" @@ -509,13 +513,13 @@ msgstr "Filtertype en waarde vereist" msgid "Invalid filter type" msgstr "Ongeldig filtertype" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "AI heeft geen gegevens teruggegeven. Controleer je API-sleutel en " "providerinstellingen." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "AI-scrapermodule niet beschikbaar" @@ -533,7 +537,7 @@ msgstr "Ongeldige bestandsnaam" msgid "ROM file not found on disk: %(path)s" msgstr "ROM-bestand niet gevonden op schijf: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "Bestemming ligt buiten de geconfigureerde ROM-hoofdmap" @@ -542,23 +546,23 @@ msgstr "Bestemming ligt buiten de geconfigureerde ROM-hoofdmap" msgid "Cannot rename: %(reason)s" msgstr "Kan niet hernoemen: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Er bestaat al een bestand met die naam" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Bestand hernoemen mislukt" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Geen screenshot opgegeven" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Ongeldige screenshot-bestandsnaam" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Screenshot niet gevonden" @@ -637,11 +641,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Volgende schijf geplaatst" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Onbekend afbeeldingstype" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Geen AI-provider geconfigureerd" @@ -720,19 +724,19 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" "Steam API-sleutel niet geconfigureerd. Voeg deze toe bij Instellingen → " "API-sleutels." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "" "Steam-ID niet geconfigureerd. Voeg deze toe bij Instellingen → API-" "sleutels." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -741,7 +745,7 @@ msgstr "" "Kon Steam vanity-URL \"%(steam_id)s\" niet herleiden. Gebruik in plaats " "daarvan je 17-cijferige Steam-ID." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -749,37 +753,37 @@ msgstr "" "Geen games gevonden. Controleer of je Steam-profiel en gamegegevens op " "Openbaar staan." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Kon Windows-systeeminvoer niet aanmaken" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" "Xbox Client-ID niet geconfigureerd. Voeg deze toe bij Instellingen → API-" "sleutels." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Xbox-inloggegevens niet geconfigureerd. Voeg deze toe bij Instellingen → " "API-sleutels." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Xbox-authenticatie mislukt. Koppel je Xbox-account opnieuw." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Geen games gevonden in Xbox-titelgeschiedenis." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "" "PSN NPSSO niet geconfigureerd. Voeg deze toe bij Instellingen → API-" "sleutels." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "Ophalen van PSN-gamelijst mislukt. Probeer eerst trofeeën te " @@ -790,11 +794,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "Systeem %(name)s ondersteunt geen RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Onbekend systeem" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" "ROM-pad niet geconfigureerd. Ga naar Instellingen → Paden om dit in te " @@ -804,8 +808,8 @@ msgstr "" msgid "Missing required parameters" msgstr "Vereiste parameters ontbreken" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Ongeldig pad" @@ -818,31 +822,31 @@ msgstr "Map bestaat niet: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "game_id of new_name ontbreekt" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Geen ROM-pad ingesteld" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "ROM-bestand niet gevonden" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "M3U-bestanden moeten de extensie .m3u behouden" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Er bestaat al een map met de nieuwe naam" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Geen gescrapete titel beschikbaar" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Titel is leeg na opschoning" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Gegenereerde bestandsnaam bevat ongeldige tekens" @@ -851,7 +855,7 @@ msgstr "Gegenereerde bestandsnaam bevat ongeldige tekens" msgid "Invalid log file" msgstr "Ongeldig logbestand" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Bestand niet gevonden" @@ -860,56 +864,56 @@ msgstr "Bestand niet gevonden" msgid "Log file not found" msgstr "Logbestand niet gevonden" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "ongeldige scraperinstellingen: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "ongeldige API-sleutels: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Geen TGDB API-sleutel geconfigureerd" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Geen ScreenScraper-inloggegevens geconfigureerd" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Ongeldige JSON-reactie van ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Fout bij verwerken van API-reactie" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper antwoordde met HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Geen RAWG API-sleutel geconfigureerd" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Onbekende AI-provider: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Geen reactie van AI-provider" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Verbindingsfout: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Quotacontrole niet ondersteund voor %(scraper)s" @@ -942,7 +946,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "Ongeldig(e) %(field)s: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Opslaan van instellingen mislukt" @@ -950,12 +954,12 @@ msgstr "Opslaan van instellingen mislukt" msgid "Request body must be a JSON object" msgstr "Aanvraagtekst moet een JSON-object zijn" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Onbekende instellingssleutel: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Ongeldige waarde voor '%(key)s': %(reason)s" @@ -993,87 +997,87 @@ msgstr "Systeem-ID vereist" msgid "ES-DE scraper not available" msgstr "ES-DE-scraper niet beschikbaar" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Beheerdersrechten vereist" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Instellingen-payload moet een object zijn" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Pad bestaat niet: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Toegang geweigerd" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Taak niet gevonden" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Taak niet gevonden of draait niet" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Taak niet gevonden of niet gepauzeerd" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Ongeldig scanpad" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Geen pad opgegeven" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Geen archiefpad opgegeven" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Ongeldig archiefpad" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Geen bestanden om te verwijderen" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Geen paden opgegeven" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Ongeldig pad: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman niet gevonden. Installeer de MAME-tools." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman niet gevonden" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Onbekende duplicaatmethode: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Deze scanresultaten zijn verlopen. Scan opnieuw en verwijder daarna." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Houd van elke game ten minste één kopie. Er is niets verwijderd." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1081,60 +1085,60 @@ msgstr "" "Configureer je RPCS3-trofeeënpad in je profielinstellingen om trofeeën te" " bekijken" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Trofeeënset niet gevonden" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH niet geconfigureerd" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Trofeeënpad niet gevonden: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "PSN-game niet gevonden" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Synchronisatie al bezig" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Game niet gevonden in je PSN-trofeeënlijst" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Game niet gevonden op PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "npwr_id ontbreekt" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "npwr_id of group_id ontbreekt" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Naam mag niet leeg zijn" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "PSNAWP-bibliotheek niet geïnstalleerd. Voer uit: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Geen NPSSO-token opgegeven" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Kon geen verbinding maken met PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "PSN NPSSO niet geconfigureerd" diff --git a/translations/pl/LC_MESSAGES/messages.mo b/translations/pl/LC_MESSAGES/messages.mo index 824b2577aedd779ceeaed979fcb7278adf5fcc15..bbbd6decaaaf931fe641ec33b48ff94fc46656d7 100644 GIT binary patch delta 32 ocmdmVg>%yt&W0_F+vB**6%386j19F74B8LIF>XH?$5hA-0L&x{Q2+n{ delta 32 ocmdmVg>%yt&W0_F+vB*56%0+S3=FgljM@*zF>XH?$5hA-0L$VFP5=M^ diff --git a/translations/pl/LC_MESSAGES/messages.po b/translations/pl/LC_MESSAGES/messages.po index b3773e78..afeb40c4 100644 --- a/translations/pl/LC_MESSAGES/messages.po +++ b/translations/pl/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-05 16:56+0200\n" "Last-Translator: FULL NAME \n" "Language: pl\n" @@ -42,8 +42,8 @@ msgstr "Wymagana konfiguracja" msgid "Password change required" msgstr "Wymagana zmiana hasła" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Hasło musi mieć co najmniej 12 znaków" @@ -60,9 +60,9 @@ msgstr "Nie znaleziono systemu lub nie zawiera on gier z osiągnięciami" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Nie znaleziono gry" @@ -87,8 +87,8 @@ msgstr "RetroAchievements nie jest skonfigurowane" msgid "Could not fetch achievement data" msgstr "Nie udało się pobrać danych osiągnięć" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Nie znaleziono systemu" @@ -102,8 +102,8 @@ msgstr "Brak gier z RetroAchievements w tym systemie" msgid "Sync already in progress for %(name)s" msgstr "Synchronizacja dla %(name)s jest już w toku" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "To zadanie należy do innego użytkownika" @@ -119,7 +119,7 @@ msgstr "Zbyt wiele prób logowania. Spróbuj ponownie później." msgid "No user selected" msgstr "Nie wybrano użytkownika" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Nie znaleziono użytkownika" @@ -153,64 +153,68 @@ msgstr "Nieprawidłowa rola" msgid "Username already exists" msgstr "Nazwa użytkownika już istnieje" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Nie można usunąć własnego konta" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Nie można usunąć ostatniego administratora" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Nie jesteś zalogowany" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Nieprawidłowe ustawienia regionalne" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Nieprawidłowy motyw" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Nieprawidłowy folder trofeów RPCS3: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Zbyt wiele prób. Spróbuj ponownie później." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Nowe hasło jest wymagane" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Bieżące hasło jest nieprawidłowe" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Zmiana hasła nie jest wymagana" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Nie podano pliku" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Nie wybrano pliku" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Nieprawidłowy typ pliku. Dozwolone: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Plik jest zbyt duży. Maksymalny rozmiar to 2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -218,7 +222,7 @@ msgstr "" "Przesyłanie awatarów wymaga biblioteki Pillow; poproś administratora o " "zainstalowanie wymaganych zależności projektu." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Nieprawidłowy plik obrazu" @@ -235,17 +239,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Płyta bonusowa i gra nadrzędna muszą należeć do tego samego systemu" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Wystąpił błąd wewnętrzny" @@ -254,7 +258,7 @@ msgstr "Wystąpił błąd wewnętrzny" msgid "Missing bonus_id or parent_id" msgstr "Brak bonus_id lub parent_id" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "Brak game_id" @@ -377,22 +381,22 @@ msgstr "Ta gra już znajduje się na Twojej liście życzeń" msgid "Wishlist item not found" msgstr "Nie znaleziono pozycji na liście życzeń" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Nazwa kontrolera jest wymagana" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Kontroler „%(name)s” już istnieje" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Nie znaleziono kontrolera" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Inny kontroler o nazwie „%(name)s” już istnieje" @@ -481,9 +485,9 @@ msgstr "Nie udało się zastosować metadanych." msgid "No fields to update" msgstr "Brak pól do zaktualizowania" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Nie wybrano żadnych gier" @@ -511,11 +515,11 @@ msgstr "Wymagany jest typ i wartość filtra" msgid "Invalid filter type" msgstr "Nieprawidłowy typ filtra" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "AI nie zwróciło żadnych danych. Sprawdź klucz API i ustawienia dostawcy." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "Moduł scrapera AI jest niedostępny" @@ -533,7 +537,7 @@ msgstr "Nieprawidłowa nazwa pliku" msgid "ROM file not found on disk: %(path)s" msgstr "Nie znaleziono pliku ROM na dysku: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "" "Miejsce docelowe znajduje się poza skonfigurowanym katalogiem głównym " @@ -544,23 +548,23 @@ msgstr "" msgid "Cannot rename: %(reason)s" msgstr "Nie można zmienić nazwy: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Plik o tej nazwie już istnieje" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Nie udało się zmienić nazwy pliku" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Nie wskazano zrzutu ekranu" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Nieprawidłowa nazwa pliku zrzutu ekranu" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Nie znaleziono zrzutu ekranu" @@ -639,11 +643,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Włożono następny dysk" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Nieznany typ obrazu" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Nie skonfigurowano dostawcy AI" @@ -722,17 +726,17 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" "Klucz API Steam nie jest skonfigurowany. Dodaj go w Ustawienia → Klucze " "API." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "Steam ID nie jest skonfigurowane. Dodaj je w Ustawienia → Klucze API." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -741,7 +745,7 @@ msgstr "" "Nie udało się rozpoznać niestandardowego adresu URL Steam „%(steam_id)s”." " Użyj zamiast tego swojego 17-cyfrowego Steam ID." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -749,37 +753,37 @@ msgstr "" "Nie znaleziono gier. Sprawdź, czy Twój profil Steam i szczegóły gier są " "ustawione jako publiczne." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Nie udało się utworzyć wpisu systemu Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" "Xbox Client ID nie jest skonfigurowane. Dodaj je w Ustawienia → Klucze " "API." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Dane uwierzytelniające Xbox nie są skonfigurowane. Dodaj je w Ustawienia " "→ Klucze API." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Uwierzytelnianie Xbox nie powiodło się. Połącz ponownie swoje konto Xbox." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Nie znaleziono gier w historii tytułów Xbox." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "" "Token PSN NPSSO nie jest skonfigurowany. Dodaj go w Ustawienia → Klucze " "API." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "Nie udało się pobrać listy gier PSN. Spróbuj najpierw zsynchronizować " @@ -790,11 +794,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "System %(name)s nie obsługuje RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Nieznany system" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" "Ścieżka ROM-ów nie jest skonfigurowana. Przejdź do Ustawienia → Ścieżki, " @@ -804,8 +808,8 @@ msgstr "" msgid "Missing required parameters" msgstr "Brak wymaganych parametrów" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Nieprawidłowa ścieżka" @@ -818,31 +822,31 @@ msgstr "Folder nie istnieje: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "Brak game_id lub new_name" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Nie ustawiono ścieżki ROM-ów" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "Nie znaleziono pliku ROM" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "Pliki M3U muszą zachować rozszerzenie .m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Folder o tej nowej nazwie już istnieje" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Brak pobranego tytułu" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Tytuł jest pusty po oczyszczeniu" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Wygenerowana nazwa pliku zawiera nieprawidłowe znaki" @@ -851,7 +855,7 @@ msgstr "Wygenerowana nazwa pliku zawiera nieprawidłowe znaki" msgid "Invalid log file" msgstr "Nieprawidłowy plik dziennika" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Nie znaleziono pliku" @@ -860,56 +864,56 @@ msgstr "Nie znaleziono pliku" msgid "Log file not found" msgstr "Nie znaleziono pliku dziennika" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "nieprawidłowe ustawienia scrapera: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "nieprawidłowe klucze API: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Nie skonfigurowano klucza API TGDB" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Nie skonfigurowano danych uwierzytelniających ScreenScraper" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Nieprawidłowa odpowiedź JSON od ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Błąd podczas przetwarzania odpowiedzi API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper odpowiedział HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Nie skonfigurowano klucza API RAWG" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Nieznany dostawca AI: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Brak odpowiedzi od dostawcy AI" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Błąd połączenia: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Sprawdzanie limitu nie jest obsługiwane dla %(scraper)s" @@ -944,7 +948,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "Nieprawidłowe %(field)s: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Nie udało się zapisać ustawień" @@ -952,12 +956,12 @@ msgstr "Nie udało się zapisać ustawień" msgid "Request body must be a JSON object" msgstr "Treść żądania musi być obiektem JSON" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Nieznany klucz ustawienia: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Nieprawidłowa wartość dla „%(key)s”: %(reason)s" @@ -995,87 +999,87 @@ msgstr "Wymagany identyfikator systemu" msgid "ES-DE scraper not available" msgstr "Scraper ES-DE jest niedostępny" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Wymagane uprawnienia administratora" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Dane ustawień muszą być obiektem" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Ścieżka nie istnieje: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Odmowa dostępu" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Nie znaleziono zadania" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Nie znaleziono zadania lub nie jest ono uruchomione" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Nie znaleziono zadania lub nie jest ono wstrzymane" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Nieprawidłowa ścieżka skanowania" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Nie podano ścieżki" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Nie podano ścieżki archiwum" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Nieprawidłowa ścieżka archiwum" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Brak plików do usunięcia" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Nie podano ścieżek" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Nieprawidłowa ścieżka: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "Nie znaleziono chdman. Zainstaluj narzędzia MAME." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "Nie znaleziono chdman" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Nieznana metoda wykrywania duplikatów: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Te wyniki skanowania wygasły. Zeskanuj ponownie, a potem usuń." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Zachowaj co najmniej jedną kopię każdej gry. Nic nie zostało usunięte." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1083,60 +1087,60 @@ msgstr "" "Skonfiguruj ścieżkę trofeów RPCS3 w ustawieniach profilu, aby wyświetlić " "trofea" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Nie znaleziono zestawu trofeów" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "Nie skonfigurowano RPCS3_TROPHY_PATH" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Nie znaleziono ścieżki trofeów: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "Nie znaleziono gry PSN" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Synchronizacja jest już w toku" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Nie znaleziono gry na Twojej liście trofeów PSN" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Nie znaleziono gry na PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "Brak npwr_id" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "Brak npwr_id lub group_id" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Nazwa nie może być pusta" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "Biblioteka PSNAWP nie jest zainstalowana. Uruchom: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Nie podano tokenu NPSSO" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Nie można połączyć się z PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "Token PSN NPSSO nie jest skonfigurowany" diff --git a/translations/pt_BR/LC_MESSAGES/messages.mo b/translations/pt_BR/LC_MESSAGES/messages.mo index 944653c157482c17a0bf2ef3438f7dd332743579..ab7110e7e6e74b99593348f7c3ad151bf6725fd3 100644 GIT binary patch delta 32 ocmX?qi}U;~&W0_F+vB**6%386j19F74B8LIF>XH?$JECM0Mx7tw*UYD delta 32 ocmX?qi}U;~&W0_F+vB*56%0+S3=FgljM@*zF>XH?$JECM0Mu#=v;Y7A diff --git a/translations/pt_BR/LC_MESSAGES/messages.po b/translations/pt_BR/LC_MESSAGES/messages.po index 8b6674a3..86d4e67b 100644 --- a/translations/pt_BR/LC_MESSAGES/messages.po +++ b/translations/pt_BR/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-06-12 20:10+0200\n" "Last-Translator: FULL NAME \n" "Language: pt_BR\n" @@ -41,8 +41,8 @@ msgstr "Configuração necessária" msgid "Password change required" msgstr "Alteração de senha necessária" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "A senha deve ter pelo menos 12 caracteres" @@ -59,9 +59,9 @@ msgstr "Sistema não encontrado ou sem jogos com conquistas" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Jogo não encontrado" @@ -86,8 +86,8 @@ msgstr "RetroAchievements não configurado" msgid "Could not fetch achievement data" msgstr "Não foi possível obter os dados de conquistas" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Sistema não encontrado" @@ -101,8 +101,8 @@ msgstr "Nenhum jogo com RetroAchievements neste sistema" msgid "Sync already in progress for %(name)s" msgstr "Sincronização já em andamento para %(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Essa tarefa pertence a outro usuário" @@ -118,7 +118,7 @@ msgstr "Muitas tentativas de login. Tente novamente mais tarde." msgid "No user selected" msgstr "Nenhum usuário selecionado" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Usuário não encontrado" @@ -152,64 +152,68 @@ msgstr "Função inválida" msgid "Username already exists" msgstr "O nome de usuário já existe" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Não é possível excluir sua própria conta" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Não é possível excluir o último usuário administrador" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Não conectado" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Idioma inválido" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Tema inválido" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Pasta de troféus do RPCS3 inválida: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Muitas tentativas. Tente novamente mais tarde." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "A nova senha é obrigatória" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "A senha atual está incorreta" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Alteração de senha não necessária" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Nenhum arquivo fornecido" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Nenhum arquivo selecionado" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Tipo de arquivo inválido. Permitidos: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Arquivo muito grande. O tamanho máximo é 2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -217,7 +221,7 @@ msgstr "" "O envio de avatares requer o Pillow; peça ao operador para instalar os " "requisitos do projeto." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Arquivo de imagem inválido" @@ -234,17 +238,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "O disco bônus e o jogo principal devem estar no mesmo sistema" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Ocorreu um erro interno" @@ -253,7 +257,7 @@ msgstr "Ocorreu um erro interno" msgid "Missing bonus_id or parent_id" msgstr "bonus_id ou parent_id ausente" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "game_id ausente" @@ -376,22 +380,22 @@ msgstr "Este jogo já está na sua lista de desejos" msgid "Wishlist item not found" msgstr "Item da lista de desejos não encontrado" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "O nome do controle é obrigatório" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "O controle \"%(name)s\" já existe" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Controle não encontrado" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Já existe outro controle chamado \"%(name)s\"" @@ -476,9 +480,9 @@ msgstr "Falha ao aplicar os metadados." msgid "No fields to update" msgstr "Nenhum campo para atualizar" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Nenhum jogo selecionado" @@ -506,13 +510,13 @@ msgstr "Tipo e valor de filtro são obrigatórios" msgid "Invalid filter type" msgstr "Tipo de filtro inválido" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "A IA não retornou dados. Verifique sua chave de API e as configurações do" " provedor." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "Módulo de coleta por IA não disponível" @@ -530,7 +534,7 @@ msgstr "Nome de arquivo inválido" msgid "ROM file not found on disk: %(path)s" msgstr "Arquivo de ROM não encontrado no disco: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "O destino está fora da raiz de ROMs configurada" @@ -539,23 +543,23 @@ msgstr "O destino está fora da raiz de ROMs configurada" msgid "Cannot rename: %(reason)s" msgstr "Não é possível renomear: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Já existe um arquivo com esse nome" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Falha ao renomear o arquivo" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Nenhuma captura de tela especificada" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Nome de arquivo de captura de tela inválido" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Captura de tela não encontrada" @@ -634,11 +638,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Próximo disco inserido" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Tipo de imagem desconhecido" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Nenhum provedor de IA configurado" @@ -719,17 +723,17 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" "Chave de API do Steam não configurada. Adicione-a em Configurações → " "Chaves de API." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "Steam ID não configurado. Adicione-o em Configurações → Chaves de API." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -738,7 +742,7 @@ msgstr "" "Não foi possível resolver a URL personalizada do Steam \"%(steam_id)s\". " "Use seu Steam ID de 17 dígitos." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -746,35 +750,35 @@ msgstr "" "Nenhum jogo encontrado. Verifique se seu perfil do Steam e os detalhes " "dos jogos estão definidos como Públicos." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Não foi possível criar a entrada do sistema Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" "Xbox Client ID não configurado. Adicione-o em Configurações → Chaves de " "API." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Credenciais do Xbox não configuradas. Adicione-as em Configurações → " "Chaves de API." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Falha na autenticação do Xbox. Reconecte sua conta Xbox." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Nenhum jogo encontrado no histórico de títulos do Xbox." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "NPSSO da PSN não configurado. Adicione-o em Configurações → Chaves de API." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "Falha ao obter a lista de jogos da PSN. Tente sincronizar os troféus " @@ -785,11 +789,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "O sistema %(name)s não oferece suporte a RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Sistema desconhecido" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" "Caminho de ROMs não configurado. Vá em Configurações → Caminhos para " @@ -799,8 +803,8 @@ msgstr "" msgid "Missing required parameters" msgstr "Parâmetros obrigatórios ausentes" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Caminho inválido" @@ -813,31 +817,31 @@ msgstr "A pasta não existe: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "game_id ou new_name ausente" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Nenhum caminho de ROMs definido" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "Arquivo de ROM não encontrado" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "Arquivos M3U devem manter a extensão .m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Já existe uma pasta com o novo nome" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Nenhum título coletado disponível" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "O título está vazio após a sanitização" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "O nome de arquivo gerado contém caracteres inválidos" @@ -846,7 +850,7 @@ msgstr "O nome de arquivo gerado contém caracteres inválidos" msgid "Invalid log file" msgstr "Arquivo de log inválido" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Arquivo não encontrado" @@ -855,56 +859,56 @@ msgstr "Arquivo não encontrado" msgid "Log file not found" msgstr "Arquivo de log não encontrado" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "configurações de coleta inválidas: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "chaves de API inválidas: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Nenhuma chave de API do TGDB configurada" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Nenhuma credencial do ScreenScraper configurada" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Resposta JSON inválida do ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Erro ao analisar a resposta da API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "O ScreenScraper respondeu HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Nenhuma chave de API do RAWG configurada" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Provedor de IA desconhecido: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Nenhuma resposta do provedor de IA" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Erro de conexão: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Verificação de cota não suportada para %(scraper)s" @@ -937,7 +941,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "%(field)s inválido: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Falha ao salvar configurações" @@ -945,12 +949,12 @@ msgstr "Falha ao salvar configurações" msgid "Request body must be a JSON object" msgstr "O corpo da requisição deve ser um objeto JSON" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Chave de configuração desconhecida: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Valor inválido para '%(key)s': %(reason)s" @@ -988,89 +992,89 @@ msgstr "ID do sistema obrigatório" msgid "ES-DE scraper not available" msgstr "Coletor do ES-DE não disponível" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "São necessários privilégios de administrador" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Os dados de configurações devem ser um objeto" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "O caminho não existe: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Permissão negada" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Tarefa não encontrada" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Tarefa não encontrada ou não está em execução" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Tarefa não encontrada ou não está pausada" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Caminho de varredura inválido" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Nenhum caminho fornecido" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Nenhum caminho de arquivo compactado fornecido" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Caminho de arquivo compactado inválido" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Nenhum arquivo para remover" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Nenhum caminho fornecido" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Caminho inválido: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman não encontrado. Instale as ferramentas do MAME." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman não encontrado" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Método de duplicatas desconhecido: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "" "Estes resultados da verificação expiraram. Verifique novamente e depois " "exclua." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Mantenha pelo menos uma cópia de cada jogo. Nada foi excluído." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1078,60 +1082,60 @@ msgstr "" "Configure o caminho de troféus do RPCS3 nas configurações do seu perfil " "para ver os troféus" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Conjunto de troféus não encontrado" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH não configurado" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Caminho de troféus não encontrado: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "Jogo da PSN não encontrado" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Sincronização já em andamento" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Jogo não encontrado na sua lista de troféus da PSN" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Jogo não encontrado na PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "npwr_id ausente" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "npwr_id ou group_id ausente" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "O nome não pode estar vazio" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "Biblioteca PSNAWP não instalada. Execute: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Nenhum token NPSSO fornecido" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Não foi possível conectar à PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "NPSSO da PSN não configurado" diff --git a/translations/ru/LC_MESSAGES/messages.mo b/translations/ru/LC_MESSAGES/messages.mo index f03b0678e23e10e6c99381751e07a4348727595b..aba02ce3f62b1d2962d61305deb3e33c37c8257d 100644 GIT binary patch delta 32 ocmaF5lIQ74o`x-q+vB**6%386j19F74B8LIF>XH?$E30i0M8x^#Q*>R delta 32 ocmaF5lIQ74o`x-q+vB*56%0+S3=FgljM@*zF>XH?$E30i0M6VC!T\n" "Language: ru\n" @@ -42,8 +42,8 @@ msgstr "Требуется настройка" msgid "Password change required" msgstr "Требуется смена пароля" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Пароль должен содержать не менее 12 символов" @@ -60,9 +60,9 @@ msgstr "Система не найдена или не содержит игр #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Игра не найдена" @@ -87,8 +87,8 @@ msgstr "RetroAchievements не настроены" msgid "Could not fetch achievement data" msgstr "Не удалось получить данные о достижениях" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Система не найдена" @@ -102,8 +102,8 @@ msgstr "В этой системе нет игр с RetroAchievements" msgid "Sync already in progress for %(name)s" msgstr "Синхронизация для %(name)s уже выполняется" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Это задание принадлежит другому пользователю" @@ -119,7 +119,7 @@ msgstr "Слишком много попыток входа. Повторите msgid "No user selected" msgstr "Пользователь не выбран" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Пользователь не найден" @@ -153,64 +153,68 @@ msgstr "Недопустимая роль" msgid "Username already exists" msgstr "Такое имя пользователя уже существует" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Нельзя удалить собственную учётную запись" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Нельзя удалить последнего администратора" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Вы не вошли в систему" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Недопустимая локаль" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Недопустимая тема" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Недопустимая папка трофеев RPCS3: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Слишком много попыток. Повторите попытку позже." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Требуется новый пароль" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Текущий пароль неверен" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Смена пароля не требуется" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Файл не предоставлен" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Файл не выбран" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Недопустимый тип файла. Разрешены: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Файл слишком большой. Максимальный размер — 2 МБ" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -218,7 +222,7 @@ msgstr "" "Для загрузки аватаров требуется Pillow; попросите оператора установить " "зависимости проекта." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Недопустимый файл изображения" @@ -235,17 +239,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Бонусный диск и основная игра должны относиться к одной системе" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Произошла внутренняя ошибка" @@ -254,7 +258,7 @@ msgstr "Произошла внутренняя ошибка" msgid "Missing bonus_id or parent_id" msgstr "Отсутствует bonus_id или parent_id" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "Отсутствует game_id" @@ -377,22 +381,22 @@ msgstr "Эта игра уже в вашем списке желаемого" msgid "Wishlist item not found" msgstr "Элемент списка желаемого не найден" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Требуется название контроллера" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Контроллер «%(name)s» уже существует" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Контроллер не найден" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Другой контроллер с названием «%(name)s» уже существует" @@ -479,9 +483,9 @@ msgstr "Не удалось применить метаданные." msgid "No fields to update" msgstr "Нет полей для обновления" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Игры не выбраны" @@ -509,11 +513,11 @@ msgstr "Требуются тип и значение фильтра" msgid "Invalid filter type" msgstr "Недопустимый тип фильтра" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "ИИ не вернул данные. Проверьте API-ключ и настройки провайдера." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "Модуль ИИ-скрапера недоступен" @@ -531,7 +535,7 @@ msgstr "Недопустимое имя файла" msgid "ROM file not found on disk: %(path)s" msgstr "Файл ROM не найден на диске: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "Целевой путь находится вне настроенной корневой папки ROM" @@ -540,23 +544,23 @@ msgstr "Целевой путь находится вне настроенной msgid "Cannot rename: %(reason)s" msgstr "Не удалось переименовать: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Файл с таким именем уже существует" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Не удалось переименовать файл" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Скриншот не указан" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Недопустимое имя файла скриншота" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Скриншот не найден" @@ -635,11 +639,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Вставлен следующий диск" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Неизвестный тип изображения" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Провайдер ИИ не настроен" @@ -718,15 +722,15 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "API-ключ Steam не настроен. Добавьте его в Настройки → API-ключи." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "Steam ID не настроен. Добавьте его в Настройки → API-ключи." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -735,7 +739,7 @@ msgstr "" "Не удалось определить произвольный URL Steam «%(steam_id)s». Используйте " "вместо этого 17-значный Steam ID." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -743,33 +747,33 @@ msgstr "" "Игры не найдены. Убедитесь, что ваш профиль Steam и информация об играх " "открыты для всех (Public)." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Не удалось создать запись системы Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "Client ID Xbox не настроен. Добавьте его в Настройки → API-ключи." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "Учётные данные Xbox не настроены. Добавьте их в Настройки → API-ключи." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "" "Не удалось выполнить аутентификацию Xbox. Переподключите свою учётную " "запись Xbox." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "В истории игр Xbox не найдено ни одной игры." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "NPSSO PSN не настроен. Добавьте его в Настройки → API-ключи." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "Не удалось получить список игр PSN. Сначала попробуйте синхронизировать " @@ -780,11 +784,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "Система %(name)s не поддерживает RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Неизвестная система" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "Путь к ROM не настроен. Перейдите в Настройки → Пути, чтобы указать его." @@ -792,8 +796,8 @@ msgstr "Путь к ROM не настроен. Перейдите в Настр msgid "Missing required parameters" msgstr "Отсутствуют обязательные параметры" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Недопустимый путь" @@ -806,31 +810,31 @@ msgstr "Папка не существует: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "Отсутствует game_id или new_name" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Путь к ROM не задан" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "Файл ROM не найден" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "Файлы M3U должны сохранять расширение .m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Папка с новым именем уже существует" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Нет полученного скрапингом названия" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Название пусто после очистки" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Сгенерированное имя файла содержит недопустимые символы" @@ -839,7 +843,7 @@ msgstr "Сгенерированное имя файла содержит нед msgid "Invalid log file" msgstr "Недопустимый файл журнала" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Файл не найден" @@ -848,56 +852,56 @@ msgstr "Файл не найден" msgid "Log file not found" msgstr "Файл журнала не найден" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "недопустимые настройки скрапера: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "недопустимые API-ключи: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "API-ключ TGDB не настроен" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Учётные данные ScreenScraper не настроены" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Недопустимый JSON-ответ от ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Ошибка разбора ответа API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper ответил HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "API-ключ RAWG не настроен" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Неизвестный провайдер ИИ: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Нет ответа от провайдера ИИ" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Ошибка подключения: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Проверка лимита не поддерживается для %(scraper)s" @@ -928,7 +932,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "Недопустимое значение поля %(field)s: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Не удалось сохранить настройки" @@ -936,12 +940,12 @@ msgstr "Не удалось сохранить настройки" msgid "Request body must be a JSON object" msgstr "Тело запроса должно быть JSON-объектом" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Неизвестный ключ настройки: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Недопустимое значение для «%(key)s»: %(reason)s" @@ -979,87 +983,87 @@ msgstr "Требуется ID системы" msgid "ES-DE scraper not available" msgstr "Скрапер ES-DE недоступен" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Требуются права администратора" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Данные настроек должны быть объектом" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Путь не существует: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Доступ запрещён" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Задача не найдена" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Задача не найдена или не выполняется" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Задача не найдена или не приостановлена" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Недопустимый путь сканирования" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Путь не указан" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Путь к архиву не указан" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Недопустимый путь к архиву" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Нет файлов для удаления" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Пути не предоставлены" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Недопустимый путь: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman не найден. Установите инструменты MAME." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman не найден" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Неизвестный метод поиска дубликатов: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Эти результаты сканирования устарели. Просканируйте снова, затем удаляйте." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Оставьте хотя бы одну копию каждой игры. Ничего не удалено." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1067,60 +1071,60 @@ msgstr "" "Настройте путь к трофеям RPCS3 в настройках профиля, чтобы просматривать " "трофеи" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Набор трофеев не найден" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH не настроен" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Путь к трофеям не найден: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "Игра PSN не найдена" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Синхронизация уже выполняется" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Игра не найдена в вашем списке трофеев PSN" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Игра не найдена в PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "Отсутствует npwr_id" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "Отсутствует npwr_id или group_id" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Имя не может быть пустым" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "Библиотека PSNAWP не установлена. Выполните: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Токен NPSSO не предоставлен" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Не удалось подключиться к PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "NPSSO PSN не настроен" diff --git a/translations/tr/LC_MESSAGES/messages.mo b/translations/tr/LC_MESSAGES/messages.mo index 32634d5a940d8012495497f8f299a18a6be3d8d4..abc3f22d271bf8991f9c966d969d739a5313f118 100644 GIT binary patch delta 32 ocmcb6o%8N>&W0_F+vB**6%386j19F74B8LIF>XH?$0WlG0M={^S^xk5 delta 32 ocmcb6o%8N>&W0_F+vB*56%0+S3=FgljM@*zF>XH?$0WlG0M;rCR{#J2 diff --git a/translations/tr/LC_MESSAGES/messages.po b/translations/tr/LC_MESSAGES/messages.po index 41f58e9c..5bd0ddd2 100644 --- a/translations/tr/LC_MESSAGES/messages.po +++ b/translations/tr/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-05 16:56+0200\n" "Last-Translator: FULL NAME \n" "Language: tr\n" @@ -42,8 +42,8 @@ msgstr "Kurulum gerekli" msgid "Password change required" msgstr "Şifre değişikliği gerekli" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Şifre en az 12 karakter olmalıdır" @@ -60,9 +60,9 @@ msgstr "Sistem bulunamadı veya başarım içeren oyunu yok" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Oyun bulunamadı" @@ -87,8 +87,8 @@ msgstr "RetroAchievements yapılandırılmamış" msgid "Could not fetch achievement data" msgstr "Başarım verisi alınamadı" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Sistem bulunamadı" @@ -102,8 +102,8 @@ msgstr "Bu sistemde RetroAchievements içeren oyun yok" msgid "Sync already in progress for %(name)s" msgstr "%(name)s için senkronizasyon zaten devam ediyor" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Bu iş başka bir kullanıcıya ait" @@ -119,7 +119,7 @@ msgstr "Çok fazla giriş denemesi. Lütfen daha sonra tekrar deneyin." msgid "No user selected" msgstr "Kullanıcı seçilmedi" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Kullanıcı bulunamadı" @@ -153,64 +153,68 @@ msgstr "Geçersiz rol" msgid "Username already exists" msgstr "Bu kullanıcı adı zaten mevcut" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Kendi hesabınızı silemezsiniz" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Son yönetici kullanıcısı silinemez" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Oturum açılmamış" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Geçersiz dil" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Geçersiz tema" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Geçersiz RPCS3 kupa klasörü: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Çok fazla deneme. Lütfen daha sonra tekrar deneyin." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Yeni şifre gerekli" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Mevcut şifre yanlış" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Şifre değişikliği gerekli değil" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Dosya sağlanmadı" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Dosya seçilmedi" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Geçersiz dosya türü. İzin verilenler: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Dosya çok büyük. Maksimum boyut 2MB'dir" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -218,7 +222,7 @@ msgstr "" "Avatar yüklemeleri için Pillow gereklidir; lütfen operatörden proje " "gereksinimlerini kurmasını isteyin." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Geçersiz resim dosyası" @@ -235,17 +239,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Bonus disk ve ana oyun aynı sistemde olmalıdır" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Bir dahili hata oluştu" @@ -254,7 +258,7 @@ msgstr "Bir dahili hata oluştu" msgid "Missing bonus_id or parent_id" msgstr "bonus_id veya parent_id eksik" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "game_id eksik" @@ -377,22 +381,22 @@ msgstr "Bu oyun zaten istek listenizde" msgid "Wishlist item not found" msgstr "İstek listesi öğesi bulunamadı" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Kontrolcü adı gerekli" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "\"%(name)s\" kontrolcüsü zaten mevcut" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Kontrolcü bulunamadı" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "\"%(name)s\" adında başka bir kontrolcü zaten mevcut" @@ -479,9 +483,9 @@ msgstr "Meta veriler uygulanamadı." msgid "No fields to update" msgstr "Güncellenecek alan yok" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Oyun seçilmedi" @@ -509,13 +513,13 @@ msgstr "Filtre türü ve değeri gereklidir" msgid "Invalid filter type" msgstr "Geçersiz filtre türü" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "Yapay zeka veri döndürmedi. API anahtarınızı ve sağlayıcı ayarlarınızı " "kontrol edin." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "Yapay zeka kazıyıcı modülü kullanılamıyor" @@ -533,7 +537,7 @@ msgstr "Geçersiz dosya adı" msgid "ROM file not found on disk: %(path)s" msgstr "ROM dosyası diskte bulunamadı: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "Hedef, yapılandırılmış ROM kök dizininin dışında" @@ -542,23 +546,23 @@ msgstr "Hedef, yapılandırılmış ROM kök dizininin dışında" msgid "Cannot rename: %(reason)s" msgstr "Yeniden adlandırılamıyor: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Bu isimde bir dosya zaten mevcut" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Dosya yeniden adlandırılamadı" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Ekran görüntüsü belirtilmedi" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Geçersiz ekran görüntüsü dosya adı" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Ekran görüntüsü bulunamadı" @@ -637,11 +641,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Sonraki disk takıldı" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Bilinmeyen görsel türü" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Yapay zeka sağlayıcısı yapılandırılmamış" @@ -720,17 +724,17 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" "Steam API anahtarı yapılandırılmamış. Ayarlar → API Anahtarları " "bölümünden ekleyin." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "Steam ID yapılandırılmamış. Ayarlar → API Anahtarları bölümünden ekleyin." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -739,7 +743,7 @@ msgstr "" "Steam özel URL'si \"%(steam_id)s\" çözümlenemedi. Bunun yerine 17 haneli " "Steam ID'nizi kullanın." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -747,37 +751,37 @@ msgstr "" "Oyun bulunamadı. Steam profilinizin ve oyun ayrıntılarınızın Herkese Açık" " olarak ayarlandığından emin olun." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Windows sistem girişi oluşturulamadı" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" "Xbox İstemci Kimliği yapılandırılmamış. Ayarlar → API Anahtarları " "bölümünden ekleyin." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Xbox kimlik bilgileri yapılandırılmamış. Ayarlar → API Anahtarları " "bölümünden ekleyin." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "" "Xbox kimlik doğrulaması başarısız oldu. Lütfen Xbox hesabınızı yeniden " "bağlayın." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Xbox oyun geçmişinde oyun bulunamadı." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "PSN NPSSO yapılandırılmamış. Ayarlar → API Anahtarları bölümünden ekleyin." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "PSN oyun listesi alınamadı. Önce kupaları senkronize etmeyi deneyin." @@ -786,11 +790,11 @@ msgstr "PSN oyun listesi alınamadı. Önce kupaları senkronize etmeyi deneyin. msgid "System %(name)s does not support RetroAchievements" msgstr "%(name)s sistemi RetroAchievements'ı desteklemiyor" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Bilinmeyen sistem" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" "ROM yolu yapılandırılmamış. Ayarlamak için Ayarlar → Yollar bölümüne " @@ -800,8 +804,8 @@ msgstr "" msgid "Missing required parameters" msgstr "Gerekli parametreler eksik" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Geçersiz yol" @@ -814,31 +818,31 @@ msgstr "Klasör mevcut değil: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "game_id veya new_name eksik" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "ROM yolu ayarlanmamış" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "ROM dosyası bulunamadı" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "M3U dosyaları .m3u uzantısını korumalıdır" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Bu yeni isimde bir klasör zaten mevcut" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Kazınmış başlık mevcut değil" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Temizleme sonrası başlık boş" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Oluşturulan dosya adı geçersiz karakterler içeriyor" @@ -847,7 +851,7 @@ msgstr "Oluşturulan dosya adı geçersiz karakterler içeriyor" msgid "Invalid log file" msgstr "Geçersiz günlük dosyası" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Dosya bulunamadı" @@ -856,56 +860,56 @@ msgstr "Dosya bulunamadı" msgid "Log file not found" msgstr "Günlük dosyası bulunamadı" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "geçersiz kazıyıcı ayarları: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "geçersiz API anahtarları: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "TGDB API anahtarı yapılandırılmamış" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "ScreenScraper kimlik bilgileri yapılandırılmamış" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "ScreenScraper'dan geçersiz JSON yanıtı" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "API yanıtı ayrıştırılırken hata" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper HTTP %(code)s yanıtı verdi" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "RAWG API anahtarı yapılandırılmamış" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Bilinmeyen yapay zeka sağlayıcısı: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Yapay zeka sağlayıcısından yanıt yok" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Bağlantı hatası: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "%(scraper)s için kota kontrolü desteklenmiyor" @@ -936,7 +940,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "Geçersiz %(field)s: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Ayarlar kaydedilemedi" @@ -944,12 +948,12 @@ msgstr "Ayarlar kaydedilemedi" msgid "Request body must be a JSON object" msgstr "İstek gövdesi bir JSON nesnesi olmalıdır" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Bilinmeyen ayar anahtarı: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "'%(key)s' için geçersiz değer: %(reason)s" @@ -987,87 +991,87 @@ msgstr "Sistem ID'si gerekli" msgid "ES-DE scraper not available" msgstr "ES-DE kazıyıcısı kullanılamıyor" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Yönetici izinleri gerekli" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Ayarlar verisi bir nesne olmalıdır" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Yol mevcut değil: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "İzin reddedildi" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Görev bulunamadı" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Görev bulunamadı veya çalışmıyor" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Görev bulunamadı veya duraklatılmamış" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Geçersiz tarama yolu" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Yol sağlanmadı" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Arşiv yolu sağlanmadı" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Geçersiz arşiv yolu" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Kaldırılacak dosya yok" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Yol sağlanmadı" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Geçersiz yol: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman bulunamadı. Lütfen MAME araçlarını kurun." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman bulunamadı" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Bilinmeyen kopya bulma yöntemi: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Bu tarama sonuçlarının süresi doldu. Yeniden tarayın, ardından silin." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Her oyunun en az bir kopyasını tutun. Hiçbir şey silinmedi." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1075,60 +1079,60 @@ msgstr "" "Kupaları görüntülemek için profil ayarlarınızdan RPCS3 Kupa Yolunu " "yapılandırın" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Kupa seti bulunamadı" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH yapılandırılmamış" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Kupa yolu bulunamadı: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "PSN oyunu bulunamadı" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Senkronizasyon zaten devam ediyor" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Oyun PSN kupa listenizde bulunamadı" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Oyun PSN'de bulunamadı" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "npwr_id eksik" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "npwr_id veya group_id eksik" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Ad boş olamaz" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "PSNAWP kütüphanesi kurulu değil. Çalıştırın: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "NPSSO token'ı sağlanmadı" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "PSN'ye bağlanılamadı" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "PSN NPSSO yapılandırılmamış" diff --git a/translations/uk/LC_MESSAGES/messages.mo b/translations/uk/LC_MESSAGES/messages.mo index fcc75a591822f3ece3467a73606b6a46ea10fdb5..a5d8e334db9e368ced789b1378306aea4378d697 100644 GIT binary patch delta 32 ocmaDbi|4^Co`x-q+vB**6%386j19F74B8LIF>XH?$25N(0Lx|!5C8xG delta 32 ocmaDbi|4^Co`x-q+vB*56%0+S3=FgljM@*zF>XH?$25N(0Lvr{4FCWD diff --git a/translations/uk/LC_MESSAGES/messages.po b/translations/uk/LC_MESSAGES/messages.po index 9afad7ca..19c8e066 100644 --- a/translations/uk/LC_MESSAGES/messages.po +++ b/translations/uk/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-05 16:56+0200\n" "Last-Translator: FULL NAME \n" "Language: uk\n" @@ -43,8 +43,8 @@ msgstr "Потрібне налаштування" msgid "Password change required" msgstr "Потрібна зміна пароля" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Пароль має містити щонайменше 12 символів" @@ -61,9 +61,9 @@ msgstr "Систему не знайдено або вона не має іго #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Гру не знайдено" @@ -88,8 +88,8 @@ msgstr "RetroAchievements не налаштовано" msgid "Could not fetch achievement data" msgstr "Не вдалося отримати дані про досягнення" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Систему не знайдено" @@ -103,8 +103,8 @@ msgstr "У цій системі немає ігор з RetroAchievements" msgid "Sync already in progress for %(name)s" msgstr "Синхронізація для %(name)s вже виконується" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Це завдання належить іншому користувачу" @@ -120,7 +120,7 @@ msgstr "Забагато спроб входу. Спробуйте пізніш msgid "No user selected" msgstr "Користувача не вибрано" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Користувача не знайдено" @@ -154,64 +154,68 @@ msgstr "Невірна роль" msgid "Username already exists" msgstr "Таке ім'я користувача вже існує" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Неможливо видалити власний обліковий запис" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Неможливо видалити останнього адміністратора" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Не виконано вхід" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "Недійсна локаль" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Недійсна тема" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Недійсна тека трофеїв RPCS3: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Забагато спроб. Спробуйте пізніше." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Потрібен новий пароль" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Поточний пароль неправильний" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Зміна пароля не потрібна" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Файл не надано" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Файл не вибрано" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Недійсний тип файлу. Дозволено: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Файл завеликий. Максимальний розмір — 2 МБ" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -219,7 +223,7 @@ msgstr "" "Для завантаження аватарів потрібна бібліотека Pillow; попросіть оператора" " встановити залежності проєкту." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Недійсний файл зображення" @@ -236,17 +240,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Бонусний диск і батьківська гра мають належати до однієї системи" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Сталася внутрішня помилка" @@ -255,7 +259,7 @@ msgstr "Сталася внутрішня помилка" msgid "Missing bonus_id or parent_id" msgstr "Відсутній bonus_id або parent_id" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "Відсутній game_id" @@ -378,22 +382,22 @@ msgstr "Ця гра вже у вашому списку бажань" msgid "Wishlist item not found" msgstr "Елемент списку бажань не знайдено" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Потрібна назва контролера" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Контролер «%(name)s» вже існує" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Контролер не знайдено" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Уже існує інший контролер з назвою «%(name)s»" @@ -482,9 +486,9 @@ msgstr "Не вдалося застосувати метадані." msgid "No fields to update" msgstr "Немає полів для оновлення" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Не вибрано жодної гри" @@ -512,11 +516,11 @@ msgstr "Потрібні тип і значення фільтра" msgid "Invalid filter type" msgstr "Недійсний тип фільтра" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "ШІ не повернув даних. Перевірте ключ API та налаштування провайдера." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "Модуль скрапера ШІ недоступний" @@ -534,7 +538,7 @@ msgstr "Недійсне ім'я файлу" msgid "ROM file not found on disk: %(path)s" msgstr "Файл ROM не знайдено на диску: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "Місце призначення поза межами налаштованого кореневого каталогу ROM" @@ -543,23 +547,23 @@ msgstr "Місце призначення поза межами налаштов msgid "Cannot rename: %(reason)s" msgstr "Не вдалося перейменувати: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Файл з такою назвою вже існує" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Не вдалося перейменувати файл" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Не вказано знімок екрана" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Недійсне ім'я файлу знімка екрана" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Знімок екрана не знайдено" @@ -638,11 +642,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Вставлено наступний диск" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Невідомий тип зображення" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Провайдера ШІ не налаштовано" @@ -721,15 +725,15 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "Ключ API Steam не налаштовано. Додайте його в «Налаштування → Ключі API»." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "Steam ID не налаштовано. Додайте його в «Налаштування → Ключі API»." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -738,7 +742,7 @@ msgstr "" "Не вдалося визначити користувацьке посилання Steam «%(steam_id)s». " "Використайте натомість 17-значний Steam ID." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -746,35 +750,35 @@ msgstr "" "Ігор не знайдено. Перевірте, що ваш профіль Steam і дані про ігри " "встановлено як публічні." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Не вдалося створити запис системи Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "Xbox Client ID не налаштовано. Додайте його в «Налаштування → Ключі API»." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Облікові дані Xbox не налаштовано. Додайте їх в «Налаштування → Ключі " "API»." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "" "Не вдалося пройти автентифікацію Xbox. Повторно підключіть свій обліковий" " запис Xbox." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "В історії ігор Xbox нічого не знайдено." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "PSN NPSSO не налаштовано. Додайте його в «Налаштування → Ключі API»." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "Не вдалося отримати список ігор PSN. Спробуйте спочатку синхронізувати " @@ -785,11 +789,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "Система %(name)s не підтримує RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Невідома система" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" "Шлях до ROM не налаштовано. Перейдіть у «Налаштування → Шляхи», щоб " @@ -799,8 +803,8 @@ msgstr "" msgid "Missing required parameters" msgstr "Відсутні обов'язкові параметри" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Недійсний шлях" @@ -813,31 +817,31 @@ msgstr "Папка не існує: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "Відсутній game_id або new_name" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Шлях до ROM не встановлено" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "Файл ROM не знайдено" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "Файли M3U мають зберігати розширення .m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Папка з такою назвою вже існує" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Немає отриманої скрапером назви" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Назва порожня після очищення" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Згенероване ім'я файлу містить недійсні символи" @@ -846,7 +850,7 @@ msgstr "Згенероване ім'я файлу містить недійсн msgid "Invalid log file" msgstr "Недійсний файл журналу" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Файл не знайдено" @@ -855,56 +859,56 @@ msgstr "Файл не знайдено" msgid "Log file not found" msgstr "Файл журналу не знайдено" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "недійсні налаштування скрапера: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "недійсні ключі API: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Ключ API TheGamesDB не налаштовано" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Облікові дані ScreenScraper не налаштовано" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Недійсна відповідь JSON від ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Помилка обробки відповіді API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper відповів HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Ключ API RAWG не налаштовано" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Невідомий провайдер ШІ: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Немає відповіді від провайдера ШІ" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Помилка з'єднання: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Перевірка ліміту не підтримується для %(scraper)s" @@ -937,7 +941,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "Недійсне поле %(field)s: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Не вдалося зберегти налаштування" @@ -945,12 +949,12 @@ msgstr "Не вдалося зберегти налаштування" msgid "Request body must be a JSON object" msgstr "Тіло запиту має бути об'єктом JSON" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Невідомий ключ налаштування: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Недійсне значення для «%(key)s»: %(reason)s" @@ -988,87 +992,87 @@ msgstr "Потрібен ID системи" msgid "ES-DE scraper not available" msgstr "Скрапер ES-DE недоступний" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Потрібні права адміністратора" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "Дані налаштувань мають бути об'єктом" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Шлях не існує: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Доступ заборонено" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Завдання не знайдено" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Завдання не знайдено або воно не виконується" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Завдання не знайдено або воно не призупинено" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Недійсний шлях сканування" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Шлях не надано" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Шлях до архіву не надано" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Недійсний шлях до архіву" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Немає файлів для видалення" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Шляхи не надано" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Недійсний шлях: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "chdman не знайдено. Установіть інструменти MAME." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "chdman не знайдено" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Невідомий метод пошуку дублікатів: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Ці результати сканування застаріли. Проскануйте знову, потім видаляйте." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Залиште принаймні одну копію кожної гри. Нічого не видалено." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1076,60 +1080,60 @@ msgstr "" "Налаштуйте шлях до трофеїв RPCS3 у налаштуваннях профілю, щоб переглядати" " трофеї" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Набір трофеїв не знайдено" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "RPCS3_TROPHY_PATH не налаштовано" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Шлях до трофеїв не знайдено: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "Гру PSN не знайдено" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Синхронізація вже виконується" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Гру не знайдено у вашому списку трофеїв PSN" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Гру не знайдено в PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "Відсутній npwr_id" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "Відсутній npwr_id або group_id" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Назва не може бути порожньою" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "Бібліотеку PSNAWP не встановлено. Виконайте: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Токен NPSSO не надано" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Не вдалося під'єднатися до PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "PSN NPSSO не налаштовано" diff --git a/translations/xh/LC_MESSAGES/messages.mo b/translations/xh/LC_MESSAGES/messages.mo index 9a296c4e6e826ec18af35dac4c61f386d40ffd76..b10383ecdea9b8e8db551b3b879cbdf74af3c19e 100644 GIT binary patch delta 32 ocmZ2;mvhZs&W0_F+vB**6%386j19F74B8LIF>XH?$Ml*X0L{S*)c^nh delta 32 ocmZ2;mvhZs&W0_F+vB*56%0+S3=FgljM@*zF>XH?$Ml*X0L_03(f|Me diff --git a/translations/xh/LC_MESSAGES/messages.po b/translations/xh/LC_MESSAGES/messages.po index 7d12c667..5e04493f 100644 --- a/translations/xh/LC_MESSAGES/messages.po +++ b/translations/xh/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-05 16:56+0200\n" "Last-Translator: FULL NAME \n" "Language: xh\n" @@ -42,8 +42,8 @@ msgstr "Kufuneka ukusetwa" msgid "Password change required" msgstr "Kufuneka utshintshe iphasiwedi" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Iphasiwedi kufuneka ibe nabalinganiso abangama-12 ubuncinane" @@ -60,9 +60,9 @@ msgstr "Isistim ayifunyanwanga okanye ayinamidlalo eneempumelelo" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Umdlalo awufunyanwanga" @@ -87,8 +87,8 @@ msgstr "IRetroAchievements ayimiselwanga" msgid "Could not fetch achievement data" msgstr "Ayikwazanga ukulanda idatha yempumelelo" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Isistim ayifunyanwanga" @@ -102,8 +102,8 @@ msgstr "Akukho midlalo eneRetroAchievements kule sistim" msgid "Sync already in progress for %(name)s" msgstr "Ukulungelelanisa sekuqhubeka ku-%(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Lo msebenzi ngowomnye umsebenzisi" @@ -119,7 +119,7 @@ msgstr "Iinzame ezininzi zokungena. Nceda uzame kwakhona kamva." msgid "No user selected" msgstr "Akukho msebenzisi ukhethiweyo" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Umsebenzisi akafunyanwanga" @@ -151,64 +151,68 @@ msgstr "Indima engasebenziyo" msgid "Username already exists" msgstr "Igama lomsebenzisi sele likhona" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Awukwazi ukucima i-akhawunti yakho" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Awukwazi ukucima umlawuli wokugqibela" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Awungenanga" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "I-locale engasebenziyo" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Umxholo ongasebenziyo" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Ifolda yeendebe ze-RPCS3 engasebenziyo: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Iinzame ezininzi. Nceda uzame kwakhona kamva." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Kufuneka iphasiwedi entsha" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Iphasiwedi yangoku ayichanekanga" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Utshintsho lwephasiwedi alufuneki" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Akukho fayile inikeziweyo" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Akukho fayile ikhethiweyo" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Uhlobo lwefayile olungasebenziyo. Kuvunyelwe: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Ifayile inkulu kakhulu. Ubungakanani obuphezulu bu-2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -216,7 +220,7 @@ msgstr "" "Ukulayishwa kwe-avatar kufuna iPillow; nceda ucele umqhubi ukuba afakele " "iimfuno zeprojekthi." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Ifayile yomfanekiso engasebenziyo" @@ -233,17 +237,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Idiski yebhonasi nomdlalo ongumzali kufuneka bakwisistim enye" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Kwenzeke impazamo yangaphakathi" @@ -252,7 +256,7 @@ msgstr "Kwenzeke impazamo yangaphakathi" msgid "Missing bonus_id or parent_id" msgstr "Kulahleke i-bonus_id okanye i-parent_id" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "Kulahleke i-game_id" @@ -375,22 +379,22 @@ msgstr "Lo mdlalo sele ukuluhlu lwakho lweminqweno" msgid "Wishlist item not found" msgstr "Into yoluhlu lweminqweno ayifunyanwanga" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Kufuneka igama lekhontrola" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Ikhontrola \"%(name)s\" sele ikhona" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Ikhontrola ayifunyanwanga" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Enye ikhontrola egama linguye \"%(name)s\" sele ikhona" @@ -479,9 +483,9 @@ msgstr "Ukufakwa kwemetadata akuphumelelanga." msgid "No fields to update" msgstr "Akukho ndawo zokuhlaziya" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Akukho midlalo ikhethiweyo" @@ -509,13 +513,13 @@ msgstr "Kufuneka uhlobo lwesihluzi nexabiso" msgid "Invalid filter type" msgstr "Uhlobo lwesihluzi olungasebenziyo" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "I-AI ayibuyisanga datha. Khangela isitshixo se-API kunye neesethingi " "zomboneleli." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "Imodyuli ye-AI scraper ayifumaneki" @@ -533,7 +537,7 @@ msgstr "Igama lefayile elingasebenziyo" msgid "ROM file not found on disk: %(path)s" msgstr "Ifayile yeROM ayifunyanwanga kwidiski: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "Indawo eyiyeyo ingaphandle kwengcambu yeROM emiselweyo" @@ -542,23 +546,23 @@ msgstr "Indawo eyiyeyo ingaphandle kwengcambu yeROM emiselweyo" msgid "Cannot rename: %(reason)s" msgstr "Ayikwazi ukuthiya ngokutsha: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Ifayile enelo gama sele ikhona" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Kwehlulekile ukunika ifayile igama elitsha" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Akukho screenshot ikhankanyiweyo" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Igama lefayile ye-screenshot elingasebenziyo" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "I-screenshot ayifunyanwanga" @@ -637,11 +641,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Idiski elandelayo ifakiwe" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Uhlobo lomfanekiso olungaziwayo" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Akukho mboneleli we-AI omiselweyo" @@ -720,15 +724,15 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "Isitshixo se-Steam API asimiselwanga. Sifake kwiiSethingi → API Keys." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "I-Steam ID ayimiselwanga. Yifake kwiiSethingi → API Keys." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -737,7 +741,7 @@ msgstr "" "Ayikwazanga ukucombulula i-Steam vanity URL \"%(steam_id)s\". Endaweni " "yoko sebenzisa i-Steam ID yakho enamanani angu-17." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -745,33 +749,33 @@ msgstr "" "Akukho midlalo ifunyenweyo. Qinisekisa ukuba iprofayile yakho yeSteam " "neenkcukacha zomdlalo zimiselwe kuLuntu." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Ayikwazanga ukwenza ungeniso lwesistim yeWindows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "I-Xbox Client ID ayimiselwanga. Yifake kwiiSethingi → API Keys." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "Iziqinisekiso zeXbox azimiselwanga. Zifake kwiiSethingi → API Keys." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "" "Uqinisekiso lweXbox lwehlulekile. Nceda uphinde uxhume i-akhawunti yakho " "yeXbox." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Akukho midlalo ifunyenwe kwimbali yezihloko zeXbox." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "I-PSN NPSSO ayimiselwanga. Yifake kwiiSethingi → API Keys." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "Kwehlulekile ukulanda uluhlu lwemidlalo yePSN. Zama ukulungelelanisa " @@ -782,11 +786,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "Isistim %(name)s ayixhasi iRetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Isistim engaziwayo" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "Indlela yeROM ayimiselwanga. Yiya kwiiSethingi → Iindlela ukuze uyimisele." @@ -794,8 +798,8 @@ msgstr "Indlela yeROM ayimiselwanga. Yiya kwiiSethingi → Iindlela ukuze uyimis msgid "Missing required parameters" msgstr "Kulahleke iiparamitha ezifunekayo" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Indlela engasebenziyo" @@ -808,31 +812,31 @@ msgstr "Ifolda ayikho: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "Kulahleke i-game_id okanye i-new_name" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Akukho ndlela yeROM emiselweyo" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "Ifayile yeROM ayifunyanwanga" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "Iifayile zeM3U kufuneka zigcine isandiso se-.m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Ifolda enegama elitsha sele ikhona" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Akukho sihloko sikhutshiweyo sifumanekayo" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Isihloko asinanto emva kococeko" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Igama lefayile eliveliswe liqulethe abalinganiso abangasebenziyo" @@ -841,7 +845,7 @@ msgstr "Igama lefayile eliveliswe liqulethe abalinganiso abangasebenziyo" msgid "Invalid log file" msgstr "Ifayile yelog engasebenziyo" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Ifayile ayifunyanwanga" @@ -850,56 +854,56 @@ msgstr "Ifayile ayifunyanwanga" msgid "Log file not found" msgstr "Ifayile yelog ayifunyanwanga" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "iisethingi ze-scraper ezingasebenziyo: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "izitshixo ze-api ezingasebenziyo: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Akukho sitshixo se-TGDB API simiselweyo" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Akukho ziqinisekiso zeScreenScraper zimiselweyo" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Impendulo ye-JSON engasebenziyo evela kwiScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Impazamo ekuhlalutyweni kwempendulo ye-API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "I-ScreenScraper iphendule nge-HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Akukho sitshixo se-RAWG API simiselweyo" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Umboneleli we-AI ongaziwayo: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Akukho mpendulo evela kumboneleli we-AI" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Impazamo yoqhagamshelo: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Ukhangelo lwesabelo aluxhaswanga kwi-%(scraper)s" @@ -930,7 +934,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "%(field)s engasebenziyo: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Kwehlulekile ukugcina iisethingi" @@ -938,12 +942,12 @@ msgstr "Kwehlulekile ukugcina iisethingi" msgid "Request body must be a JSON object" msgstr "Umzimba wesicelo kufuneka ube yinto ye-JSON" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Isitshixo sesethingi esingaziwayo: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Ixabiso elingasebenziyo le-'%(key)s': %(reason)s" @@ -981,87 +985,87 @@ msgstr "Kufuneka i-ID yesistim" msgid "ES-DE scraper not available" msgstr "I-ES-DE scraper ayifumaneki" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Kufuneka amalungelo omlawuli" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "I-payload yeisethingi kufuneka ibe yinto" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Indlela ayikho: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Imvume yaliwe" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Umsebenzi awufunyanwanga" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Umsebenzi awufunyanwanga okanye awuqhubi" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Umsebenzi awufunyanwanga okanye awumiswanga" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Indlela yokuskena engasebenziyo" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Akukho ndlela inikeziweyo" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Akukho ndlela ye-archive inikeziweyo" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Indlela ye-archive engasebenziyo" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Akukho fayile zokususa" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Akukho ndlela zinikeziweyo" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Indlela engasebenziyo: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "i-chdman ayifunyanwanga. Nceda ufake izixhobo zeMAME." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "i-chdman ayifunyanwanga" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Indlela yokufumana iimpinda engaziwayo: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Ezi ziphumo zokuskena ziphelelwe lixesha. Skena kwakhona, uze ucime." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Gcina ubuncinci ikopi enye yomdlalo ngamnye. Akukho nto icinyiweyo." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1069,60 +1073,60 @@ msgstr "" "Nceda umisele indlela yakho yeRPCS3 Trophy kwiisethingi zeprofayile yakho" " ukuze ubone iindebe" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Iseti yeendebe ayifunyanwanga" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "I-RPCS3_TROPHY_PATH ayimiselwanga" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Indlela yendebe ayifunyanwanga: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "Umdlalo wePSN awufunyanwanga" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Ukulungelelanisa sekuqhubeka" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Umdlalo awufunyanwanga kuluhlu lwakho lweendebe zePSN" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Umdlalo awufunyanwanga kwiPSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "Kulahleke i-npwr_id" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "Kulahleke i-npwr_id okanye i-group_id" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Igama alinakuba lingenanto" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "Ilayibrari yePSNAWP ayifakwanga. Sebenzisa: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Akukho thokheni ye-NPSSO inikeziweyo" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Ayikwazanga ukuxhuma kwi-PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "I-PSN NPSSO ayimiselwanga" diff --git a/translations/zh_Hans/LC_MESSAGES/messages.mo b/translations/zh_Hans/LC_MESSAGES/messages.mo index ab73b7886039b152746d4da5d4be41fc63630b25..86b90efa358d8b719d90cf66eb049a39b593389f 100644 GIT binary patch delta 28 kcmdn@fn(nXj)pCa+vB**6%386j19F747MMPW30Ui0H}xx(EtDd delta 28 kcmdn@fn(nXj)pCa+vB*56%0+S3=FgljJ6+)W30Ui0H{(5&Hw-a diff --git a/translations/zh_Hans/LC_MESSAGES/messages.po b/translations/zh_Hans/LC_MESSAGES/messages.po index 044c06f7..be4822e6 100644 --- a/translations/zh_Hans/LC_MESSAGES/messages.po +++ b/translations/zh_Hans/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-01 09:16+0200\n" "Last-Translator: FULL NAME \n" "Language: zh_Hans\n" @@ -40,8 +40,8 @@ msgstr "需要初始设置" msgid "Password change required" msgstr "需要更改密码" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "密码长度至少为 12 个字符" @@ -58,9 +58,9 @@ msgstr "未找到系统,或该系统没有包含成就的游戏" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "未找到游戏" @@ -85,8 +85,8 @@ msgstr "未配置 RetroAchievements" msgid "Could not fetch achievement data" msgstr "无法获取成就数据" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "未找到系统" @@ -100,8 +100,8 @@ msgstr "此系统中没有带 RetroAchievements 的游戏" msgid "Sync already in progress for %(name)s" msgstr "%(name)s 的同步已在进行中" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "该任务属于其他用户" @@ -117,7 +117,7 @@ msgstr "登录尝试次数过多,请稍后再试。" msgid "No user selected" msgstr "未选择用户" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "未找到用户" @@ -149,70 +149,74 @@ msgstr "角色无效" msgid "Username already exists" msgstr "用户名已存在" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "无法删除您自己的账户" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "无法删除最后一个管理员用户" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "未登录" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "无效的语言区域" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "无效的主题" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "无效的 RPCS3 奖杯文件夹:%(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "尝试次数过多,请稍后再试。" -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "需要设置新密码" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "当前密码不正确" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "无需更改密码" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "未提供文件" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "未选择文件" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "文件类型无效。允许的类型:jpg、png、gif、webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "文件过大。最大大小为 2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." msgstr "上传头像需要 Pillow;请联系操作员安装项目依赖。" -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "无效的图片文件" @@ -229,17 +233,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "附赠光盘与主游戏必须属于同一系统" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "发生内部错误" @@ -248,7 +252,7 @@ msgstr "发生内部错误" msgid "Missing bonus_id or parent_id" msgstr "缺少 bonus_id 或 parent_id" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "缺少 game_id" @@ -369,22 +373,22 @@ msgstr "该游戏已在您的愿望单中" msgid "Wishlist item not found" msgstr "未找到心愿单项目" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "必须填写手柄名称" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "手柄“%(name)s”已存在" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "未找到手柄" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "已存在另一个名为“%(name)s”的手柄" @@ -465,9 +469,9 @@ msgstr "应用元数据失败。" msgid "No fields to update" msgstr "没有可更新的字段" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "未选择任何游戏" @@ -495,11 +499,11 @@ msgstr "需要筛选类型和值" msgid "Invalid filter type" msgstr "筛选类型无效" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "AI 未返回任何数据。请检查您的 API 密钥和提供商设置。" -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "AI 抓取模块不可用" @@ -517,7 +521,7 @@ msgstr "无效的文件名" msgid "ROM file not found on disk: %(path)s" msgstr "磁盘上未找到 ROM 文件:%(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "目标位置在已配置的 ROM 根目录之外" @@ -526,23 +530,23 @@ msgstr "目标位置在已配置的 ROM 根目录之外" msgid "Cannot rename: %(reason)s" msgstr "无法重命名:%(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "已存在同名文件" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "重命名文件失败" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "未指定截图" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "截图文件名无效" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "未找到截图" @@ -619,11 +623,11 @@ msgstr "" msgid "Next disk inserted" msgstr "已插入下一张光盘" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "未知的图片类型" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "未配置 AI 提供方" @@ -702,52 +706,52 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "尚未配置 Steam API 密钥。请在“设置 → API 密钥”中添加。" -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "尚未配置 Steam ID。请在“设置 → API Keys”中添加。" -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " "Steam ID instead." msgstr "无法解析 Steam 个性网址“%(steam_id)s”。请改用你的 17 位 Steam ID。" -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." msgstr "未找到游戏。请检查您的 Steam 个人资料和游戏详情是否设置为公开。" -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "无法创建 Windows 系统条目" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "未配置 Xbox Client ID。请在“设置 → API 密钥”中添加。" -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "未配置 Xbox 凭据。请在“设置 → API Keys”中添加。" -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Xbox 身份验证失败。请重新连接你的 Xbox 账户。" -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "在 Xbox 游戏历史记录中未找到游戏。" -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "尚未配置 PSN NPSSO。请在“设置 → API 密钥”中添加。" -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "获取 PSN 游戏列表失败。请先尝试同步奖杯。" @@ -756,11 +760,11 @@ msgstr "获取 PSN 游戏列表失败。请先尝试同步奖杯。" msgid "System %(name)s does not support RetroAchievements" msgstr "系统 %(name)s 不支持 RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "未知系统" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "未配置 ROM 路径。请前往“设置 → 路径”进行设置。" @@ -768,8 +772,8 @@ msgstr "未配置 ROM 路径。请前往“设置 → 路径”进行设置。" msgid "Missing required parameters" msgstr "缺少必填参数" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "路径无效" @@ -782,31 +786,31 @@ msgstr "文件夹不存在:%(folder_path)s" msgid "Missing game_id or new_name" msgstr "缺少 game_id 或 new_name" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "未设置 ROM 路径" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "未找到 ROM 文件" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "M3U 文件必须保留 .m3u 扩展名" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "已存在同名的文件夹" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "没有可用的抓取标题" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "清理后标题为空" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "生成的文件名包含无效字符" @@ -815,7 +819,7 @@ msgstr "生成的文件名包含无效字符" msgid "Invalid log file" msgstr "日志文件无效" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "未找到文件" @@ -824,56 +828,56 @@ msgstr "未找到文件" msgid "Log file not found" msgstr "未找到日志文件" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "抓取器设置无效:%(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "API 密钥无效:%(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "尚未配置 TGDB API 密钥" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "尚未配置 ScreenScraper 凭据" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "ScreenScraper 返回的 JSON 响应无效" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "解析 API 响应时出错" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper 返回了 HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "未配置 RAWG API 密钥" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "未知的 AI 提供方:%(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "AI 提供方无响应" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "连接错误:%(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "%(scraper)s 不支持配额检查" @@ -904,7 +908,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "无效的 %(field)s:%(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "保存设置失败" @@ -912,12 +916,12 @@ msgstr "保存设置失败" msgid "Request body must be a JSON object" msgstr "请求正文必须是 JSON 对象" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "未知的设置键:%(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "“%(key)s”的值无效:%(reason)s" @@ -955,146 +959,146 @@ msgstr "需要系统 ID" msgid "ES-DE scraper not available" msgstr "ES-DE 抓取器不可用" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "需要管理员权限" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "设置数据必须为对象" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "路径不存在:%(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "权限被拒绝" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "未找到任务" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "未找到任务或任务未运行" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "未找到任务或任务未暂停" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "扫描路径无效" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "未提供路径" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "未提供归档路径" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "压缩包路径无效" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "没有可移除的文件" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "未提供路径" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "路径无效:%(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "未找到 chdman。请安装 MAME 工具。" -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "未找到 chdman" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "未知的重复检测方式:%(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "这些扫描结果已过期。请重新扫描后再删除。" -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "每个游戏至少保留一份副本。未删除任何内容。" -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" msgstr "请在您的个人资料设置中配置 RPCS3 奖杯路径以查看奖杯" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "未找到奖杯组" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "未配置 RPCS3_TROPHY_PATH" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "未找到奖杯路径:%(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "未找到 PSN 游戏" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "同步已在进行中" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "在你的 PSN 奖杯列表中未找到该游戏" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "在 PSN 上未找到该游戏" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "缺少 npwr_id" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "缺少 npwr_id 或 group_id" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "名称不能为空" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "未安装 PSNAWP 库。请运行:pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "未提供 NPSSO 令牌" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "无法连接到 PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "未配置 PSN NPSSO" diff --git a/translations/zh_Hant/LC_MESSAGES/messages.mo b/translations/zh_Hant/LC_MESSAGES/messages.mo index f223aa6fe20f7a7a987772e837cfd751479cc0d7..9deb4ff0ff931bf8e5c466f7fc03935cf5bc853e 100644 GIT binary patch delta 28 kcmZ4Vm1EIYj)pCa+vB**6%386j19F747MMPV|;iU0HlQr2><{9 delta 28 kcmZ4Vm1EIYj)pCa+vB*56%0+S3=FgljJ6+)V|;iU0HjX~1^@s6 diff --git a/translations/zh_Hant/LC_MESSAGES/messages.po b/translations/zh_Hant/LC_MESSAGES/messages.po index 15cd8944..8b517a76 100644 --- a/translations/zh_Hant/LC_MESSAGES/messages.po +++ b/translations/zh_Hant/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-01 09:16+0200\n" "Last-Translator: FULL NAME \n" "Language: zh_Hant\n" @@ -40,8 +40,8 @@ msgstr "需要初始設定" msgid "Password change required" msgstr "需要變更密碼" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "密碼長度至少為 12 個字元" @@ -58,9 +58,9 @@ msgstr "未找到系統,或該系統沒有包含成就的遊戲" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "未找到遊戲" @@ -85,8 +85,8 @@ msgstr "未配置 RetroAchievements" msgid "Could not fetch achievement data" msgstr "無法取得成就資料" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "未找到系統" @@ -100,8 +100,8 @@ msgstr "此系統中沒有帶 RetroAchievements 的遊戲" msgid "Sync already in progress for %(name)s" msgstr "%(name)s 的同步已在進行中" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "該工作屬於其他使用者" @@ -117,7 +117,7 @@ msgstr "登入嘗試次數過多,請稍後再試。" msgid "No user selected" msgstr "未選擇使用者" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "找不到使用者" @@ -149,70 +149,74 @@ msgstr "角色無效" msgid "Username already exists" msgstr "使用者名稱已存在" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "無法刪除您自己的帳戶" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "無法刪除最後一個管理員使用者" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "未登入" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "無效的語言區域" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "無效的主題" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "無效的 RPCS3 獎盃資料夾:%(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "嘗試次數過多,請稍後再試。" -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "需要設定新密碼" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "目前密碼不正確" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "無需更改密碼" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "未提供檔案" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "未選擇檔案" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "檔案類型無效。允許的類型:jpg、png、gif、webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "檔案過大。最大大小為 2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." msgstr "上傳頭像需要 Pillow;請聯絡操作員安裝專案相依套件。" -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "無效的圖片檔案" @@ -229,17 +233,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "附贈光碟與主遊戲必須屬於同一系統" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "發生內部錯誤" @@ -248,7 +252,7 @@ msgstr "發生內部錯誤" msgid "Missing bonus_id or parent_id" msgstr "缺少 bonus_id 或 parent_id" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "缺少 game_id" @@ -369,22 +373,22 @@ msgstr "該遊戲已在您的願望清單中" msgid "Wishlist item not found" msgstr "未找到心願單項目" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "必須填寫手把名稱" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "手把「%(name)s」已存在" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "未找到手把" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "已存在另一個名為「%(name)s」的手把" @@ -465,9 +469,9 @@ msgstr "套用中繼資料失敗。" msgid "No fields to update" msgstr "沒有可更新的欄位" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "未選擇任何遊戲" @@ -495,11 +499,11 @@ msgstr "需要篩選類型和值" msgid "Invalid filter type" msgstr "篩選類型無效" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "AI 未返回任何資料。請檢查您的 API 金鑰和供應商設定。" -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "AI 抓取模組不可用" @@ -517,7 +521,7 @@ msgstr "無效的檔案名稱" msgid "ROM file not found on disk: %(path)s" msgstr "磁碟上未找到 ROM 檔案:%(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "目標位置在已設定的 ROM 根目錄之外" @@ -526,23 +530,23 @@ msgstr "目標位置在已設定的 ROM 根目錄之外" msgid "Cannot rename: %(reason)s" msgstr "無法重新命名:%(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "已存在同名檔案" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "重新命名檔案失敗" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "未指定螢幕截圖" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "螢幕截圖檔案名稱無效" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "未找到螢幕截圖" @@ -619,11 +623,11 @@ msgstr "" msgid "Next disk inserted" msgstr "已插入下一張光碟" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "未知的圖片類型" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "未配置 AI 提供方" @@ -702,52 +706,52 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "尚未設定 Steam API 金鑰。請在「設定 → API 金鑰」中新增。" -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "尚未設定 Steam ID。請在「設定 → API Keys」中新增。" -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " "Steam ID instead." msgstr "無法解析 Steam 個性網址「%(steam_id)s」。請改用你的 17 位 Steam ID。" -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." msgstr "未找到遊戲。請檢查您的 Steam 個人資料和遊戲詳情是否設定為公開。" -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "無法建立 Windows 系統項目" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "未設定 Xbox Client ID。請在「設定 → API 金鑰」中新增。" -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "未配置 Xbox 憑據。請在「設定 → API Keys」中新增。" -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "Xbox 身分驗證失敗。請重新連接你的 Xbox 帳戶。" -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "在 Xbox 遊戲歷史記錄中未找到遊戲。" -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "尚未設定 PSN NPSSO。請在「設定 → API 金鑰」中新增。" -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "取得 PSN 遊戲清單失敗。請先嘗試同步獎盃。" @@ -756,11 +760,11 @@ msgstr "取得 PSN 遊戲清單失敗。請先嘗試同步獎盃。" msgid "System %(name)s does not support RetroAchievements" msgstr "系統 %(name)s 不支援 RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "未知系統" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "未設定 ROM 路徑。請前往「設定 → 路徑」進行設定。" @@ -768,8 +772,8 @@ msgstr "未設定 ROM 路徑。請前往「設定 → 路徑」進行設定。" msgid "Missing required parameters" msgstr "缺少必填參數" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "路徑無效" @@ -782,31 +786,31 @@ msgstr "資料夾不存在:%(folder_path)s" msgid "Missing game_id or new_name" msgstr "缺少 game_id 或 new_name" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "未設定 ROM 路徑" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "未找到 ROM 檔案" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "M3U 檔案必須保留 .m3u 副檔名" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "已存在同名的資料夾" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "沒有可用的抓取標題" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "清理後標題為空" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "產生的檔案名稱包含無效字元" @@ -815,7 +819,7 @@ msgstr "產生的檔案名稱包含無效字元" msgid "Invalid log file" msgstr "日誌檔案無效" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "未找到檔案" @@ -824,56 +828,56 @@ msgstr "未找到檔案" msgid "Log file not found" msgstr "未找到日誌檔案" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "抓取器設定無效:%(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "API 金鑰無效:%(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "尚未設定 TGDB API 金鑰" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "尚未設定 ScreenScraper 憑證" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "ScreenScraper 返回的 JSON 回應無效" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "解析 API 回應時出錯" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "ScreenScraper 回傳了 HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "未設定 RAWG API 金鑰" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "未知的 AI 提供方:%(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "AI 提供方無回應" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "連接錯誤:%(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "%(scraper)s 不支援配額檢查" @@ -904,7 +908,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "無效的 %(field)s:%(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "儲存設定失敗" @@ -912,12 +916,12 @@ msgstr "儲存設定失敗" msgid "Request body must be a JSON object" msgstr "請求正文必須是 JSON 物件" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "未知的設定鍵:%(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "“%(key)s”的值無效:%(reason)s" @@ -955,146 +959,146 @@ msgstr "需要系統 ID" msgid "ES-DE scraper not available" msgstr "ES-DE 抓取器不可用" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "需要管理員權限" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "設定資料必須為物件" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "路徑不存在:%(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "權限被拒絕" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "未找到任務" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "未找到任務或任務未執行" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "未找到任務或任務未暫停" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "掃描路徑無效" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "未提供路徑" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "未提供歸檔路徑" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "壓縮檔路徑無效" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "沒有可移除的檔案" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "未提供路徑" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "路徑無效:%(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "未找到 chdman。請安裝 MAME 工具。" -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "未找到 chdman" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "未知的重複偵測方式:%(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "這些掃描結果已過期。請重新掃描後再刪除。" -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "每個遊戲至少保留一份副本。未刪除任何內容。" -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" msgstr "請在您的個人資料設定中設定 RPCS3 獎盃路徑以檢視獎盃" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "找不到獎盃組" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "未設定 RPCS3_TROPHY_PATH" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "未找到獎盃路徑:%(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "未找到 PSN 遊戲" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "同步已在進行中" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "在你的 PSN 獎盃列表中未找到該遊戲" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "在 PSN 上未找到該遊戲" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "缺少 npwr_id" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "缺少 npwr_id 或 group_id" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "名稱不能為空" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "未安裝 PSNAWP 程式庫。請執行:pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "未提供 NPSSO 權杖" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "無法連線到 PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "未設定 PSN NPSSO" diff --git a/translations/zu/LC_MESSAGES/messages.mo b/translations/zu/LC_MESSAGES/messages.mo index 8c8ecd3427083ac9eab15196ca8d0d3bbc3a21c1..5dde07dae9d9d1199e6618aa42a8c0e91bd161e5 100644 GIT binary patch delta 32 ocmccoob&Q?&W0_F+vB**6%386j19F74B8LIF>XH?$0Q^Q0N1VyWB>pF delta 32 ocmccoob&Q?&W0_F+vB*56%0+S3=FgljM@*zF>XH?$0Q^Q0M~2_VE_OC diff --git a/translations/zu/LC_MESSAGES/messages.po b/translations/zu/LC_MESSAGES/messages.po index ec44064d..5b9dc897 100644 --- a/translations/zu/LC_MESSAGES/messages.po +++ b/translations/zu/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 12:31+0000\n" "PO-Revision-Date: 2026-07-05 16:56+0200\n" "Last-Translator: FULL NAME \n" "Language: zu\n" @@ -42,8 +42,8 @@ msgstr "Kudingeka ukusetha" msgid "Password change required" msgstr "Kudingeka ukushintsha iphasiwedi" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1695 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "Iphasiwedi kufanele libe nezinhlamvu ezingama-12 okungenani" @@ -60,9 +60,9 @@ msgstr "Isistimu ayitholakali noma ayinamidlalo yezimpumelelo" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "Umdlalo awutholakali" @@ -87,8 +87,8 @@ msgstr "I-RetroAchievements ayilungiselelwanga" msgid "Could not fetch achievement data" msgstr "Ayikwazanga ukulanda idatha yezimpumelelo" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "Isistimu ayitholakali" @@ -102,8 +102,8 @@ msgstr "Ayikho imidlalo ene-RetroAchievements kule sistimu" msgid "Sync already in progress for %(name)s" msgstr "Ukuvumelanisa sekuvele kuqhubeka kwa-%(name)s" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "Lo msebenzi ungowomunye umsebenzisi" @@ -119,7 +119,7 @@ msgstr "Imizamo eminingi kakhulu yokungena. Sicela uzame futhi kamuva." msgid "No user selected" msgstr "Akukho msebenzisi okhethiwe" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "Umsebenzisi akatholakali" @@ -151,64 +151,68 @@ msgstr "Indima engavumelekile" msgid "Username already exists" msgstr "Igama lomsebenzisi selivele likhona" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "Awukwazi ukususa i-akhawunti yakho" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "Awukwazi ukususa umsebenzisi wokugcina ongumlawuli" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "Awungenanga" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "I-locale engavumelekile" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "Itimu engavumelekile" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "Ifolda yezindondo ze-RPCS3 engavumelekile: %(reason)s" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "Imizamo eminingi kakhulu. Sicela uzame futhi kamuva." -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "Kudingeka iphasiwedi elisha" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "Iphasiwedi lamanje alilungile" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "Ukushintsha iphasiwedi akudingeki" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "Alikho ifayela elinikeziwe" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "Alikho ifayela elikhethiwe" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "Uhlobo lwefayela olungavumelekile. Okuvunyelwe: jpg, png, gif, webp" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "Ifayela likhulu kakhulu. Usayizi omkhulu ngu-2MB" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." @@ -216,7 +220,7 @@ msgstr "" "Ukulayisha kwe-avatar kudinga i-Pillow; sicela ucele umqhubi ukuba afake " "izidingo zephrojekthi." -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "Ifayela lesithombe elingavumelekile" @@ -233,17 +237,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "Idiski yebhonasi nomdlalo omzali kufanele kube kwisistimu efanayo" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "Kwenzeke iphutha langaphakathi" @@ -252,7 +256,7 @@ msgstr "Kwenzeke iphutha langaphakathi" msgid "Missing bonus_id or parent_id" msgstr "Kushoda i-bonus_id noma i-parent_id" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "Kushoda i-game_id" @@ -375,22 +379,22 @@ msgstr "Lo mdlalo usuvele ukuhlwini lwakho lwezifiso" msgid "Wishlist item not found" msgstr "Into yohlu lwezifiso ayitholakali" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "Kudingeka igama lesilawuli" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "Isilawuli \"%(name)s\" sesivele sikhona" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "Isilawuli asitholakali" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "Esinye isilawuli esibizwa nge-\"%(name)s\" sesivele sikhona" @@ -479,9 +483,9 @@ msgstr "Ukufakwa kwemethadatha kwehlulekile." msgid "No fields to update" msgstr "Akukho zinkambu zokubuyekeza" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "Ayikho imidlalo ekhethiwe" @@ -509,13 +513,13 @@ msgstr "Kudingeka uhlobo lwesihlungi nenani" msgid "Invalid filter type" msgstr "Uhlobo lwesihlungi olungavumelekile" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" "I-AI ayibuyiselanga datha. Hlola i-API key yakho nezilungiselelo " "zomhlinzeki." -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "Imojula ye-AI scraper ayitholakali" @@ -533,7 +537,7 @@ msgstr "Igama lefayela elingavumelekile" msgid "ROM file not found on disk: %(path)s" msgstr "Ifayela le-ROM alitholakali kudiski: %(path)s" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "Indawo ophokophele kuyo ingaphandle kwe-ROM root elungiselelwe" @@ -542,23 +546,23 @@ msgstr "Indawo ophokophele kuyo ingaphandle kwe-ROM root elungiselelwe" msgid "Cannot rename: %(reason)s" msgstr "Ayikwazi ukuqamba kabusha: %(reason)s" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "Ifayela elinalelo gama selivele likhona" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "Yehlulekile ukushintsha igama lefayela" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "Awukho umfanekiso-skrini ocaciswe" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "Igama lefayela lomfanekiso-skrini elingavumelekile" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "Umfanekiso-skrini awutholakali" @@ -637,11 +641,11 @@ msgstr "" msgid "Next disk inserted" msgstr "Idiski elandelayo ifakiwe" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "Uhlobo lwesithombe olungaziwa" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "Akukho mhlinzeki we-AI olungiselelwe" @@ -720,15 +724,15 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "I-Steam API key ayilungiselelwanga. Yifake ku-Izilungiselelo → API Keys." -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "I-Steam ID ayilungiselelwanga. Yifake ku-Izilungiselelo → API Keys." -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " @@ -737,7 +741,7 @@ msgstr "" "Ayikwazanga ukuthola i-Steam vanity URL \"%(steam_id)s\". Kunalokho " "sebenzisa i-Steam ID yakho enezinombolo ezingu-17." -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." @@ -745,35 +749,35 @@ msgstr "" "Ayikho imidlalo etholakele. Qinisekisa ukuthi iphrofayela yakho ye-Steam " "nemininingwane yomdlalo isethelwe ku-Public." -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "Ayikwazanga ukudala okufakiwe kwesistimu ye-Windows" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "I-Xbox Client ID ayilungiselelwanga. Yifake ku-Izilungiselelo → API Keys." -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" "Iziqinisekiso ze-Xbox azilungiselelwanga. Zifake ku-Izilungiselelo → API " "Keys." -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "" "Ukuqinisekisa kwe-Xbox kwehlulekile. Sicela uphinde uxhume i-akhawunti " "yakho ye-Xbox." -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "Ayikho imidlalo etholakele emlandweni wezihloko ze-Xbox." -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "I-PSN NPSSO ayilungiselelwanga. Yifake ku-Izilungiselelo → API Keys." -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" "Yehlulekile ukulanda uhlu lwemidlalo ye-PSN. Zama ukuvumelanisa izindondo" @@ -784,11 +788,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "Isistimu %(name)s ayisekeli i-RetroAchievements" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "Isistimu engaziwa" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" "Indlela ye-ROM ayilungiselelwanga. Yiya ku-Izilungiselelo → Izindlela " @@ -798,8 +802,8 @@ msgstr "" msgid "Missing required parameters" msgstr "Kushoda amapharamitha adingekayo" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "Indlela engavumelekile" @@ -812,31 +816,31 @@ msgstr "Ifolda ayikho: %(folder_path)s" msgid "Missing game_id or new_name" msgstr "Kushoda i-game_id noma i-new_name" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "Ayikho indlela ye-ROM esethiwe" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "Ifayela le-ROM alitholakali" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "Amafayela e-M3U kufanele agcine isandiso se-.m3u" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "Ifolda enegama elisha seyivele ikhona" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "Asikho isihloko esilandiwe esitholakalayo" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "Isihloko asinalutho ngemva kokuhlanzwa" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "Igama lefayela elikhiqiziwe liqukethe izinhlamvu ezingavumelekile" @@ -845,7 +849,7 @@ msgstr "Igama lefayela elikhiqiziwe liqukethe izinhlamvu ezingavumelekile" msgid "Invalid log file" msgstr "Ifayela lelogi elingavumelekile" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "Ifayela alitholakali" @@ -854,56 +858,56 @@ msgstr "Ifayela alitholakali" msgid "Log file not found" msgstr "Ifayela lelogi alitholakali" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "izilungiselelo ze-scraper ezingavumelekile: %(reason)s" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "ama-api keys angavumelekile: %(reason)s" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "Akukho i-TGDB API key elungiselelwe" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "Azikho iziqinisekiso ze-ScreenScraper ezilungiselelwe" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "Impendulo ye-JSON engavumelekile evela ku-ScreenScraper" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "Iphutha ekuhlaziyeni impendulo ye-API" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "I-ScreenScraper iphendule nge-HTTP %(code)s" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "Akukho i-RAWG API key elungiselelwe" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "Umhlinzeki we-AI ongaziwa: %(provider)s" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "Ayikho impendulo evela kumhlinzeki we-AI" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "Iphutha loxhumano: %(error)s" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "Ukuhlolwa kwemvume akusekelwe kwa-%(scraper)s" @@ -934,7 +938,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "I-%(field)s engavumelekile: %(result)s" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 msgid "Failed to save settings" msgstr "Yehlulekile ukulondoloza izilungiselelo" @@ -942,12 +946,12 @@ msgstr "Yehlulekile ukulondoloza izilungiselelo" msgid "Request body must be a JSON object" msgstr "Umzimba wesicelo kufanele ube yi-JSON object" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "Ukhiye wesilungiselelo ongaziwa: %(key)s" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "Inani elingavumelekile lika-'%(key)s': %(reason)s" @@ -985,87 +989,87 @@ msgstr "Kudingeka i-System ID" msgid "ES-DE scraper not available" msgstr "I-ES-DE scraper ayitholakali" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "Kudingeka amalungelo omlawuli" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "I-payload yezilungiselelo kufanele ibe yi-object" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "Indlela ayikho: %(path)s" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "Imvume yenqatshelwe" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "Umsebenzi awutholakali" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "Umsebenzi awutholakali noma awusebenzi" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "Umsebenzi awutholakali noma awumisiwe" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "Indlela yokuskena engavumelekile" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "Ayikho indlela enikeziwe" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "Ayikho indlela ye-archive enikeziwe" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "Indlela ye-archive engavumelekile" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "Akukho mafayela okususa" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "Azikho izindlela ezinikeziwe" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "Indlela engavumelekile: %(path)s" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "I-chdman ayitholakali. Sicela ufake amathuluzi e-MAME." -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "I-chdman ayitholakali" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "Indlela yokuthola okuphindiwe engaziwa: %(method)s" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "Le miphumela yokuskena isiphelelwe yisikhathi. Skena futhi, bese ususa." -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "Gcina okungenani ikhophi eyodwa yomdlalo ngamunye. Akukho okususiwe." -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" @@ -1073,60 +1077,60 @@ msgstr "" "Sicela ulungiselele i-RPCS3 Trophy Path yakho ezilungiselelweni " "zephrofayela yakho ukuze ubuke izindondo" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "Isethi yezindondo ayitholakali" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "I-RPCS3_TROPHY_PATH ayilungiselelwanga" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "Indlela yezindondo ayitholakali: %(path)s" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "Umdlalo we-PSN awutholakali" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "Ukuvumelanisa sekuvele kuyaqhubeka" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "Umdlalo awutholakali ohlwini lwakho lwezindondo ze-PSN" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "Umdlalo awutholakali ku-PSN" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "Kushoda i-npwr_id" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "Kushoda i-npwr_id noma i-group_id" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "Igama alikwazi ukuba yize" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "Umtapo we-PSNAWP awufakiwe. Sebenzisa: pip install psnawp" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "Alikho ithokheni ye-NPSSO enikeziwe" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "Ayikwazanga ukuxhuma ku-PSN" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "I-PSN NPSSO ayilungiselelwanga"
        • ${escapeHtml(t('From'))}${escapeHtml(t('To'))}${escapeHtml(t('Games'))}
          ${escapeHtml(t('From'))}${escapeHtml(t('To'))}${escapeHtml(t('Games'))}
          ${escapeHtml(m.old)}${escapeHtml(m.new)}