diff --git a/.github/workflows/compat-smoke.yml b/.github/workflows/compat-smoke.yml index d156281e..ee6ae6a1 100644 --- a/.github/workflows/compat-smoke.yml +++ b/.github/workflows/compat-smoke.yml @@ -9,7 +9,28 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install deps + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt pytest aiohttp + - name: Compile smoke + run: | + python -m compileall -q __init__.py mjr_am_backend mjr_am_shared + - name: Compat tests + run: | + python -m pytest -q tests/compat/test_import_smoke.py tests/compat/test_version_route_alias.py + + smoke-windows: + runs-on: windows-latest + strategy: + matrix: + python-version: ["3.10", "3.12"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 98121644..cb2847f5 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.12", "3.13"] + python-version: ["3.10", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 1c0ff0a5..2976efac 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,5 @@ update_filesystem_handlers.py # Test outputs and dumps tests/__reports__/ variation_exif_dump.json +run_tests_comfy_cli.bat +scripts/sync_and_test_comfy_cli.bat diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d14ee7..3deb7a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,17 @@ All notable changes to this project are documented in this file. ### Added / Improved - Enhanced metadata extraction for the AC-Step (Ace Step) custom node. - Drag & Drop: stage dropped audio files to the node input (audio file staging). +- Added new filtering/sorting capabilities: + - Filter by workflow type (I2I, I2V, T2I, T2V, V2V, FLF, UPSCL, INPT, TTS, A2A). + - Filter by file size (weight). + - Filter by image resolution/size. +- Added a settings option to configure the output path directly from the UI. +- Added grid settings to configure video preview behavior. +- Refactored frontend/backend integration paths for better ComfyUI compatibility. ### Fixed - Fixed drag & drop issues for images and videos. - Enhanced index status functionality and various bug fixes. +- Fixed multiple UI/filtering issues and improved overall stability. --- diff --git a/README.md b/README.md index 55fdd313..71740e80 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,31 @@ If dependencies are missing at startup, install them explicitly: python ComfyUI-Majoor-AssetsManager/scripts/install-requirements.py ``` +No dependency installation is executed automatically at import time. + +### Required Python Packages + +Runtime requirements are defined in `requirements.txt`: + +- `aiohttp>=3.8.0` +- `aiosqlite>=0.20.0` +- `pillow>=11.0.0` +- `send2trash>=1.8.0` +- `watchdog>=3.0.0` +- `pywin32>=300` (Windows only) + +Install manually with one of: + +```bash +pip install -r ComfyUI-Majoor-AssetsManager/requirements.txt +``` + +or + +```bash +python ComfyUI-Majoor-AssetsManager/scripts/install-requirements.py +``` + ### Tkinter Note (Embedded Python) The native folder picker requires built-in `tkinter`. On embedded Python builds (for example `python_embeded`), `tkinter` is often missing, so the UI falls back to manual path entry. @@ -177,6 +202,37 @@ Package names can vary by distro (e.g. `perl-Image-ExifTool` on some). - to the ComfyUI canvas (staging/inject path), - or to your OS (single file or a ZIP for multi-selection). +## Route Registration (Custom Nodes) + +To avoid duplicate middleware/route injection, use explicit initialization with an aiohttp app: + +```python +from ComfyUI_Majoor_AssetsManager import init + +def setup_with_app(app): + init(app) +``` + +Auth-aware variant (recommended): + +```python +from ComfyUI_Majoor_AssetsManager import register + +def setup_with_app(app, user_manager): + register(app, user_manager=user_manager) +``` + +Notes: +- `register_routes(app)` is idempotent per app instance. +- No `sys.modules` replacement is required. +- Do not call both manual registration and another auto-registration path for the same app. + +### API Namespace & Versioning + +- Extension endpoints are namespaced under `/mjr/am/...` to avoid collisions with ComfyUI native APIs (for example `/api/assets/...`). +- Versioned compatibility path is supported via middleware redirect: `/mjr/am/v1/...` -> `/mjr/am/...` (HTTP 308). +- During startup, the extension performs a route-collision check against existing app routes and logs overlaps if detected. + ## Hotkeys (high level) Hotkeys are ignored while typing inside inputs. @@ -212,12 +268,33 @@ Remote access & API Token (new): This project supports Python 3.10 through 3.12, with upstream ComfyUI guidance recommending Python 3.13 (3.12 as the stable fallback). GitHub Actions now runs both `ci-python.yml` and `python-tests.yml` on `ubuntu-22.04` with `actions/setup-python@v4` targeting Python 3.12 so the runner closely matches the ComfyUI best-practice environment. +ComfyUI compatibility baseline: +- Minimum recommended ComfyUI: `0.13.0` +- Tested frontend baseline: `1.38.x` +- Older/development snapshots may break extension APIs; upgrade both ComfyUI backend and frontend together. + +API reference: +- Full endpoint table: `docs/API_REFERENCE.md` + Run tests across multiple Python versions locally with `tox` (requires those Python versions installed): ```bash pip install tox tox ``` + +Frontend compatibility notes: +- UI classes/components are namespaced with `mjr-am-*` to reduce CSS/JS collisions. +- ComfyUI frontend API access is centralized in `js/app/comfyApiBridge.js` (API, settings, sidebar registration) for legacy/new frontend compatibility. +- i18n detection supports legacy and newer ComfyUI frontend locale surfaces, with fallback: + 1. Stored Majoor language + 2. ComfyUI locale settings/runtime + 3. Browser/document locale + 4. `en-US` +- Majoor API endpoints are namespaced under `/mjr/am/*`. +- Frontend bundle hygiene checks: + - `npm run check:frontend-size` + - `npm run check:frontend-imports` - Auto-scan (on open / on startup) - Status poll interval - Sidebar position (left/right) @@ -230,7 +307,9 @@ tox ## Environment Variables (backend) -Both `MAJOOR_...` and `MJR_...` prefixes are used in this project. Where both variants exist, both are accepted for compatibility. +Canonical namespace is now `MJR_AM_...` to avoid collisions with ComfyUI/global environment variables. + +Compatibility aliases are still accepted (`MAJOOR_...` and some `MJR_...` names), but new deployments should prefer `MJR_AM_...`. - `MAJOOR_OUTPUT_DIRECTORY` - override output directory - `MAJOOR_EXIFTOOL_PATH` / `MAJOOR_EXIFTOOL_BIN` - ExifTool path @@ -348,6 +427,14 @@ Manual fallback (with ComfyUI stopped): python -m pytest -q ``` +Type/lint checks: + +```bash +python -m mypy --config-file mypy.ini +python -m ruff check mjr_am_backend mjr_am_shared tests +python -m black --check mjr_am_backend mjr_am_shared tests +``` + ### Tests (Windows) Batch runners generate both JUnit XML and a styled HTML report in `tests/__reports__/`: @@ -375,13 +462,22 @@ pwsh -File scripts/make_release_zip.ps1 ## Architecture -- `backend/` - Python backend (routes + features) +- `mjr_am_backend/` - Python backend (routes + features) - `features/index` (scan/search), `features/metadata` (extract), `features/tags` (sync), `features/collections` - `routes/handlers` (HTTP), `routes/core` (security/paths/response) - `js/` - frontend extension - `features/grid`, `features/panel`, `features/viewer`, `features/dnd` - `components` (Card/Viewer/Sidebar/menus), `app` (settings/bootstrap), `theme` +## Upgrade Guide (Major ComfyUI Changes) + +When ComfyUI introduces major frontend/backend API changes: +1. Update to a pinned ComfyUI stable tag first. +2. Verify route registration and `/mjr/am/health` in the browser. +3. Run compatibility tests (`tests/compat/*`) and full pytest. +4. Check frontend bridge (`js/app/comfyApiBridge.js`) for API surface changes. +5. Re-validate i18n + settings registration in UI. + ## Viewer Roadmap (bigger items) - GPU grade (WebGL2): exposure/gamma/channels/zebra/false color. diff --git a/__init__.py b/__init__.py index abe87cf1..2caf667c 100644 --- a/__init__.py +++ b/__init__.py @@ -7,10 +7,8 @@ from __future__ import annotations import os import re -import shutil -import subprocess import sys -from typing import Optional +import importlib from pathlib import Path # ComfyUI extension metadata @@ -21,6 +19,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {} WEB_DIRECTORY = None import logging _logger = logging.getLogger("majoor_assets_manager") +_REGISTRY_HOOKS_DONE = False def _read_version_from_pyproject() -> str: @@ -53,142 +52,88 @@ def _detect_branch_from_env() -> str: __version__ = _read_version_from_pyproject() __branch__ = _detect_branch_from_env() -# ---- Windows safety guard ------------------------------------------------- -# Optional maintenance helper. Disabled by default to avoid mutating sibling -# entries in a shared `custom_nodes/` directory. - - -def _windows_extended_path(path: str) -> str: - if path.startswith("\\\\?\\"): - return path - if path.startswith("\\\\"): - # UNC path: \\server\share\... -> \\?\UNC\server\share\... - return "\\\\?\\UNC\\" + path.lstrip("\\") - return "\\\\?\\" + path - - -def _try_delete_windows_path(path: str) -> None: - nt_path = _windows_extended_path(path) - try: - os.unlink(nt_path) - return - except IsADirectoryError: - pass - except FileNotFoundError: - return - except OSError: - # Fall through to directory removal / cmd fallback. - pass - - try: - shutil.rmtree(nt_path) - return - except FileNotFoundError: - return - except OSError: - pass - - # Last-resort fallback via cmd builtins (kept best-effort and silent). - try: - subprocess.run(["cmd", "/c", f'del /f /q "{nt_path}"'], check=False, capture_output=True) - subprocess.run(["cmd", "/c", f'rd /s /q "{nt_path}"'], check=False, capture_output=True) - except Exception: - pass - - -def _cleanup_windows_reserved_custom_nodes() -> Optional[str]: - try: - if os.name != "nt": - return None - custom_nodes_dir = Path(__file__).resolve().parent.parent - if not custom_nodes_dir.exists(): - return None - - try: - entries = os.listdir(str(custom_nodes_dir)) - except OSError: - return None - - reserved = {"CON", "PRN", "AUX", "NUL"} - reserved.update({f"COM{i}" for i in range(1, 10)}) - reserved.update({f"LPT{i}" for i in range(1, 10)}) - - for name in entries: - # Windows device names are reserved even with extensions and trailing dots/spaces. - cleaned = str(name or "").strip().rstrip(". ") - base = cleaned.split(".", 1)[0].strip().upper() - if base not in reserved: - continue - try: - victim = str((custom_nodes_dir / name).resolve()) - except Exception: - victim = str(custom_nodes_dir / name) - _try_delete_windows_path(victim) - return victim - except Exception: - return None - return None - - root = Path(__file__).resolve().parent -if str(root) not in sys.path: - sys.path.append(str(root)) if WEB_DIRECTORY is None: WEB_DIRECTORY = str(root / "js") - -def _warn_missing_dependencies() -> None: - try: - import importlib.util as _ilu - required_modules = ["aiohttp", "PIL", "send2trash", "aiosqlite"] - if os.name == "nt": - required_modules.append("win32api") - missing = [m for m in required_modules if _ilu.find_spec(m) is None] - if missing: - _logger.warning( - "missing dependencies: %s. install manually: \"%s\" -m pip install -r \"%s\"", - ", ".join(missing), - sys.executable, - root / "requirements.txt", - ) - except Exception: - pass +# Ensure extension-local packages (e.g. `mjr_am_backend`) are importable even when +# ComfyUI loads this module by file path without adding custom node root to sys.path. +try: + root_str = str(root) + if root_str not in sys.path: + sys.path.insert(0, root_str) +except Exception: + _logger.debug("failed to ensure extension root on sys.path", exc_info=True) def init_prompt_server() -> None: + """ + Initialize routes against ComfyUI PromptServer. + + This is kept as a convenience wrapper for ComfyUI runtime. + """ + global _REGISTRY_HOOKS_DONE try: - from mjr_am_backend.routes.registry import register_all_routes, _install_observability_on_prompt_server - register_all_routes() - _install_observability_on_prompt_server() + _registry = importlib.import_module("mjr_am_backend.routes.registry") + + register_all_routes = getattr(_registry, "register_all_routes", None) + register_routes = getattr(_registry, "register_routes", None) + install_observability = getattr(_registry, "_install_observability_on_prompt_server", None) + + # Always register decorator-backed routes first; this does not require app. + if not _REGISTRY_HOOKS_DONE and callable(register_all_routes): + register_all_routes() + + # Keep legacy behavior expected by import-hook tests and older runtime wiring. + if not _REGISTRY_HOOKS_DONE and callable(install_observability): + install_observability() + _REGISTRY_HOOKS_DONE = True + + from server import PromptServer # type: ignore + app = getattr(PromptServer.instance, "app", None) + if app is None: + _logger.debug("PromptServer app is not available yet; route table registered only") + return + if callable(register_routes): + register_routes(app) except Exception: _logger.exception("failed to initialize prompt server routes") -def init(app_or_prompt_server=None) -> None: +def init(app) -> None: """Public explicit initializer for host integrations and tests.""" - if app_or_prompt_server is None: - init_prompt_server() - return + if app is None: + raise ValueError("init(app) requires a valid aiohttp app instance") try: from mjr_am_backend.routes.registry import register_routes - register_routes(app_or_prompt_server) + register_routes(app) except Exception: _logger.exception("failed to initialize routes on provided app") -_warn_missing_dependencies() -if str(os.environ.get("MJR_AM_CLEANUP_RESERVED_NAMES", "")).strip().lower() in ("1", "true", "yes", "on"): +def register(app, user_manager=None) -> None: + """ + Explicit host integration entrypoint. + + Args: + app: aiohttp application instance + user_manager: optional ComfyUI user manager for auth-aware route policies + """ + if app is None: + raise ValueError("register(app, user_manager=None) requires a valid aiohttp app instance") try: - removed = _cleanup_windows_reserved_custom_nodes() - if removed: - _logger.info("removed Windows reserved custom node entry: %s", removed) + from mjr_am_backend.routes.registry import register_routes + register_routes(app, user_manager=user_manager) except Exception: - pass + _logger.exception("failed to register routes on provided app") + + try: - if "server" in sys.modules: - init_prompt_server() + # Minimal ComfyUI custom_nodes import-time behavior: + # register routes/UI via explicit init path, no sys.modules mutation. + init_prompt_server() except Exception: - _logger.exception("failed to auto-initialize prompt server routes") + _logger.exception("failed to initialize at import time") __all__ = [ "NODE_CLASS_MAPPINGS", @@ -197,5 +142,6 @@ __all__ = [ "__version__", "__branch__", "init", + "register", "init_prompt_server", ] diff --git a/_old_repo_ref b/_old_repo_ref deleted file mode 160000 index 8ca9e0e0..00000000 --- a/_old_repo_ref +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8ca9e0e0fd445e7617f639adbd919d3c58659291 diff --git a/backend/__init__.py b/backend/__init__.py deleted file mode 100644 index 6a27adeb..00000000 --- a/backend/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Compatibility shim: expose `backend` as alias of `mjr_am_backend`.""" -from __future__ import annotations - -import importlib - -_impl = importlib.import_module("mjr_am_backend") - -# Re-export package attributes -for _k in dir(_impl): - if _k.startswith("__") and _k not in {"__all__", "__doc__", "__path__", "__spec__"}: - continue - globals()[_k] = getattr(_impl, _k) - -# Make submodule imports like `backend.routes...` resolve to `mjr_am_backend/...` -try: - __path__ = _impl.__path__ # type: ignore[attr-defined] -except Exception: - pass - -__all__ = getattr(_impl, "__all__", []) diff --git a/backend/config.py b/backend/config.py deleted file mode 100644 index db0cf4c5..00000000 --- a/backend/config.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Compatibility shim module: backend.config -> mjr_am_backend.config""" -from mjr_am_backend.config import * # noqa: F401,F403 diff --git a/backend/shared.py b/backend/shared.py deleted file mode 100644 index 8ef81cd0..00000000 --- a/backend/shared.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Compatibility shim module: backend.shared -> mjr_am_backend.shared""" -from mjr_am_backend.shared import * # noqa: F401,F403 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 00000000..abc76d39 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,76 @@ +# Majoor API Reference + +## Compatibility +- Base prefix: `/mjr/am` +- Version alias: `/mjr/am/v1/*` (redirects to `/mjr/am/*`) +- Legacy alias kept for compatibility: `/majoor/version` + +## Core Endpoints +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/mjr/am/health` | Extension health summary | +| `GET` | `/mjr/am/health/counters` | Indexed counters | +| `GET` | `/mjr/am/health/db` | DB health/diagnostics | +| `GET` | `/mjr/am/config` | Runtime config snapshot | +| `GET` | `/mjr/am/version` | Extension version info | +| `GET` | `/mjr/am/tools/status` | ExifTool/ffprobe availability | + +## Settings +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/mjr/am/settings/probe-backend` | Set probe mode (`auto`, `exiftool`, `ffprobe`, `both`) | +| `GET` | `/mjr/am/settings/output-directory` | Read output directory override | +| `POST` | `/mjr/am/settings/output-directory` | Set output directory override | +| `GET` | `/mjr/am/settings/metadata-fallback` | Read fallback toggles (`image`, `media`) | +| `POST` | `/mjr/am/settings/metadata-fallback` | Set fallback toggles (`image`, `media`) | + +## Search/List +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/mjr/am/search` | Search with filters/scope | +| `GET` | `/mjr/am/list` | Paginated asset list | +| `GET` | `/mjr/am/asset/{asset_id}` | Asset details by id | +| `GET` | `/mjr/am/metadata` | Metadata lookup by file reference | + +## Indexing/Scan +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/mjr/am/scan` | Scan a scope/root | +| `POST` | `/mjr/am/index-files` | Index explicit files | +| `POST` | `/mjr/am/index/reset` | Reset/rebuild index | + +## Asset Operations (Sensitive) +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/mjr/am/open-in-folder` | Open asset location in OS | +| `POST` | `/mjr/am/asset/rating` | Update rating | +| `POST` | `/mjr/am/asset/tags` | Update tags | +| `POST` | `/mjr/am/asset/rename` | Rename asset file | +| `POST` | `/mjr/am/asset/delete` | Delete asset file | +| `POST` | `/mjr/am/assets/delete` | Bulk delete assets | +| `POST` | `/mjr/am/stage-to-input` | Copy/link asset to input | + +## Collections/Custom Roots +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/mjr/am/collections` | List collections | +| `POST` | `/mjr/am/collections/*` | Create/update/delete collections | +| `GET` | `/mjr/am/custom-roots` | List custom roots | +| `POST` | `/mjr/am/custom-roots` | Add/remove custom roots | + +## Maintenance and Utilities +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/mjr/am/db/optimize` | DB optimize/cleanup | +| `POST` | `/mjr/am/db/cleanup-case-duplicates` | Remove historical path-case duplicates (Windows) | +| `POST` | `/mjr/am/db/force-delete` | Emergency DB rebuild | +| `GET` | `/mjr/am/download` | Stream/download asset | +| `POST` | `/mjr/am/batch-zip` | Build batch ZIP token | +| `GET` | `/mjr/am/batch-zip/{token}` | Download generated ZIP | +| `GET` | `/mjr/am/date-histogram` | Calendar histogram | +| `GET` | `/mjr/am/duplicates/alerts` | Duplicate alerts | + +## Security Notes +- State-changing routes require CSRF protection checks. +- Write routes are guarded by Safe Mode / operation gates. +- Optional API token: `X-MJR-Token` or `Authorization: Bearer `. diff --git a/docs/DB_MAINTENANCE.md b/docs/DB_MAINTENANCE.md index 3a192fab..989ba500 100644 --- a/docs/DB_MAINTENANCE.md +++ b/docs/DB_MAINTENANCE.md @@ -83,8 +83,8 @@ If the Delete DB button reports that files could not be deleted (another process | File | Role | |---|---| -| `backend/routes/handlers/db_maintenance.py` | `/db/optimize` and `/db/force-delete` endpoints | -| `backend/adapters/db/sqlite.py` | DB adapter with malformed detection and online recovery | +| `mjr_am_backend/routes/handlers/db_maintenance.py` | `/db/optimize` and `/db/force-delete` endpoints | +| `mjr_am_backend/adapters/db/sqlite.py` | DB adapter with malformed detection and online recovery | | `js/features/status/StatusDot.js` | Frontend status polling, corruption detection, Reset/Delete buttons | | `js/api/client.js` | `forceDeleteDb()` API call | diff --git a/js/api/client.js b/js/api/client.js index c694fc80..b96d4d79 100644 --- a/js/api/client.js +++ b/js/api/client.js @@ -464,6 +464,14 @@ export async function setProbeBackendMode(mode) { return post("/mjr/am/settings/probe-backend", { mode }); } +export async function getMetadataFallbackSettings() { + return get(ENDPOINTS.SETTINGS_METADATA_FALLBACK); +} + +export async function setMetadataFallbackSettings({ image, media } = {}) { + return post(ENDPOINTS.SETTINGS_METADATA_FALLBACK, { image, media }); +} + export async function getOutputDirectorySetting() { return get(ENDPOINTS.SETTINGS_OUTPUT_DIRECTORY); } diff --git a/js/api/endpoints.js b/js/api/endpoints.js index 1e33f7fd..4d382595 100644 --- a/js/api/endpoints.js +++ b/js/api/endpoints.js @@ -14,7 +14,7 @@ export const ENDPOINTS = { HEALTH_DB: "/mjr/am/health/db", STATUS: "/mjr/am/status", CONFIG: "/mjr/am/config", - VERSION: "/majoor/version", + VERSION: "/mjr/am/version", // Index & Search SCAN: "/mjr/am/scan", @@ -35,6 +35,7 @@ export const ENDPOINTS = { OPEN_IN_FOLDER: "/mjr/am/open-in-folder", TOOLS_STATUS: "/mjr/am/tools/status", SETTINGS_OUTPUT_DIRECTORY: "/mjr/am/settings/output-directory", + SETTINGS_METADATA_FALLBACK: "/mjr/am/settings/metadata-fallback", // View (ComfyUI native) VIEW: "/view", @@ -109,6 +110,11 @@ export function buildListURL(params = {}) { kind = null, hasWorkflow = null, minRating = null, + minSizeMB = null, + maxSizeMB = null, + minWidth = null, + minHeight = null, + workflowType = null, dateRange = null, dateExact = null, sort = null, @@ -131,6 +137,21 @@ export function buildListURL(params = {}) { if (minRating !== null && minRating !== undefined && Number(minRating) > 0) { url += `&min_rating=${encodeURIComponent(String(minRating))}`; } + if (minSizeMB !== null && minSizeMB !== undefined && Number(minSizeMB) > 0) { + url += `&min_size_mb=${encodeURIComponent(String(minSizeMB))}`; + } + if (maxSizeMB !== null && maxSizeMB !== undefined && Number(maxSizeMB) > 0) { + url += `&max_size_mb=${encodeURIComponent(String(maxSizeMB))}`; + } + if (minWidth !== null && minWidth !== undefined && Number(minWidth) > 0) { + url += `&min_width=${encodeURIComponent(String(minWidth))}`; + } + if (minHeight !== null && minHeight !== undefined && Number(minHeight) > 0) { + url += `&min_height=${encodeURIComponent(String(minHeight))}`; + } + if (workflowType) { + url += `&workflow_type=${encodeURIComponent(String(workflowType))}`; + } if (dateRange) { url += `&date_range=${encodeURIComponent(dateRange)}`; } diff --git a/js/app/comfyApiBridge.js b/js/app/comfyApiBridge.js new file mode 100644 index 00000000..4dc7ba83 --- /dev/null +++ b/js/app/comfyApiBridge.js @@ -0,0 +1,54 @@ +/** + * ComfyUI frontend compatibility bridge. + * Centralizes access to API/settings/sidebar registration across legacy/new UIs. + */ + +export function getComfyApi(app) { + if (!app || typeof app !== "object") return null; + return app.api || app?.ui?.api || app?.ui?.app?.api || null; +} + +export function getComfyApp() { + try { + if (typeof globalThis !== "undefined" && globalThis?.app) return globalThis.app; + } catch {} + try { + if (typeof window !== "undefined" && window?.app) return window.app; + } catch {} + try { + if (typeof window !== "undefined" && window?.comfyAPI?.app) return window.comfyAPI.app; + } catch {} + return null; +} + +export function getSettingsApi(app) { + if (!app || typeof app !== "object") return null; + return ( + app?.ui?.settings || + app?.settings || + app?.ui?.api?.settings || + app?.api?.settings || + null + ); +} + +export function getSettingValue(app, key) { + const settingsApi = getSettingsApi(app); + if (!settingsApi || typeof settingsApi?.getSettingValue !== "function") return null; + try { + return settingsApi.getSettingValue(key); + } catch { + return null; + } +} + +export function registerSidebarTabCompat(app, tabDef) { + try { + const manager = app?.extensionManager || app?.ui?.extensionManager || null; + if (manager && typeof manager.registerSidebarTab === "function") { + manager.registerSidebarTab(tabDef); + return true; + } + } catch {} + return false; +} diff --git a/js/app/config.js b/js/app/config.js index 38834cfc..5021e40d 100644 --- a/js/app/config.js +++ b/js/app/config.js @@ -81,7 +81,7 @@ export const APP_DEFAULTS = Object.freeze({ GRID_SHOW_DETAILS_DIMENSIONS: true, GRID_SHOW_DETAILS_GENTIME: true, GRID_SHOW_WORKFLOW_DOT: true, - GRID_VIDEO_HOVER_AUTOPLAY: false, + GRID_VIDEO_AUTOPLAY_MODE: "off", // "off" | "hover" | "always" UI_CARD_HOVER_COLOR: "#3D3D3D", UI_CARD_SELECTION_COLOR: "#4A90E2", UI_RATING_COLOR: "#FF9500", @@ -93,6 +93,7 @@ export const APP_DEFAULTS = Object.freeze({ BADGE_VIDEO_COLOR: "#9C27B0", BADGE_AUDIO_COLOR: "#FF9800", BADGE_MODEL3D_COLOR: "#4CAF50", + BADGE_DUPLICATE_ALERT_COLOR: "#FF1744", // Pagination DEFAULT_PAGE_SIZE: 100, diff --git a/js/app/i18n.js b/js/app/i18n.js index b25eabb6..a393d220 100644 --- a/js/app/i18n.js +++ b/js/app/i18n.js @@ -1,19 +1,22 @@ -/** +/** * Internationalization support for Majoor Assets Manager. * Detects ComfyUI language and provides translations for the entire UI. */ import { GENERATED_TRANSLATIONS } from "./i18n.generated.js"; +import { getSettingValue } from "./comfyApiBridge.js"; const DEFAULT_LANG = "en-US"; let currentLang = DEFAULT_LANG; let _langChangeListeners = []; +const LANG_STORAGE_KEYS = ["mjr_lang", "majoor.lang"]; +const _missingTranslationKeys = new Set(); -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // DICTIONARY - Full UI translations -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── const DICTIONARY = { "en-US": { - // ─── Settings Categories ─── + // ─── Settings Categories ─── "cat.grid": "Grid", "cat.cards": "Cards", "cat.badges": "Badges", @@ -23,7 +26,7 @@ const DICTIONARY = { "cat.security": "Security", "cat.remote": "Remote Access", - // ─── Settings: Grid ─── + // ─── Settings: Grid ─── "setting.grid.minsize.name": "Majoor: Thumbnail Size (px)", "setting.grid.minsize.desc": "Minimum size of thumbnails in the grid. May require reopening the panel.", "setting.grid.cardSize.group": "Card size", @@ -43,13 +46,13 @@ const DICTIONARY = { "setting.grid.pagesize.name": "Majoor: Grid Page Size", "setting.grid.pagesize.desc": "Number of assets loaded per page/request in the grid.", - // ─── Settings: Viewer ─── + // ─── Settings: Viewer ─── "setting.viewer.pan.name": "Majoor: Pan without Zoom", "setting.viewer.pan.desc": "Allow panning the image even at zoom level 1.", "setting.minimap.enabled.name": "Majoor: Enable Minimap", "setting.minimap.enabled.desc": "Global activation of the workflow minimap.", - // ─── Settings: Scanning ─── + // ─── Settings: Scanning ─── "setting.scan.startup.name": "Majoor: Auto-scan on Startup", "setting.scan.startup.desc": "Start a background scan as soon as ComfyUI loads.", "setting.watcher.name": "Majoor: File Watcher", @@ -63,7 +66,7 @@ const DICTIONARY = { "setting.sync.rating.name": "Majoor: Sync Rating/Tags to Files", "setting.sync.rating.desc": "Write ratings and tags into file metadata (ExifTool).", - // ─── Settings: Badge Colors ─── + // ─── Settings: Badge Colors ─── "cat.badgeColors": "Badge colors", "setting.starColor": "Star color", "setting.starColor.tooltip": "Color of rating stars on thumbnails (hex, e.g. #FFD45A)", @@ -76,7 +79,7 @@ const DICTIONARY = { "setting.badgeModel3dColor": "3D model badge color", "setting.badgeModel3dColor.tooltip": "Color for 3D model badges: OBJ, FBX, GLB, GLTF (hex)", - // ─── Settings: Advanced ─── + // ─── Settings: Advanced ─── "setting.obs.enabled.name": "Majoor: Enable Detailed Logs", "setting.obs.enabled.desc": "Enable detailed backend logs for debugging.", "setting.probe.mode.name": "Majoor: Metadata Backend", @@ -84,7 +87,7 @@ const DICTIONARY = { "setting.language.name": "Majoor: Language", "setting.language.desc": "Choose the language for the Assets Manager interface. Reload required to fully apply.", - // ─── Settings: Security ─── + // ─── Settings: Security ─── "setting.sec.safe.name": "Majoor: Safe Mode", "setting.sec.safe.desc": "When enabled, rating/tags writes are blocked unless explicitly authorized.", "setting.sec.remote.name": "Majoor: Allow Remote Full Access", @@ -103,13 +106,13 @@ const DICTIONARY = { "setting.sec.token.desc": "Store the write authorization token. Majoor inserts it in the Authorization and X-MJR-Token headers.", "setting.sec.token.placeholder": "Leave blank to disable.", - // ─── Panel: Tabs ─── + // ─── Panel: Tabs ─── "tab.output": "Output", "tab.input": "Input", "tab.custom": "Custom", - // ─── Panel: Buttons ─── - "btn.add": "Add…", + // ─── Panel: Buttons ─── + "btn.add": "Add…", "btn.remove": "Remove", "btn.adding": "Adding...", "btn.removing": "Removing...", @@ -128,7 +131,7 @@ const DICTIONARY = { "btn.copyPrompt": "Copy Prompt", "btn.close": "Close", - // ─── Panel: Labels ─── + // ─── Panel: Labels ─── "label.folder": "Folder", "label.type": "Type", "label.workflow": "Workflow", @@ -138,10 +141,10 @@ const DICTIONARY = { "label.sort": "Sort", "label.collections": "Collections", "label.filters": "Filters", - "label.selectFolder": "Select folder…", + "label.selectFolder": "Select folder…", "label.thisFolder": "this folder", - // ─── Panel: Filters ─── + // ─── Panel: Filters ─── "filter.all": "All", "filter.images": "Images", "filter.videos": "Videos", @@ -156,7 +159,7 @@ const DICTIONARY = { "filter.last7days": "Last 7 days", "filter.last30days": "Last 30 days", - // ─── Panel: Sort ─── + // ─── Panel: Sort ─── "sort.newest": "Newest first", "sort.oldest": "Oldest first", "sort.nameAZ": "Name A-Z", @@ -166,7 +169,7 @@ const DICTIONARY = { "sort.sizeDesc": "Size (large)", "sort.sizeAsc": "Size (small)", - // ─── Panel: Status ─── + // ─── Panel: Status ─── "status.checking": "Checking...", "status.ready": "Ready", "status.scanning": "Scanning...", @@ -206,20 +209,20 @@ const DICTIONARY = { "status.dbCorruptedHint": "Use the \"Delete DB\" button below to force-delete and rebuild the index.", "status.retryFailed": "Retry failed", - // ─── Scopes ─── + // ─── Scopes ─── "scope.all": "Inputs + Outputs", "scope.allFull": "All (Inputs + Outputs)", "scope.input": "Inputs", "scope.output": "Outputs", "scope.custom": "Custom", - // ─── Tools ─── + // ─── Tools ─── "tool.exiftool": "ExifTool metadata", "tool.exiftool.hint": "PNG/WEBP workflow data (uses ExifTool)", "tool.ffprobe": "FFprobe video stats", "tool.ffprobe.hint": "Video duration, FPS, and resolution (uses FFprobe)", - // ─── Panel: Messages ─── + // ─── Panel: Messages ─── "msg.noCollections": "No collections yet.", "msg.addCustomFolder": "Add a custom folder to browse.", "msg.noResults": "No results found.", @@ -229,7 +232,7 @@ const DICTIONARY = { "msg.noGenerationData": "No generation data found for this file.", "msg.rawMetadata": "Raw metadata", - // ─── Viewer ─── + // ─── Viewer ─── "viewer.genInfo": "Generation Info", "viewer.workflow": "Workflow", "viewer.metadata": "Metadata", @@ -238,7 +241,7 @@ const DICTIONARY = { "viewer.copySuccess": "Copied to clipboard!", "viewer.copyFailed": "Failed to copy", - // ─── Sidebar ─── + // ─── Sidebar ─── "sidebar.details": "Details", "sidebar.preview": "Preview", "sidebar.rating": "Rating", @@ -251,7 +254,7 @@ const DICTIONARY = { "sidebar.size": "Size", "sidebar.genTime": "Generation time", - // ─── Context Menu ─── + // ─── Context Menu ─── "ctx.openViewer": "Open in viewer", "ctx.loadWorkflow": "Load workflow", "ctx.copyPath": "Copy path", @@ -265,7 +268,7 @@ const DICTIONARY = { "ctx.createCollection": "Create collection...", "ctx.exitCollection": "Exit collection view", - // ─── Dialogs ─── + // ─── Dialogs ─── "dialog.confirm": "Confirm", "dialog.cancel": "Cancel", "dialog.delete.title": "Delete file?", @@ -285,14 +288,14 @@ const DICTIONARY = { "dialog.createCollection": "Create collection", "dialog.collectionPlaceholder": "My collection", - // ─── Toasts ─── + // ─── Toasts ─── "toast.scanStarted": "Scan started", "toast.scanComplete": "Scan complete", "toast.scanFailed": "Scan failed", "toast.resetTriggered": "Reset triggered: Reindexing all files...", "toast.resetStarted": "Index reset started. Files will be reindexed in the background.", "toast.resetFailed": "Failed to reset index", - "toast.resetFailedCorrupt": "Reset failed — database is corrupted. Use the \"Delete DB\" button to force-delete and rebuild.", + "toast.resetFailedCorrupt": "Reset failed — database is corrupted. Use the \"Delete DB\" button to force-delete and rebuild.", "toast.dbDeleteTriggered": "Deleting database and rebuilding...", "toast.dbDeleteSuccess": "Database deleted and rebuilt. Files are being reindexed.", "toast.dbDeleteFailed": "Failed to delete database", @@ -356,9 +359,35 @@ const DICTIONARY = { "toast.filesDeletedShort": "{n} files deleted", "toast.filesDeletedShortPartial": "{success} deleted, {failed} failed", "toast.copiedToClipboardNamed": "{name} copied to clipboard!", - "toast.rescanningFile": "Rescanning file…", + "toast.rescanningFile": "Rescanning file…", - // ─── Hotkeys ─── + // ─── Hotkeys ─── + // Missing keys filled from runtime usage + "cat.search": "Search", + "setting.badgeDuplicateAlertColor": "Duplicate alert badge color", + "setting.badgeDuplicateAlertColor.tooltip": "Alert color used when duplicate extension badges are shown (e.g. PNG+).", + "setting.grid.videoAutoplayMode.name": "Majoor: Video Autoplay", + "setting.grid.videoAutoplayMode.desc": "Controls video thumbnail playback in the grid. Off: static frame. Hover: play on mouse hover. Always: loop while visible.", + "setting.grid.videoAutoplayMode.off": "Off", + "setting.grid.videoAutoplayMode.hover": "Hover", + "setting.grid.videoAutoplayMode.always": "Always", + "setting.watcher.enabled.label": "Watcher enabled", + "setting.watcher.debounce.label": "Watcher debounce (ms)", + "setting.watcher.dedupe.label": "Watcher dedupe window (ms)", + "setting.search.maxResults.name": "Majoor: Search max results", + "setting.search.maxResults.desc": "Maximum number of results returned by search endpoints.", + "btn.dbSave": "Save DB", + "btn.dbRestore": "Restore DB", + "status.dbBackupSelectHint": "Select a DB backup to restore", + "status.dbBackupLoading": "Loading DB backups...", + "status.dbSaveHint": "Create a DB backup snapshot now.", + "status.dbRestoreHint": "Restore selected DB backup and restart indexing.", + "status.dbHealthOk": "DB health: ok", + "status.indexHealthOk": "Index health: ok", + "status.toast.info": "Index status: checking", + "status.toast.success": "Index status: ready", + "status.toast.warning": "Index status: attention needed", + "status.toast.error": "Index status: error", "hotkey.scan": "Scan (S)", "hotkey.search": "Search (Ctrl+F)", "hotkey.details": "Toggle details (D)", @@ -368,19 +397,19 @@ const DICTIONARY = { }, "fr-FR": { - // ─── Catégories Settings ─── + // ─── Catégories Settings ─── "cat.grid": "Grille", "cat.cards": "Cartes", "cat.badges": "Badges", "cat.viewer": "Visionneuse", "cat.scanning": "Scan", - "cat.advanced": "Avancé", - "cat.security": "Sécurité", - "cat.remote": "Accès distant", + "cat.advanced": "Avancé", + "cat.security": "Sécurité", + "cat.remote": "Accès distant", - // ─── Settings: Grille ─── + // ─── Settings: Grille ─── "setting.grid.minsize.name": "Majoor: Taille des vignettes (px)", - "setting.grid.minsize.desc": "Taille minimale des vignettes. Peut nécessiter de réouvrir le panneau.", + "setting.grid.minsize.desc": "Taille minimale des vignettes. Peut nécessiter de réouvrir le panneau.", "setting.grid.cardSize.group": "Taille des cartes", "setting.grid.cardSize.name": "Majoor: Taille des cartes", "setting.grid.cardSize.desc": "Choisir un preset de taille de carte : small, medium ou large.", @@ -390,62 +419,62 @@ const DICTIONARY = { "setting.grid.gap.name": "Majoor: Espacement (px)", "setting.grid.gap.desc": "Espace entre les vignettes.", "setting.sidebar.pos.name": "Majoor: Position sidebar", - "setting.sidebar.pos.desc": "Afficher la sidebar à gauche ou à droite. Recharger la page pour appliquer.", - "setting.siblings.hide.name": "Majoor: Masquer aperçus PNG", - "setting.siblings.hide.desc": "Si une vidéo a un fichier .png correspondant, masquer le .png de la grille.", - "setting.nav.infinite.name": "Majoor: Défilement infini", + "setting.sidebar.pos.desc": "Afficher la sidebar à gauche ou à droite. Recharger la page pour appliquer.", + "setting.siblings.hide.name": "Majoor: Masquer aperçus PNG", + "setting.siblings.hide.desc": "Si une vidéo a un fichier .png correspondant, masquer le .png de la grille.", + "setting.nav.infinite.name": "Majoor: Défilement infini", "setting.nav.infinite.desc": "Charger automatiquement plus de fichiers en scrollant.", "setting.grid.pagesize.name": "Majoor: Taille de page de la grille", "setting.grid.pagesize.desc": "Nombre d'assets charg?s par page/requ?te dans la grille.", - // ─── Settings: Visionneuse ─── + // ─── Settings: Visionneuse ─── "setting.viewer.pan.name": "Majoor: Pan sans zoom", - "setting.viewer.pan.desc": "Permet de déplacer l'image même sans zoom.", + "setting.viewer.pan.desc": "Permet de déplacer l'image même sans zoom.", "setting.minimap.enabled.name": "Majoor: Activer Minimap", "setting.minimap.enabled.desc": "Activation globale de la minimap du workflow.", - // ─── Settings: Scan ─── - "setting.scan.startup.name": "Majoor: Auto-scan au démarrage", - "setting.scan.startup.desc": "Lancer un scan en arrière-plan dès le chargement de ComfyUI.", + // ─── Settings: Scan ─── + "setting.scan.startup.name": "Majoor: Auto-scan au démarrage", + "setting.scan.startup.desc": "Lancer un scan en arrière-plan dès le chargement de ComfyUI.", "setting.watcher.name": "Majoor: Surveillance fichiers", - "setting.watcher.desc": "Surveiller les dossiers output et custom pour indexer automatiquement les fichiers ajoutés manuellement.", - "setting.watcher.debounce.name": "Majoor : Délai du watcher", - "setting.watcher.debounce.desc": "Regroupe les événements du watcher pendant N ms avant l'indexation.", - "setting.watcher.debounce.error": "Échec de la mise à jour du délai du watcher.", - "setting.watcher.dedupe.name": "Majoor : Fenêtre de déduplication du watcher", - "setting.watcher.dedupe.desc": "Durée (ms) pendant laquelle un fichier reçu est considéré comme déjà traité.", - "setting.watcher.dedupe.error": "Échec de la mise à jour de la fenêtre de déduplication.", + "setting.watcher.desc": "Surveiller les dossiers output et custom pour indexer automatiquement les fichiers ajoutés manuellement.", + "setting.watcher.debounce.name": "Majoor : Délai du watcher", + "setting.watcher.debounce.desc": "Regroupe les événements du watcher pendant N ms avant l'indexation.", + "setting.watcher.debounce.error": "Échec de la mise à jour du délai du watcher.", + "setting.watcher.dedupe.name": "Majoor : Fenêtre de déduplication du watcher", + "setting.watcher.dedupe.desc": "Durée (ms) pendant laquelle un fichier reçu est considéré comme déjà traité.", + "setting.watcher.dedupe.error": "Échec de la mise à jour de la fenêtre de déduplication.", "setting.sync.rating.name": "Majoor: Sync rating/tags vers fichiers", - "setting.sync.rating.desc": "Écrire les notes et tags dans les métadonnées des fichiers (ExifTool).", + "setting.sync.rating.desc": "Écrire les notes et tags dans les métadonnées des fichiers (ExifTool).", - // ─── Settings: Couleurs des badges ─── + // ─── Settings: Couleurs des badges ─── "cat.badgeColors": "Couleurs des badges", - "setting.starColor": "Couleur des étoiles", - "setting.starColor.tooltip": "Couleur des étoiles de notation sur les vignettes (hex, ex. #FFD45A)", + "setting.starColor": "Couleur des étoiles", + "setting.starColor.tooltip": "Couleur des étoiles de notation sur les vignettes (hex, ex. #FFD45A)", "setting.badgeImageColor": "Couleur badge image", "setting.badgeImageColor.tooltip": "Couleur des badges image : PNG, JPG, WEBP, GIF, BMP, TIF (hex)", - "setting.badgeVideoColor": "Couleur badge vidéo", - "setting.badgeVideoColor.tooltip": "Couleur des badges vidéo : MP4, WEBM, MOV, AVI, MKV (hex)", + "setting.badgeVideoColor": "Couleur badge vidéo", + "setting.badgeVideoColor.tooltip": "Couleur des badges vidéo : MP4, WEBM, MOV, AVI, MKV (hex)", "setting.badgeAudioColor": "Couleur badge audio", "setting.badgeAudioColor.tooltip": "Couleur des badges audio : MP3, WAV, OGG, FLAC (hex)", - "setting.badgeModel3dColor": "Couleur badge modèle 3D", - "setting.badgeModel3dColor.tooltip": "Couleur des badges modèle 3D : OBJ, FBX, GLB, GLTF (hex)", + "setting.badgeModel3dColor": "Couleur badge modèle 3D", + "setting.badgeModel3dColor.tooltip": "Couleur des badges modèle 3D : OBJ, FBX, GLB, GLTF (hex)", - // ─── Settings: Avancé ─── - "setting.obs.enabled.name": "Majoor: Activer logs détaillés", - "setting.obs.enabled.desc": "Active les logs détaillés côté backend pour le debugging.", - "setting.probe.mode.name": "Majoor: Backend métadonnées", - "setting.probe.mode.desc": "Choisir l'outil pour extraire les métadonnées des fichiers.", + // ─── Settings: Avancé ─── + "setting.obs.enabled.name": "Majoor: Activer logs détaillés", + "setting.obs.enabled.desc": "Active les logs détaillés côté backend pour le debugging.", + "setting.probe.mode.name": "Majoor: Backend métadonnées", + "setting.probe.mode.desc": "Choisir l'outil pour extraire les métadonnées des fichiers.", "setting.language.name": "Majoor: Langue", - "setting.language.desc": "Choisir la langue de l'interface Assets Manager. Rechargement requis pour appliquer entièrement.", + "setting.language.desc": "Choisir la langue de l'interface Assets Manager. Rechargement requis pour appliquer entièrement.", - // ─── Settings: Sécurité ─── - "setting.sec.safe.name": "Majoor: Mode sécurisé", - "setting.sec.safe.desc": "Quand activé, les écritures rating/tags sont bloquées sauf autorisation explicite.", - "setting.sec.remote.name": "Majoor: Autoriser l'accès distant complet", - "setting.sec.remote.desc": "Autorise les clients non locaux à écrire. Désactiver bloque les écritures sauf si un token est configuré.", - "setting.sec.write.name": "Majoor: Autoriser écriture", - "setting.sec.write.desc": "Autorise l'écriture des notes et tags.", + // ─── Settings: Sécurité ─── + "setting.sec.safe.name": "Majoor: Mode sécurisé", + "setting.sec.safe.desc": "Quand activé, les écritures rating/tags sont bloquées sauf autorisation explicite.", + "setting.sec.remote.name": "Majoor: Autoriser l'accès distant complet", + "setting.sec.remote.desc": "Autorise les clients non locaux à écrire. Désactiver bloque les écritures sauf si un token est configuré.", + "setting.sec.write.name": "Majoor: Autoriser écriture", + "setting.sec.write.desc": "Autorise l'écriture des notes et tags.", "setting.sec.del.name": "Majoor: Autoriser suppression", "setting.sec.del.desc": "Active la suppression de fichiers.", "setting.sec.ren.name": "Majoor: Autoriser renommage", @@ -455,54 +484,54 @@ const DICTIONARY = { "setting.sec.reset.name": "Majoor: Autoriser reset index", "setting.sec.reset.desc": "Reset le cache d'index et relance un scan complet.", "setting.sec.token.name": "Majoor: Token API", - "setting.sec.token.desc": "Stocke le token d'autorisation pour les écritures. Majoor l'envoie via les en-têtes Authorization et X-MJR-Token.", - "setting.sec.token.placeholder": "Laisser vide pour désactiver.", + "setting.sec.token.desc": "Stocke le token d'autorisation pour les écritures. Majoor l'envoie via les en-têtes Authorization et X-MJR-Token.", + "setting.sec.token.placeholder": "Laisser vide pour désactiver.", - // ─── Panel: Onglets ─── + // ─── Panel: Onglets ─── "tab.output": "Sortie", - "tab.input": "Entrée", - "tab.custom": "Personnalisé", + "tab.input": "Entrée", + "tab.custom": "Personnalisé", - // ─── Panel: Boutons ─── - "btn.add": "Ajouter…", + // ─── Panel: Boutons ─── + "btn.add": "Ajouter…", "btn.remove": "Supprimer", "btn.adding": "Ajout...", "btn.removing": "Suppression...", - "btn.retry": "Réessayer", + "btn.retry": "Réessayer", "btn.clear": "Effacer", "btn.refresh": "Actualiser", "btn.scan": "Scanner", "btn.scanning": "Scan...", - "btn.resetIndex": "Réinitialiser l'index", - "btn.resetting": "Réinitialisation...", + "btn.resetIndex": "Réinitialiser l'index", + "btn.resetting": "Réinitialisation...", "btn.deleteDb": "Supprimer BDD", "btn.deletingDb": "Suppression BDD...", - "btn.retryServices": "Réessayer services", - "btn.retrying": "Réessai...", + "btn.retryServices": "Réessayer services", + "btn.retrying": "Réessai...", "btn.loadWorkflow": "Charger le workflow", "btn.copyPrompt": "Copier le prompt", "btn.close": "Fermer", - // ─── Panel: Labels ─── + // ─── Panel: Labels ─── "label.folder": "Dossier", "label.type": "Type", "label.workflow": "Workflow", "label.rating": "Note", - "label.dateRange": "Période", + "label.dateRange": "Période", "label.agenda": "Agenda", "label.sort": "Trier", "label.collections": "Collections", "label.filters": "Filtres", - "label.selectFolder": "Sélectionner un dossier…", + "label.selectFolder": "Sélectionner un dossier…", "label.thisFolder": "ce dossier", - // ─── Panel: Filtres ─── + // ─── Panel: Filtres ─── "filter.all": "Tout", "filter.images": "Images", - "filter.videos": "Vidéos", + "filter.videos": "Vidéos", "filter.onlyWithWorkflow": "Avec workflow uniquement", "filter.anyRating": "Toutes notes", - "filter.minStars": "{n}+ étoiles", + "filter.minStars": "{n}+ étoiles", "filter.anytime": "Toutes dates", "filter.today": "Aujourd'hui", "filter.yesterday": "Hier", @@ -511,8 +540,8 @@ const DICTIONARY = { "filter.last7days": "7 derniers jours", "filter.last30days": "30 derniers jours", - // ─── Panel: Tri ─── - "sort.newest": "Plus récent", + // ─── Panel: Tri ─── + "sort.newest": "Plus récent", "sort.oldest": "Plus ancien", "sort.nameAZ": "Nom A-Z", "sort.nameZA": "Nom Z-A", @@ -521,19 +550,19 @@ const DICTIONARY = { "sort.sizeDesc": "Taille (grand)", "sort.sizeAsc": "Taille (petit)", - // ─── Panel: Statut ─── - "status.checking": "Vérification...", - "status.ready": "Prêt", + // ─── Panel: Statut ─── + "status.checking": "Vérification...", + "status.ready": "Prêt", "status.scanning": "Scan en cours...", "status.error": "Erreur", - "status.capabilities": "Capacités", - "status.toolStatus": "État des outils", - "status.selectCustomFolder": "Sélectionnez d'abord un dossier personnalisé", + "status.capabilities": "Capacités", + "status.toolStatus": "État des outils", + "status.selectCustomFolder": "Sélectionnez d'abord un dossier personnalisé", "status.errorGetConfig": "Erreur: Impossible d'obtenir la config", - "status.discoveringTools": "Capacités: découverte des outils...", - "status.indexStatus": "État de l'index", - "status.toolStatusChecking": "État des outils: vérification...", - "status.resetIndexHint": "Réinitialiser le cache d'index (nécessite allowResetIndex dans les paramètres).", + "status.discoveringTools": "Capacités: découverte des outils...", + "status.indexStatus": "État de l'index", + "status.toolStatusChecking": "État des outils: vérification...", + "status.resetIndexHint": "Réinitialiser le cache d'index (nécessite allowResetIndex dans les paramètres).", "status.scanningHint": "Cela peut prendre un moment", "status.toolAvailable": "{tool} disponible", "status.toolUnavailable": "{tool} indisponible", @@ -541,61 +570,61 @@ const DICTIONARY = { "status.available": "disponible", "status.missing": "manquant", "status.path": "Chemin", - "status.pathAuto": "auto / non configuré", - "status.noAssets": "Aucun asset indexé ({scope})", + "status.pathAuto": "auto / non configuré", + "status.noAssets": "Aucun asset indexé ({scope})", "status.clickToScan": "Cliquez sur le point pour lancer un scan", - "status.assetsIndexed": "{count} assets indexés ({scope})", - "status.imagesVideos": "Images: {images} - Vidéos: {videos}", - "status.withWorkflows": "Avec workflows: {workflows} - Données de génération: {gendata}", + "status.assetsIndexed": "{count} assets indexés ({scope})", + "status.imagesVideos": "Images: {images} - Vidéos: {videos}", + "status.withWorkflows": "Avec workflows: {workflows} - Données de génération: {gendata}", "status.dbSize": "Taille de la base: {size}", "status.lastScan": "Dernier scan: {date}", - "status.scanStats": "Ajoutés: {added} - Mis à jour: {updated} - Ignorés: {skipped}", - "status.watcher.enabled": "Watcher : activé", - "status.watcher.enabledScoped": "Watcher : activé ({scope})", - "status.watcher.disabled": "Watcher : désactivé", - "status.watcher.disabledScoped": "Watcher : désactivé ({scope})", + "status.scanStats": "Ajoutés: {added} - Mis à jour: {updated} - Ignorés: {skipped}", + "status.watcher.enabled": "Watcher : activé", + "status.watcher.enabledScoped": "Watcher : activé ({scope})", + "status.watcher.disabled": "Watcher : désactivé", + "status.watcher.disabledScoped": "Watcher : désactivé ({scope})", "status.apiNotFound": "Endpoints API Majoor introuvables (404)", - "status.apiNotFoundHint": "Les routes backend ne sont pas chargées. Redémarrez ComfyUI et vérifiez le terminal pour les erreurs d'import Majoor.", - "status.errorChecking": "Erreur lors de la vérification", - "status.dbCorrupted": "Base de données corrompue", - "status.dbCorruptedHint": "Utilisez le bouton « Supprimer la BDD » ci-dessous pour forcer la suppression et reconstruire l'index.", - "status.retryFailed": "Échec de la relance", + "status.apiNotFoundHint": "Les routes backend ne sont pas chargées. Redémarrez ComfyUI et vérifiez le terminal pour les erreurs d'import Majoor.", + "status.errorChecking": "Erreur lors de la vérification", + "status.dbCorrupted": "Base de données corrompue", + "status.dbCorruptedHint": "Utilisez le bouton « Supprimer la BDD » ci-dessous pour forcer la suppression et reconstruire l'index.", + "status.retryFailed": "Échec de la relance", - // ─── Scopes ─── - "scope.all": "Entrées + Sorties", - "scope.allFull": "Tout (Entrées + Sorties)", - "scope.input": "Entrées", + // ─── Scopes ─── + "scope.all": "Entrées + Sorties", + "scope.allFull": "Tout (Entrées + Sorties)", + "scope.input": "Entrées", "scope.output": "Sorties", - "scope.custom": "Personnalisé", + "scope.custom": "Personnalisé", - // ─── Outils ─── - "tool.exiftool": "Métadonnées ExifTool", - "tool.exiftool.hint": "Données workflow PNG/WEBP (via ExifTool)", - "tool.ffprobe": "Stats vidéo FFprobe", - "tool.ffprobe.hint": "Durée, FPS et résolution vidéo (via FFprobe)", + // ─── Outils ─── + "tool.exiftool": "Métadonnées ExifTool", + "tool.exiftool.hint": "Données workflow PNG/WEBP (via ExifTool)", + "tool.ffprobe": "Stats vidéo FFprobe", + "tool.ffprobe.hint": "Durée, FPS et résolution vidéo (via FFprobe)", - // ─── Panel: Messages ─── + // ─── Panel: Messages ─── "msg.noCollections": "Aucune collection.", - "msg.addCustomFolder": "Ajoutez un dossier personnalisé à parcourir.", - "msg.noResults": "Aucun résultat.", + "msg.addCustomFolder": "Ajoutez un dossier personnalisé à parcourir.", + "msg.noResults": "Aucun résultat.", "msg.loading": "Chargement...", "msg.errorLoading": "Erreur de chargement", "msg.errorLoadingFolders": "Erreur de chargement des dossiers", - "msg.noGenerationData": "Aucune donnée de génération pour ce fichier.", - "msg.rawMetadata": "Métadonnées brutes", + "msg.noGenerationData": "Aucune donnée de génération pour ce fichier.", + "msg.rawMetadata": "Métadonnées brutes", - // ─── Visionneuse ─── - "viewer.genInfo": "Infos de génération", + // ─── Visionneuse ─── + "viewer.genInfo": "Infos de génération", "viewer.workflow": "Workflow", - "viewer.metadata": "Métadonnées", + "viewer.metadata": "Métadonnées", "viewer.noWorkflow": "Pas de workflow", - "viewer.noMetadata": "Aucune métadonnée disponible", - "viewer.copySuccess": "Copié dans le presse-papier !", - "viewer.copyFailed": "Échec de la copie", + "viewer.noMetadata": "Aucune métadonnée disponible", + "viewer.copySuccess": "Copié dans le presse-papier !", + "viewer.copyFailed": "Échec de la copie", - // ─── Sidebar ─── - "sidebar.details": "Détails", - "sidebar.preview": "Aperçu", + // ─── Sidebar ─── + "sidebar.details": "Détails", + "sidebar.preview": "Aperçu", "sidebar.rating": "Note", "sidebar.tags": "Tags", "sidebar.addTag": "Ajouter un tag...", @@ -604,128 +633,154 @@ const DICTIONARY = { "sidebar.dimensions": "Dimensions", "sidebar.date": "Date", "sidebar.size": "Taille", - "sidebar.genTime": "Temps de génération", + "sidebar.genTime": "Temps de génération", - // ─── Menu contextuel ─── + // ─── Menu contextuel ─── "ctx.openViewer": "Ouvrir dans la visionneuse", "ctx.loadWorkflow": "Charger le workflow", "ctx.copyPath": "Copier le chemin", "ctx.openInFolder": "Ouvrir dans le dossier", "ctx.rename": "Renommer", "ctx.delete": "Supprimer", - "ctx.addToCollection": "Ajouter à la collection", + "ctx.addToCollection": "Ajouter à la collection", "ctx.removeFromCollection": "Retirer de la collection", "ctx.newCollection": "Nouvelle collection...", - "ctx.rescanMetadata": "Rescanner les métadonnées", - "ctx.createCollection": "Créer une collection...", + "ctx.rescanMetadata": "Rescanner les métadonnées", + "ctx.createCollection": "Créer une collection...", "ctx.exitCollection": "Quitter la vue collection", - // ─── Dialogues ─── + // ─── Dialogues ─── "dialog.confirm": "Confirmer", "dialog.cancel": "Annuler", "dialog.delete.title": "Supprimer le fichier ?", - "dialog.delete.msg": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible.", + "dialog.delete.msg": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible.", "dialog.rename.title": "Renommer le fichier", "dialog.rename.placeholder": "Nouveau nom", "dialog.newCollection.title": "Nouvelle collection", "dialog.newCollection.placeholder": "Nom de la collection", - "dialog.resetIndex.title": "Réinitialiser l'index ?", - "dialog.resetIndex.msg": "Cela supprimera la base de données et rescannera tous les fichiers. Continuer ?", - "dialog.securityWarning": "Cela ressemble à un dossier système ou très large.\n\nL'ajouter peut exposer des fichiers sensibles via la fonctionnalité de dossiers personnalisés.\n\nContinuer ?", - "dialog.securityWarningTitle": "Majoor: Avertissement de sécurité", - "dialog.enterFolderPath": "Entrez le chemin d'un dossier à ajouter comme racine personnalisée :", - "dialog.customFoldersTitle": "Majoor: Dossiers personnalisés", - "dialog.removeFolder": "Supprimer le dossier personnalisé \"{name}\" ?", + "dialog.resetIndex.title": "Réinitialiser l'index ?", + "dialog.resetIndex.msg": "Cela supprimera la base de données et rescannera tous les fichiers. Continuer ?", + "dialog.securityWarning": "Cela ressemble à un dossier système ou très large.\n\nL'ajouter peut exposer des fichiers sensibles via la fonctionnalité de dossiers personnalisés.\n\nContinuer ?", + "dialog.securityWarningTitle": "Majoor: Avertissement de sécurité", + "dialog.enterFolderPath": "Entrez le chemin d'un dossier à ajouter comme racine personnalisée :", + "dialog.customFoldersTitle": "Majoor: Dossiers personnalisés", + "dialog.removeFolder": "Supprimer le dossier personnalisé \"{name}\" ?", "dialog.deleteCollection": "Supprimer la collection \"{name}\" ?", - "dialog.createCollection": "Créer une collection", + "dialog.createCollection": "Créer une collection", "dialog.collectionPlaceholder": "Ma collection", - // ─── Toasts ─── - "toast.scanStarted": "Scan démarré", - "toast.scanComplete": "Scan terminé", - "toast.scanFailed": "Échec du scan", - "toast.resetTriggered": "Réinitialisation : Réindexation de tous les fichiers...", - "toast.resetStarted": "Réinitialisation démarrée. Les fichiers seront réindexés en arrière-plan.", - "toast.resetFailed": "Échec de la réinitialisation de l'index", - "toast.resetFailedCorrupt": "Échec de la réinitialisation — base de données corrompue. Utilisez le bouton « Supprimer la BDD » pour forcer la suppression et reconstruire.", + // ─── Toasts ─── + "toast.scanStarted": "Scan démarré", + "toast.scanComplete": "Scan terminé", + "toast.scanFailed": "Échec du scan", + "toast.resetTriggered": "Réinitialisation : Réindexation de tous les fichiers...", + "toast.resetStarted": "Réinitialisation démarrée. Les fichiers seront réindexés en arrière-plan.", + "toast.resetFailed": "Échec de la réinitialisation de l'index", + "toast.resetFailedCorrupt": "Échec de la réinitialisation — base de données corrompue. Utilisez le bouton « Supprimer la BDD » pour forcer la suppression et reconstruire.", "toast.dbDeleteTriggered": "Suppression de la base et reconstruction...", - "toast.dbDeleteSuccess": "Base supprimée et reconstruite. Les fichiers sont en cours de réindexation.", - "toast.dbDeleteFailed": "Échec de la suppression de la base", - "dialog.dbDelete.confirm": "Cela supprimera définitivement la base de données d'index et la reconstruira. Toutes les notes, tags et métadonnées seront perdus.\n\nContinuer ?", - "toast.deleted": "Fichier supprimé", - "toast.renamed": "Fichier renommé", - "toast.addedToCollection": "Ajouté à la collection", - "toast.removedFromCollection": "Retiré de la collection", - "toast.collectionCreated": "Collection créée", - "toast.permissionDenied": "Permission refusée", - "toast.tagAdded": "Tag ajouté", - "toast.tagRemoved": "Tag supprimé", - "toast.ratingSaved": "Note enregistrée", - "toast.failedAddFolder": "Échec de l'ajout du dossier personnalisé", - "toast.failedRemoveFolder": "Échec de la suppression du dossier personnalisé", - "toast.folderLinked": "Dossier lié avec succès", - "toast.folderRemoved": "Dossier supprimé", + "toast.dbDeleteSuccess": "Base supprimée et reconstruite. Les fichiers sont en cours de réindexation.", + "toast.dbDeleteFailed": "Échec de la suppression de la base", + "dialog.dbDelete.confirm": "Cela supprimera définitivement la base de données d'index et la reconstruira. Toutes les notes, tags et métadonnées seront perdus.\n\nContinuer ?", + "toast.deleted": "Fichier supprimé", + "toast.renamed": "Fichier renommé", + "toast.addedToCollection": "Ajouté à la collection", + "toast.removedFromCollection": "Retiré de la collection", + "toast.collectionCreated": "Collection créée", + "toast.permissionDenied": "Permission refusée", + "toast.tagAdded": "Tag ajouté", + "toast.tagRemoved": "Tag supprimé", + "toast.ratingSaved": "Note enregistrée", + "toast.failedAddFolder": "Échec de l'ajout du dossier personnalisé", + "toast.failedRemoveFolder": "Échec de la suppression du dossier personnalisé", + "toast.folderLinked": "Dossier lié avec succès", + "toast.folderRemoved": "Dossier supprimé", "toast.nativeBrowserUnavailable": "Explorateur natif indisponible. Veuillez entrer le chemin manuellement.", - "toast.errorAddingFolder": "Erreur lors de l'ajout du dossier personnalisé", - "toast.errorRemovingFolder": "Erreur lors de la suppression du dossier personnalisé", - "toast.failedCreateCollection": "Échec de la création de la collection", - "toast.failedDeleteCollection": "Échec de la suppression de la collection", - "toast.languageChanged": "Langue modifiée. Rechargez la page pour une application complète.", - "toast.ratingUpdateFailed": "Échec de la mise à jour de la note", - "toast.ratingUpdateError": "Erreur lors de la mise à jour de la note", - "toast.tagsUpdateFailed": "Échec de la mise à jour des tags", - "toast.watcherToggleFailed": "Échec de l'activation/désactivation du watcher", - "toast.noValidAssetsSelected": "Aucun asset valide sélectionné.", - "toast.failedCreateCollectionDot": "Échec de la création de la collection.", - "toast.failedAddAssetsToCollection": "Échec de l'ajout des assets à la collection.", - "toast.removeFromCollectionFailed": "Échec du retrait de la collection.", - "toast.copyClipboardFailed": "Échec de la copie dans le presse-papiers", - "toast.metadataRefreshFailed": "Échec du rafraîchissement des métadonnées.", - "toast.ratingCleared": "Note effacée", - "toast.ratingSetN": "Note réglée à {n} étoiles", - "toast.tagsUpdated": "Tags mis à jour", - "toast.fileRenamedSuccess": "Fichier renommé avec succès !", - "toast.fileRenameFailed": "Échec du renommage du fichier.", - "toast.fileDeletedSuccess": "Fichier supprimé avec succès !", - "toast.fileDeleteFailed": "Échec de la suppression du fichier.", + "toast.errorAddingFolder": "Erreur lors de l'ajout du dossier personnalisé", + "toast.errorRemovingFolder": "Erreur lors de la suppression du dossier personnalisé", + "toast.failedCreateCollection": "Échec de la création de la collection", + "toast.failedDeleteCollection": "Échec de la suppression de la collection", + "toast.languageChanged": "Langue modifiée. Rechargez la page pour une application complète.", + "toast.ratingUpdateFailed": "Échec de la mise à jour de la note", + "toast.ratingUpdateError": "Erreur lors de la mise à jour de la note", + "toast.tagsUpdateFailed": "Échec de la mise à jour des tags", + "toast.watcherToggleFailed": "Échec de l'activation/désactivation du watcher", + "toast.noValidAssetsSelected": "Aucun asset valide sélectionné.", + "toast.failedCreateCollectionDot": "Échec de la création de la collection.", + "toast.failedAddAssetsToCollection": "Échec de l'ajout des assets à la collection.", + "toast.removeFromCollectionFailed": "Échec du retrait de la collection.", + "toast.copyClipboardFailed": "Échec de la copie dans le presse-papiers", + "toast.metadataRefreshFailed": "Échec du rafraîchissement des métadonnées.", + "toast.ratingCleared": "Note effacée", + "toast.ratingSetN": "Note réglée à {n} étoiles", + "toast.tagsUpdated": "Tags mis à jour", + "toast.fileRenamedSuccess": "Fichier renommé avec succès !", + "toast.fileRenameFailed": "Échec du renommage du fichier.", + "toast.fileDeletedSuccess": "Fichier supprimé avec succès !", + "toast.fileDeleteFailed": "Échec de la suppression du fichier.", "toast.openedInFolder": "Ouvert dans le dossier", - "toast.openFolderFailed": "Échec de l'ouverture du dossier.", - "toast.pathCopied": "Chemin copié dans le presse-papiers", - "toast.pathCopyFailed": "Échec de la copie du chemin", + "toast.openFolderFailed": "Échec de l'ouverture du dossier.", + "toast.pathCopied": "Chemin copié dans le presse-papiers", + "toast.pathCopyFailed": "Échec de la copie du chemin", "toast.noFilePath": "Aucun chemin de fichier disponible pour cet asset.", - "toast.downloadingFile": "Téléchargement de {filename}...", + "toast.downloadingFile": "Téléchargement de {filename}...", "toast.playbackRate": "Lecture {rate}x", - "toast.metadataRefreshed": "Métadonnées rafraîchies{suffix}", - "toast.enrichmentComplete": "Enrichissement des métadonnées terminé", + "toast.metadataRefreshed": "Métadonnées rafraîchies{suffix}", + "toast.enrichmentComplete": "Enrichissement des métadonnées terminé", "toast.errorRenaming": "Erreur de renommage du fichier : {error}", "toast.errorDeleting": "Erreur de suppression du fichier : {error}", - "toast.tagMergeFailed": "Échec de fusion des tags : {error}", - "toast.deleteFailed": "Échec de suppression : {error}", - "toast.analysisNotStarted": "Analyse non démarrée : {error}", - "toast.dupAnalysisStarted": "Analyse de doublons démarrée", - "toast.tagsMerged": "Tags fusionnés", - "toast.duplicatesDeleted": "Doublons supprimés", - "toast.playbackVideoOnly": "La vitesse de lecture est disponible uniquement pour les vidéos", - "toast.filesDeletedSuccessN": "{n} fichiers supprimés avec succès !", - "toast.filesDeletedPartial": "{success} fichiers supprimés, {failed} en échec.", - "toast.filesDeletedShort": "{n} fichiers supprimés", - "toast.filesDeletedShortPartial": "{success} supprimés, {failed} en échec", - "toast.copiedToClipboardNamed": "{name} copié dans le presse-papiers !", - "toast.rescanningFile": "Rescan du fichier…", + "toast.tagMergeFailed": "Échec de fusion des tags : {error}", + "toast.deleteFailed": "Échec de suppression : {error}", + "toast.analysisNotStarted": "Analyse non démarrée : {error}", + "toast.dupAnalysisStarted": "Analyse de doublons démarrée", + "toast.tagsMerged": "Tags fusionnés", + "toast.duplicatesDeleted": "Doublons supprimés", + "toast.playbackVideoOnly": "La vitesse de lecture est disponible uniquement pour les vidéos", + "toast.filesDeletedSuccessN": "{n} fichiers supprimés avec succès !", + "toast.filesDeletedPartial": "{success} fichiers supprimés, {failed} en échec.", + "toast.filesDeletedShort": "{n} fichiers supprimés", + "toast.filesDeletedShortPartial": "{success} supprimés, {failed} en échec", + "toast.copiedToClipboardNamed": "{name} copié dans le presse-papiers !", + "toast.rescanningFile": "Rescan du fichier…", - // ─── Raccourcis ─── + // ─── Raccourcis ─── + // Clés manquantes remontées par le runtime + "cat.search": "Recherche", + "setting.badgeDuplicateAlertColor": "Couleur badge alerte doublon", + "setting.badgeDuplicateAlertColor.tooltip": "Couleur d'alerte utilisée quand un badge d'extension dupliquée est affiché (ex. PNG+).", + "setting.grid.videoAutoplayMode.name": "Majoor : Lecture auto vidéo", + "setting.grid.videoAutoplayMode.desc": "Contrôle la lecture des miniatures vidéo dans la grille. Off : image fixe. Hover : lecture au survol. Always : boucle tant que visible.", + "setting.grid.videoAutoplayMode.off": "Off", + "setting.grid.videoAutoplayMode.hover": "Survol", + "setting.grid.videoAutoplayMode.always": "Toujours", + "setting.watcher.enabled.label": "Watcher activé", + "setting.watcher.debounce.label": "Délai watcher (ms)", + "setting.watcher.dedupe.label": "Fenêtre déduplication watcher (ms)", + "setting.search.maxResults.name": "Majoor : Résultats max de recherche", + "setting.search.maxResults.desc": "Nombre maximum de résultats renvoyés par les endpoints de recherche.", + "btn.dbSave": "Sauvegarder BDD", + "btn.dbRestore": "Restaurer BDD", + "status.dbBackupSelectHint": "Sélectionnez une sauvegarde BDD à restaurer", + "status.dbBackupLoading": "Chargement des sauvegardes BDD...", + "status.dbSaveHint": "Créer maintenant un snapshot de sauvegarde de la BDD.", + "status.dbRestoreHint": "Restaurer la sauvegarde BDD sélectionnée et relancer l'indexation.", + "status.dbHealthOk": "Santé BDD : OK", + "status.indexHealthOk": "Santé index : OK", + "status.toast.info": "État index : vérification", + "status.toast.success": "État index : prêt", + "status.toast.warning": "État index : attention requise", + "status.toast.error": "État index : erreur", "hotkey.scan": "Scanner (S)", "hotkey.search": "Rechercher (Ctrl+F)", - "hotkey.details": "Afficher détails (D)", + "hotkey.details": "Afficher détails (D)", "hotkey.delete": "Supprimer (Suppr)", - "hotkey.viewer": "Ouvrir visionneuse (Entrée)", - "hotkey.escape": "Fermer (Échap)", + "hotkey.viewer": "Ouvrir visionneuse (Entrée)", + "hotkey.escape": "Fermer (Échap)", } }; const LANGUAGE_NAMES = Object.freeze({ "en-US": "English", - "fr-FR": "Français", + "fr-FR": "Français", "zh-CN": "Chinese (Simplified)", "ja-JP": "Japanese", "ko-KR": "Korean", @@ -750,260 +805,260 @@ const LANGUAGE_NAMES = Object.freeze({ const CORE_TRANSLATIONS = { "zh-CN": { - "cat.grid": "网格", - "cat.cards": "卡片", - "cat.badges": "徽章", - "cat.viewer": "查看器", - "cat.scanning": "扫描", - "cat.advanced": "高级", - "cat.security": "安全", - "cat.remote": "远程访问", - "setting.language.name": "Majoor: 语言", - "setting.language.desc": "选择 Assets Manager 界面语言。完整应用需重新加载。", - "tab.output": "输出", - "tab.input": "输入", - "tab.custom": "自定义", - "btn.add": "添加…", - "btn.remove": "移除", - "btn.retry": "重试", - "btn.clear": "清除", - "btn.refresh": "刷新", - "btn.scan": "扫描", - "btn.close": "关闭", - "label.folder": "文件夹", - "label.type": "类型", - "label.workflow": "工作流", - "label.rating": "评分", - "label.dateRange": "日期范围", - "label.sort": "排序", - "label.filters": "筛选", - "filter.all": "全部", - "filter.images": "图片", - "filter.videos": "视频", - "filter.onlyWithWorkflow": "仅有工作流", - "filter.anyRating": "任意评分", - "filter.anytime": "任何时间", - "filter.today": "今天", - "filter.yesterday": "昨天", - "sort.newest": "最新优先", - "sort.oldest": "最旧优先", - "sort.nameAZ": "名称 A-Z", - "sort.nameZA": "名称 Z-A", - "status.ready": "就绪", - "status.scanning": "扫描中...", - "status.error": "错误", - "msg.noResults": "未找到结果。", - "msg.loading": "加载中...", - "msg.addCustomFolder": "添加一个自定义文件夹以浏览。", - "msg.errorLoading": "加载错误", - "sidebar.details": "详情", - "sidebar.preview": "预览", - "sidebar.rating": "评分", - "sidebar.tags": "标签", - "ctx.openViewer": "在查看器中打开", - "ctx.copyPath": "复制路径", - "ctx.openInFolder": "在文件夹中打开", - "ctx.rename": "重命名", - "ctx.delete": "删除", - "toast.ratingUpdateFailed": "更新评分失败", - "toast.tagsUpdated": "标签已更新", - "toast.pathCopied": "路径已复制到剪贴板", + "cat.grid": "网格", + "cat.cards": "卡片", + "cat.badges": "徽章", + "cat.viewer": "查看器", + "cat.scanning": "扫描", + "cat.advanced": "高级", + "cat.security": "安全", + "cat.remote": "远程访问", + "setting.language.name": "Majoor: 语言", + "setting.language.desc": "选择 Assets Manager 界面语言。完整应用需重新加载。", + "tab.output": "输出", + "tab.input": "输入", + "tab.custom": "自定义", + "btn.add": "添加…", + "btn.remove": "移除", + "btn.retry": "重试", + "btn.clear": "清除", + "btn.refresh": "刷新", + "btn.scan": "扫描", + "btn.close": "关闭", + "label.folder": "文件夹", + "label.type": "类型", + "label.workflow": "工作流", + "label.rating": "评分", + "label.dateRange": "日期范围", + "label.sort": "排序", + "label.filters": "筛选", + "filter.all": "全部", + "filter.images": "图片", + "filter.videos": "视频", + "filter.onlyWithWorkflow": "仅有工作流", + "filter.anyRating": "任意评分", + "filter.anytime": "任何时间", + "filter.today": "今天", + "filter.yesterday": "昨天", + "sort.newest": "最新优先", + "sort.oldest": "最旧优先", + "sort.nameAZ": "名称 A-Z", + "sort.nameZA": "名称 Z-A", + "status.ready": "就绪", + "status.scanning": "扫描中...", + "status.error": "错误", + "msg.noResults": "未找到结果。", + "msg.loading": "加载中...", + "msg.addCustomFolder": "添加一个自定义文件夹以浏览。", + "msg.errorLoading": "加载错误", + "sidebar.details": "详情", + "sidebar.preview": "预览", + "sidebar.rating": "评分", + "sidebar.tags": "标签", + "ctx.openViewer": "在查看器中打开", + "ctx.copyPath": "复制路径", + "ctx.openInFolder": "在文件夹中打开", + "ctx.rename": "重命名", + "ctx.delete": "删除", + "toast.ratingUpdateFailed": "更新评分失败", + "toast.tagsUpdated": "标签已更新", + "toast.pathCopied": "路径已复制到剪贴板", }, "ja-JP": { - "cat.grid": "グリッド", - "cat.cards": "カード", - "cat.badges": "バッジ", - "cat.viewer": "ビューア", - "cat.scanning": "スキャン", - "cat.advanced": "詳細設定", - "cat.security": "セキュリティ", - "cat.remote": "リモートアクセス", - "setting.language.name": "Majoor: 言語", - "setting.language.desc": "Assets Manager の表示言語を選択します。完全適用には再読み込みが必要です。", - "tab.output": "出力", - "tab.input": "入力", - "tab.custom": "カスタム", - "btn.add": "追加…", - "btn.remove": "削除", - "btn.retry": "再試行", - "btn.clear": "クリア", - "btn.refresh": "更新", - "btn.scan": "スキャン", - "btn.close": "閉じる", - "label.folder": "フォルダ", - "label.type": "種類", - "label.workflow": "ワークフロー", - "label.rating": "評価", - "label.dateRange": "日付範囲", - "label.sort": "並び替え", - "label.filters": "フィルター", - "filter.all": "すべて", - "filter.images": "画像", - "filter.videos": "動画", - "filter.onlyWithWorkflow": "ワークフローありのみ", - "filter.anyRating": "すべての評価", - "filter.anytime": "期間指定なし", - "filter.today": "今日", - "filter.yesterday": "昨日", - "sort.newest": "新しい順", - "sort.oldest": "古い順", - "sort.nameAZ": "名前 A-Z", - "sort.nameZA": "名前 Z-A", - "status.ready": "準備完了", - "status.scanning": "スキャン中...", - "status.error": "エラー", - "msg.noResults": "結果が見つかりません。", - "msg.loading": "読み込み中...", - "msg.addCustomFolder": "参照するカスタムフォルダを追加してください。", - "msg.errorLoading": "読み込みエラー", - "sidebar.details": "詳細", - "sidebar.preview": "プレビュー", - "sidebar.rating": "評価", - "sidebar.tags": "タグ", - "ctx.openViewer": "ビューアで開く", - "ctx.copyPath": "パスをコピー", - "ctx.openInFolder": "フォルダで開く", - "ctx.rename": "名前変更", - "ctx.delete": "削除", - "toast.ratingUpdateFailed": "評価の更新に失敗しました", - "toast.tagsUpdated": "タグを更新しました", - "toast.pathCopied": "パスをクリップボードにコピーしました", + "cat.grid": "グリッド", + "cat.cards": "カード", + "cat.badges": "バッジ", + "cat.viewer": "ビューア", + "cat.scanning": "スキャン", + "cat.advanced": "詳細設定", + "cat.security": "セキュリティ", + "cat.remote": "リモートアクセス", + "setting.language.name": "Majoor: 言語", + "setting.language.desc": "Assets Manager の表示言語を選択します。完全適用には再読み込みが必要です。", + "tab.output": "出力", + "tab.input": "入力", + "tab.custom": "カスタム", + "btn.add": "追加…", + "btn.remove": "削除", + "btn.retry": "再試行", + "btn.clear": "クリア", + "btn.refresh": "æ›´æ–°", + "btn.scan": "スキャン", + "btn.close": "閉じる", + "label.folder": "フォルダ", + "label.type": "種類", + "label.workflow": "ワークフロー", + "label.rating": "評価", + "label.dateRange": "日付範囲", + "label.sort": "並び替え", + "label.filters": "フィルター", + "filter.all": "すべて", + "filter.images": "画像", + "filter.videos": "å‹•ç”»", + "filter.onlyWithWorkflow": "ワークフローありのみ", + "filter.anyRating": "すべての評価", + "filter.anytime": "期間指定なし", + "filter.today": "今日", + "filter.yesterday": "昨日", + "sort.newest": "新しい順", + "sort.oldest": "古い順", + "sort.nameAZ": "名前 A-Z", + "sort.nameZA": "名前 Z-A", + "status.ready": "準備完了", + "status.scanning": "スキャン中...", + "status.error": "エラー", + "msg.noResults": "結果が見つかりません。", + "msg.loading": "読み込み中...", + "msg.addCustomFolder": "参照するカスタムフォルダを追加してください。", + "msg.errorLoading": "読み込みエラー", + "sidebar.details": "詳細", + "sidebar.preview": "プレビュー", + "sidebar.rating": "評価", + "sidebar.tags": "ã‚¿ã‚°", + "ctx.openViewer": "ビューアで開く", + "ctx.copyPath": "パスをコピー", + "ctx.openInFolder": "フォルダで開く", + "ctx.rename": "名前変更", + "ctx.delete": "削除", + "toast.ratingUpdateFailed": "評価の更新に失敗しました", + "toast.tagsUpdated": "タグを更新しました", + "toast.pathCopied": "パスをクリップボードにコピーしました", }, "ko-KR": { - "cat.grid": "그리드", - "cat.cards": "카드", - "cat.badges": "배지", - "cat.viewer": "뷰어", - "cat.scanning": "스캔", - "cat.advanced": "고급", - "cat.security": "보안", - "cat.remote": "원격 액세스", - "setting.language.name": "Majoor: 언어", - "setting.language.desc": "Assets Manager 인터페이스 언어를 선택합니다. 완전 적용하려면 새로고침이 필요합니다.", - "tab.output": "출력", - "tab.input": "입력", - "tab.custom": "사용자 지정", - "btn.add": "추가…", - "btn.remove": "제거", - "btn.retry": "다시 시도", - "btn.clear": "지우기", - "btn.refresh": "새로고침", - "btn.scan": "스캔", - "btn.close": "닫기", - "label.folder": "폴더", - "label.type": "유형", - "label.workflow": "워크플로우", - "label.rating": "평점", - "label.dateRange": "날짜 범위", - "label.sort": "정렬", - "label.filters": "필터", - "filter.all": "전체", - "filter.images": "이미지", - "filter.videos": "동영상", - "filter.onlyWithWorkflow": "워크플로우만", - "filter.anyRating": "모든 평점", - "filter.anytime": "전체 기간", - "filter.today": "오늘", - "filter.yesterday": "어제", - "sort.newest": "최신순", - "sort.oldest": "오래된순", - "sort.nameAZ": "이름 A-Z", - "sort.nameZA": "이름 Z-A", - "status.ready": "준비됨", - "status.scanning": "스캔 중...", - "status.error": "오류", - "msg.noResults": "결과가 없습니다.", - "msg.loading": "로딩 중...", - "msg.addCustomFolder": "찾아볼 사용자 지정 폴더를 추가하세요.", - "msg.errorLoading": "로드 오류", - "sidebar.details": "세부정보", - "sidebar.preview": "미리보기", - "sidebar.rating": "평점", - "sidebar.tags": "태그", - "ctx.openViewer": "뷰어에서 열기", - "ctx.copyPath": "경로 복사", - "ctx.openInFolder": "폴더에서 열기", - "ctx.rename": "이름 바꾸기", - "ctx.delete": "삭제", - "toast.ratingUpdateFailed": "평점 업데이트 실패", - "toast.tagsUpdated": "태그가 업데이트되었습니다", - "toast.pathCopied": "경로가 클립보드에 복사되었습니다", + "cat.grid": "그리드", + "cat.cards": "카드", + "cat.badges": "ë°°ì§€", + "cat.viewer": "ë·°ì–´", + "cat.scanning": "스캔", + "cat.advanced": "고급", + "cat.security": "보안", + "cat.remote": "원격 액세스", + "setting.language.name": "Majoor: 언어", + "setting.language.desc": "Assets Manager 인터페이스 언어를 선택합니다. 완전 적용하려면 새로고침이 필요합니다.", + "tab.output": "출력", + "tab.input": "ìž…ë ¥", + "tab.custom": "사용자 지정", + "btn.add": "추가…", + "btn.remove": "제거", + "btn.retry": "다시 시도", + "btn.clear": "지우기", + "btn.refresh": "새로고침", + "btn.scan": "스캔", + "btn.close": "닫기", + "label.folder": "폴더", + "label.type": "유형", + "label.workflow": "워크플로우", + "label.rating": "평점", + "label.dateRange": "ë‚ ì§œ 범위", + "label.sort": "ì •ë ¬", + "label.filters": "í•„í„°", + "filter.all": "ì „ì²´", + "filter.images": "이미지", + "filter.videos": "동영상", + "filter.onlyWithWorkflow": "워크플로우만", + "filter.anyRating": "모든 평점", + "filter.anytime": "ì „ì²´ 기간", + "filter.today": "오늘", + "filter.yesterday": "ì–´ì œ", + "sort.newest": "최신순", + "sort.oldest": "오래된순", + "sort.nameAZ": "이름 A-Z", + "sort.nameZA": "이름 Z-A", + "status.ready": "준비됨", + "status.scanning": "스캔 중...", + "status.error": "오류", + "msg.noResults": "결과가 없습니다.", + "msg.loading": "로딩 중...", + "msg.addCustomFolder": "찾아볼 사용자 지정 폴더를 추가하세요.", + "msg.errorLoading": "로드 오류", + "sidebar.details": "세부정보", + "sidebar.preview": "미리보기", + "sidebar.rating": "평점", + "sidebar.tags": "태그", + "ctx.openViewer": "뷰어에서 열기", + "ctx.copyPath": "경로 복사", + "ctx.openInFolder": "폴더에서 열기", + "ctx.rename": "이름 바꾸기", + "ctx.delete": "ì‚­ì œ", + "toast.ratingUpdateFailed": "평점 업데이트 실패", + "toast.tagsUpdated": "태그가 업데이트되었습니다", + "toast.pathCopied": "경로가 클립보드에 복사되었습니다", }, "hi-IN": { - "cat.grid": "ग्रिड", - "cat.cards": "कार्ड", - "cat.badges": "बैज", - "cat.viewer": "व्यूअर", - "cat.scanning": "स्कैनिंग", - "cat.advanced": "उन्नत", - "cat.security": "सुरक्षा", - "cat.remote": "रिमोट एक्सेस", - "setting.language.name": "Majoor: भाषा", - "setting.language.desc": "Assets Manager इंटरफ़ेस की भाषा चुनें। पूरी तरह लागू करने हेतु रीलोड आवश्यक है।", - "tab.output": "आउटपुट", - "tab.input": "इनपुट", - "tab.custom": "कस्टम", - "btn.add": "जोड़ें…", - "btn.remove": "हटाएं", - "btn.retry": "पुनः प्रयास", - "btn.clear": "साफ़ करें", - "btn.refresh": "रिफ्रेश", - "btn.scan": "स्कैन", - "btn.close": "बंद करें", - "label.folder": "फ़ोल्डर", - "label.type": "प्रकार", - "label.workflow": "वर्कफ़्लो", - "label.rating": "रेटिंग", - "label.dateRange": "तिथि सीमा", - "label.sort": "क्रमबद्ध करें", - "label.filters": "फ़िल्टर", - "filter.all": "सभी", - "filter.images": "छवियां", - "filter.videos": "वीडियो", - "filter.onlyWithWorkflow": "सिर्फ़ वर्कफ़्लो वाले", - "filter.anyRating": "कोई भी रेटिंग", - "filter.anytime": "कभी भी", - "filter.today": "आज", - "filter.yesterday": "कल", - "sort.newest": "नवीनतम पहले", - "sort.oldest": "पुराना पहले", - "sort.nameAZ": "नाम A-Z", - "sort.nameZA": "नाम Z-A", - "status.ready": "तैयार", - "status.scanning": "स्कैन हो रहा है...", - "status.error": "त्रुटि", - "msg.noResults": "कोई परिणाम नहीं मिला।", - "msg.loading": "लोड हो रहा है...", - "msg.addCustomFolder": "ब्राउज़ करने के लिए एक कस्टम फ़ोल्डर जोड़ें।", - "msg.errorLoading": "लोड करने में त्रुटि", - "sidebar.details": "विवरण", - "sidebar.preview": "पूर्वावलोकन", - "sidebar.rating": "रेटिंग", - "sidebar.tags": "टैग", - "ctx.openViewer": "व्यूअर में खोलें", - "ctx.copyPath": "पाथ कॉपी करें", - "ctx.openInFolder": "फ़ोल्डर में खोलें", - "ctx.rename": "नाम बदलें", - "ctx.delete": "हटाएं", - "toast.ratingUpdateFailed": "रेटिंग अपडेट विफल", - "toast.tagsUpdated": "टैग अपडेट हो गए", - "toast.pathCopied": "पाथ क्लिपबोर्ड में कॉपी हो गया", + "cat.grid": "ग्रिड", + "cat.cards": "कार्ड", + "cat.badges": "बैज", + "cat.viewer": "व्यूअर", + "cat.scanning": "स्कैनिंग", + "cat.advanced": "उन्नत", + "cat.security": "सुरक्षा", + "cat.remote": "रिमोट एक्सेस", + "setting.language.name": "Majoor: भाषा", + "setting.language.desc": "Assets Manager इंटरफ़ेस की भाषा चुनें। पूरी तरह लागू करने हेतु रीलोड आवश्यक है।", + "tab.output": "आउटपुट", + "tab.input": "इनपुट", + "tab.custom": "कस्टम", + "btn.add": "जोड़ें…", + "btn.remove": "हटाएं", + "btn.retry": "पुनः प्रयास", + "btn.clear": "साफ़ करें", + "btn.refresh": "रिफ्रेश", + "btn.scan": "स्कैन", + "btn.close": "बंद करें", + "label.folder": "फ़ोल्डर", + "label.type": "प्रकार", + "label.workflow": "वर्कफ़्लो", + "label.rating": "रेटिंग", + "label.dateRange": "तिथि सीमा", + "label.sort": "क्रमबद्ध करें", + "label.filters": "फ़िल्टर", + "filter.all": "सभी", + "filter.images": "छवियां", + "filter.videos": "वीडियो", + "filter.onlyWithWorkflow": "सिर्फ़ वर्कफ़्लो वाले", + "filter.anyRating": "कोई भी रेटिंग", + "filter.anytime": "कभी भी", + "filter.today": "आज", + "filter.yesterday": "कल", + "sort.newest": "नवीनतम पहले", + "sort.oldest": "पुराना पहले", + "sort.nameAZ": "नाम A-Z", + "sort.nameZA": "नाम Z-A", + "status.ready": "तैयार", + "status.scanning": "स्कैन हो रहा है...", + "status.error": "त्रुटि", + "msg.noResults": "कोई परिणाम नहीं मिला।", + "msg.loading": "लोड हो रहा है...", + "msg.addCustomFolder": "ब्राउज़ करने के लिए एक कस्टम फ़ोल्डर जोड़ें।", + "msg.errorLoading": "लोड करने में त्रुटि", + "sidebar.details": "विवरण", + "sidebar.preview": "पूर्वावलोकन", + "sidebar.rating": "रेटिंग", + "sidebar.tags": "टैग", + "ctx.openViewer": "व्यूअर में खोलें", + "ctx.copyPath": "पाथ कॉपी करें", + "ctx.openInFolder": "फ़ोल्डर में खोलें", + "ctx.rename": "नाम बदलें", + "ctx.delete": "हटाएं", + "toast.ratingUpdateFailed": "रेटिंग अपडेट विफल", + "toast.tagsUpdated": "टैग अपडेट हो गए", + "toast.pathCopied": "पाथ क्लिपबोर्ड में कॉपी हो गया", }, "pt-PT": { "cat.grid": "Grelha", - "cat.cards": "Cartões", + "cat.cards": "Cartões", "cat.badges": "Emblemas", "cat.viewer": "Visualizador", - "cat.scanning": "Análise", - "cat.advanced": "Avançado", - "cat.security": "Segurança", + "cat.scanning": "Análise", + "cat.advanced": "Avançado", + "cat.security": "Segurança", "cat.remote": "Acesso remoto", "setting.language.name": "Majoor: Idioma", "setting.language.desc": "Escolha o idioma da interface do Assets Manager. Recarregar para aplicar totalmente.", - "tab.output": "Saída", + "tab.output": "Saída", "tab.input": "Entrada", "tab.custom": "Personalizado", - "btn.add": "Adicionar…", + "btn.add": "Adicionar…", "btn.remove": "Remover", "btn.retry": "Tentar novamente", "btn.clear": "Limpar", @@ -1013,15 +1068,15 @@ const CORE_TRANSLATIONS = { "label.folder": "Pasta", "label.type": "Tipo", "label.workflow": "Fluxo", - "label.rating": "Classificação", + "label.rating": "Classificação", "label.dateRange": "Intervalo de datas", "label.sort": "Ordenar", "label.filters": "Filtros", "filter.all": "Todos", "filter.images": "Imagens", - "filter.videos": "Vídeos", + "filter.videos": "Vídeos", "filter.onlyWithWorkflow": "Apenas com workflow", - "filter.anyRating": "Qualquer classificação", + "filter.anyRating": "Qualquer classificação", "filter.anytime": "Qualquer altura", "filter.today": "Hoje", "filter.yesterday": "Ontem", @@ -1037,20 +1092,20 @@ const CORE_TRANSLATIONS = { "msg.addCustomFolder": "Adicione uma pasta personalizada para navegar.", "msg.errorLoading": "Erro ao carregar", "sidebar.details": "Detalhes", - "sidebar.preview": "Pré-visualização", - "sidebar.rating": "Classificação", + "sidebar.preview": "Pré-visualização", + "sidebar.rating": "Classificação", "sidebar.tags": "Etiquetas", "ctx.openViewer": "Abrir no visualizador", "ctx.copyPath": "Copiar caminho", "ctx.openInFolder": "Abrir na pasta", "ctx.rename": "Renomear", "ctx.delete": "Eliminar", - "toast.ratingUpdateFailed": "Falha ao atualizar classificação", + "toast.ratingUpdateFailed": "Falha ao atualizar classificação", "toast.tagsUpdated": "Etiquetas atualizadas", - "toast.pathCopied": "Caminho copiado para a área de transferência", + "toast.pathCopied": "Caminho copiado para a área de transferência", }, "es-ES": { - "cat.grid": "Cuadrícula", + "cat.grid": "Cuadrícula", "cat.cards": "Tarjetas", "cat.badges": "Insignias", "cat.viewer": "Visor", @@ -1063,7 +1118,7 @@ const CORE_TRANSLATIONS = { "tab.output": "Salida", "tab.input": "Entrada", "tab.custom": "Personalizado", - "btn.add": "Agregar…", + "btn.add": "Agregar…", "btn.remove": "Eliminar", "btn.retry": "Reintentar", "btn.clear": "Limpiar", @@ -1073,20 +1128,20 @@ const CORE_TRANSLATIONS = { "label.folder": "Carpeta", "label.type": "Tipo", "label.workflow": "Workflow", - "label.rating": "Valoración", + "label.rating": "Valoración", "label.dateRange": "Rango de fechas", "label.sort": "Ordenar", "label.filters": "Filtros", "filter.all": "Todo", - "filter.images": "Imágenes", - "filter.videos": "Vídeos", + "filter.images": "Imágenes", + "filter.videos": "Vídeos", "filter.onlyWithWorkflow": "Solo con workflow", - "filter.anyRating": "Cualquier valoración", + "filter.anyRating": "Cualquier valoración", "filter.anytime": "En cualquier momento", "filter.today": "Hoy", "filter.yesterday": "Ayer", - "sort.newest": "Más nuevos primero", - "sort.oldest": "Más antiguos primero", + "sort.newest": "Más nuevos primero", + "sort.oldest": "Más antiguos primero", "sort.nameAZ": "Nombre A-Z", "sort.nameZA": "Nombre Z-A", "status.ready": "Listo", @@ -1098,76 +1153,76 @@ const CORE_TRANSLATIONS = { "msg.errorLoading": "Error al cargar", "sidebar.details": "Detalles", "sidebar.preview": "Vista previa", - "sidebar.rating": "Valoración", + "sidebar.rating": "Valoración", "sidebar.tags": "Etiquetas", "ctx.openViewer": "Abrir en visor", "ctx.copyPath": "Copiar ruta", "ctx.openInFolder": "Abrir en carpeta", "ctx.rename": "Renombrar", "ctx.delete": "Eliminar", - "toast.ratingUpdateFailed": "Error al actualizar la valoración", + "toast.ratingUpdateFailed": "Error al actualizar la valoración", "toast.tagsUpdated": "Etiquetas actualizadas", "toast.pathCopied": "Ruta copiada al portapapeles", }, "ru-RU": { - "cat.grid": "Сетка", - "cat.cards": "Карточки", - "cat.badges": "Значки", - "cat.viewer": "Просмотрщик", - "cat.scanning": "Сканирование", - "cat.advanced": "Расширенные", - "cat.security": "Безопасность", - "cat.remote": "Удаленный доступ", - "setting.language.name": "Majoor: Язык", - "setting.language.desc": "Выберите язык интерфейса Assets Manager. Для полного применения требуется перезагрузка.", - "tab.output": "Выход", - "tab.input": "Вход", - "tab.custom": "Пользовательский", - "btn.add": "Добавить…", - "btn.remove": "Удалить", - "btn.retry": "Повторить", - "btn.clear": "Очистить", - "btn.refresh": "Обновить", - "btn.scan": "Сканировать", - "btn.close": "Закрыть", - "label.folder": "Папка", - "label.type": "Тип", - "label.workflow": "Воркфлоу", - "label.rating": "Рейтинг", - "label.dateRange": "Диапазон дат", - "label.sort": "Сортировка", - "label.filters": "Фильтры", - "filter.all": "Все", - "filter.images": "Изображения", - "filter.videos": "Видео", - "filter.onlyWithWorkflow": "Только с workflow", - "filter.anyRating": "Любой рейтинг", - "filter.anytime": "За всё время", - "filter.today": "Сегодня", - "filter.yesterday": "Вчера", - "sort.newest": "Сначала новые", - "sort.oldest": "Сначала старые", - "sort.nameAZ": "Имя A-Z", - "sort.nameZA": "Имя Z-A", - "status.ready": "Готово", - "status.scanning": "Сканирование...", - "status.error": "Ошибка", - "msg.noResults": "Результаты не найдены.", - "msg.loading": "Загрузка...", - "msg.addCustomFolder": "Добавьте пользовательскую папку для просмотра.", - "msg.errorLoading": "Ошибка загрузки", - "sidebar.details": "Подробности", - "sidebar.preview": "Предпросмотр", - "sidebar.rating": "Рейтинг", - "sidebar.tags": "Теги", - "ctx.openViewer": "Открыть в просмотрщике", - "ctx.copyPath": "Копировать путь", - "ctx.openInFolder": "Открыть в папке", - "ctx.rename": "Переименовать", - "ctx.delete": "Удалить", - "toast.ratingUpdateFailed": "Не удалось обновить рейтинг", - "toast.tagsUpdated": "Теги обновлены", - "toast.pathCopied": "Путь скопирован в буфер обмена", + "cat.grid": "Сетка", + "cat.cards": "Карточки", + "cat.badges": "Значки", + "cat.viewer": "Просмотрщик", + "cat.scanning": "Сканирование", + "cat.advanced": "Расширенные", + "cat.security": "Безопасность", + "cat.remote": "Удаленный доступ", + "setting.language.name": "Majoor: Язык", + "setting.language.desc": "Выберите язык интерфейса Assets Manager. Для полного применения требуется перезагрузка.", + "tab.output": "Выход", + "tab.input": "Вход", + "tab.custom": "Пользовательский", + "btn.add": "Добавить…", + "btn.remove": "Удалить", + "btn.retry": "Повторить", + "btn.clear": "Очистить", + "btn.refresh": "Обновить", + "btn.scan": "Сканировать", + "btn.close": "Закрыть", + "label.folder": "Папка", + "label.type": "Тип", + "label.workflow": "Воркфлоу", + "label.rating": "Рейтинг", + "label.dateRange": "Диапазон дат", + "label.sort": "Сортировка", + "label.filters": "Фильтры", + "filter.all": "Все", + "filter.images": "Изображения", + "filter.videos": "Видео", + "filter.onlyWithWorkflow": "Только с workflow", + "filter.anyRating": "Любой рейтинг", + "filter.anytime": "За всё время", + "filter.today": "Сегодня", + "filter.yesterday": "Вчера", + "sort.newest": "Сначала новые", + "sort.oldest": "Сначала старые", + "sort.nameAZ": "Имя A-Z", + "sort.nameZA": "Имя Z-A", + "status.ready": "Готово", + "status.scanning": "Сканирование...", + "status.error": "Ошибка", + "msg.noResults": "Результаты не найдены.", + "msg.loading": "Загрузка...", + "msg.addCustomFolder": "Добавьте пользовательскую папку для просмотра.", + "msg.errorLoading": "Ошибка загрузки", + "sidebar.details": "Подробности", + "sidebar.preview": "Предпросмотр", + "sidebar.rating": "Рейтинг", + "sidebar.tags": "Теги", + "ctx.openViewer": "Открыть в просмотрщике", + "ctx.copyPath": "Копировать путь", + "ctx.openInFolder": "Открыть в папке", + "ctx.rename": "Переименовать", + "ctx.delete": "Удалить", + "toast.ratingUpdateFailed": "Не удалось обновить рейтинг", + "toast.tagsUpdated": "Теги обновлены", + "toast.pathCopied": "Путь скопирован в буфер обмена", }, }; @@ -1187,16 +1242,17 @@ Object.keys(DICTIONARY).forEach((code) => { DICTIONARY[code] = { ...EN_US_DICT, ...(DICTIONARY[code] || {}) }; }); -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // API -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── /** * Map various locale codes to our supported languages. */ function mapLocale(locale) { if (!locale) return DEFAULT_LANG; - const lower = locale.toLowerCase(); + const raw = String(locale || "").trim(); + const lower = raw.toLowerCase(); // French variants if (lower.startsWith("fr")) return "fr-FR"; @@ -1226,45 +1282,118 @@ function mapLocale(locale) { if (lower.startsWith("ru")) return "ru-RU"; // Direct match - if (DICTIONARY[locale]) return locale; + if (DICTIONARY[raw]) return raw; return DEFAULT_LANG; } +function _readStoredLang() { + try { + if (typeof localStorage === "undefined") return ""; + for (const key of LANG_STORAGE_KEYS) { + const value = String(localStorage.getItem(key) || "").trim(); + if (value) return value; + } + } catch {} + return ""; +} + +function _persistLang(lang) { + try { + if (typeof localStorage === "undefined") return; + // Keep legacy and new key in sync for smooth upgrades. + localStorage.setItem(LANG_STORAGE_KEYS[0], lang); + localStorage.setItem(LANG_STORAGE_KEYS[1], lang); + } catch {} +} + +function _readComfyLocaleCandidates(app) { + const out = []; + const pushCandidate = (value) => { + if (typeof value !== "string") return; + const v = value.trim(); + if (v) out.push(v); + }; + const settingKeys = [ + "AGL.Locale", + "Comfy.Locale", + "Comfy.LocaleCode", + "ComfyUI.Locale", + "ComfyUI.Frontend.Locale", + ]; + for (const key of settingKeys) { + pushCandidate(getSettingValue(app, key)); + } + + // Additional frontend locale surfaces (if present). + pushCandidate(app?.ui?.locale); + pushCandidate(app?.locale); + pushCandidate(app?.ui?.i18n?.locale); + + return out; +} + +function _readPlatformLocaleCandidates() { + const out = []; + const pushCandidate = (value) => { + if (typeof value !== "string") return; + const v = value.trim(); + if (v) out.push(v); + }; + try { + if (typeof document !== "undefined") { + pushCandidate(document?.documentElement?.lang); + } + } catch {} + try { + if (typeof navigator !== "undefined") { + pushCandidate(navigator?.language); + const langs = Array.isArray(navigator?.languages) ? navigator.languages : []; + for (const lang of langs) pushCandidate(lang); + } + } catch {} + return out; +} + /** * Detect and set language from ComfyUI settings. - * Tries multiple sources: AGL.Locale, Comfy.Locale, localStorage. + * Tries multiple sources compatible with legacy and modern ComfyUI frontends. */ export const initI18n = (app) => { try { - // 1. Try AGL.Locale (ComfyUI-Translation extension) - const aglLocale = app?.ui?.settings?.getSettingValue?.("AGL.Locale"); - if (aglLocale && typeof aglLocale === "string" && DICTIONARY[mapLocale(aglLocale)]) { - setLang(mapLocale(aglLocale)); + // 1) Respect explicit user choice first. + const stored = _readStoredLang(); + const storedMapped = mapLocale(stored); + if (stored && DICTIONARY[storedMapped]) { + setLang(storedMapped); return; } - // 2. Try generic Comfy.Locale - const comfyLocale = app?.ui?.settings?.getSettingValue?.("Comfy.Locale"); - if (comfyLocale && typeof comfyLocale === "string") { - const mapped = mapLocale(comfyLocale); + // 2) Try ComfyUI settings / runtime locale surfaces. + const comfyCandidates = _readComfyLocaleCandidates(app); + for (const candidate of comfyCandidates) { + const mapped = mapLocale(candidate); if (DICTIONARY[mapped]) { setLang(mapped); return; } } - // 3. Try localStorage for persisted Majoor language preference - try { - const stored = localStorage.getItem("mjr_lang"); - if (stored && DICTIONARY[stored]) { - setLang(stored); + // 3) Browser / document locale fallback. + const platformCandidates = _readPlatformLocaleCandidates(); + for (const candidate of platformCandidates) { + const mapped = mapLocale(candidate); + if (DICTIONARY[mapped]) { + setLang(mapped); return; } - } catch {} + } + // 4) Guaranteed fallback. + setLang(DEFAULT_LANG); } catch (err) { console.warn("[Majoor i18n] Failed to detect language:", err); + setLang(DEFAULT_LANG); } }; @@ -1282,9 +1411,7 @@ export const setLang = (lang) => { currentLang = lang; // Persist preference - try { - localStorage.setItem("mjr_lang", lang); - } catch {} + _persistLang(lang); // Notify listeners _langChangeListeners.forEach(cb => { @@ -1318,6 +1445,20 @@ export const t = (key, defaultOrParams, params) => { let text = dict[key] || fallbackDict[key]; if (!text) { + const missingId = `${currentLang}:${String(key || "")}`; + if (!_missingTranslationKeys.has(missingId)) { + _missingTranslationKeys.add(missingId); + try { + console.warn(`[Majoor i18n] Missing translation key "${key}" for locale "${currentLang}"`); + } catch {} + try { + if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") { + window.dispatchEvent(new CustomEvent("mjr-i18n-missing-key", { + detail: { key: String(key || ""), locale: currentLang } + })); + } + } catch {} + } // Return default or key if (typeof defaultOrParams === "string") return defaultOrParams; return key; diff --git a/js/app/settings.js b/js/app/settings.js index c4731ca6..877fb881 100644 --- a/js/app/settings.js +++ b/js/app/settings.js @@ -3,7 +3,8 @@ */ import { APP_CONFIG, APP_DEFAULTS } from "./config.js"; -import { getSecuritySettings, setSecuritySettings, setProbeBackendMode, getOutputDirectorySetting, setOutputDirectorySetting, getWatcherStatus, toggleWatcher, getWatcherSettings, updateWatcherSettings, getRuntimeStatus } from "../api/client.js"; +import { getSecuritySettings, setSecuritySettings, setProbeBackendMode, getMetadataFallbackSettings, setMetadataFallbackSettings, getOutputDirectorySetting, setOutputDirectorySetting, getWatcherStatus, toggleWatcher, getWatcherSettings, updateWatcherSettings, getRuntimeStatus } from "../api/client.js"; +import { getSettingsApi } from "./comfyApiBridge.js"; import { comfyToast } from "./toast.js"; import { safeDispatchCustomEvent } from "../utils/events.js"; import { t, initI18n, setLang, getCurrentLang, getSupportedLanguages } from "./i18n.js"; @@ -36,12 +37,13 @@ const DEFAULT_SETTINGS = { showDimensions: APP_DEFAULTS.GRID_SHOW_DETAILS_DIMENSIONS, showGenTime: APP_DEFAULTS.GRID_SHOW_DETAILS_GENTIME, showWorkflowDot: APP_DEFAULTS.GRID_SHOW_WORKFLOW_DOT, - videoHoverAutoplay: APP_DEFAULTS.GRID_VIDEO_HOVER_AUTOPLAY, + videoAutoplayMode: APP_DEFAULTS.GRID_VIDEO_AUTOPLAY_MODE, starColor: APP_DEFAULTS.BADGE_STAR_COLOR, badgeImageColor: APP_DEFAULTS.BADGE_IMAGE_COLOR, badgeVideoColor: APP_DEFAULTS.BADGE_VIDEO_COLOR, badgeAudioColor: APP_DEFAULTS.BADGE_AUDIO_COLOR, badgeModel3dColor: APP_DEFAULTS.BADGE_MODEL3D_COLOR, + badgeDuplicateAlertColor: APP_DEFAULTS.BADGE_DUPLICATE_ALERT_COLOR, }, infiniteScroll: { enabled: APP_DEFAULTS.INFINITE_SCROLL_ENABLED, @@ -95,6 +97,10 @@ const DEFAULT_SETTINGS = { probeBackend: { mode: "auto", }, + metadataFallback: { + image: true, + media: true, + }, paths: { outputDirectory: "", }, @@ -180,12 +186,16 @@ const detectGridSizePreset = (minSize) => { return "medium"; }; +const _isUnsafeMergeKey = (key) => + key === "__proto__" || key === "prototype" || key === "constructor"; + const deepMerge = (base, next) => { const output = { ...base }; if (!next || typeof next !== "object") { return output; } Object.keys(next).forEach((key) => { + if (_isUnsafeMergeKey(key)) return; const value = next[key]; if (value && typeof value === "object" && !Array.isArray(value)) { output[key] = deepMerge(base[key] || {}, value); @@ -244,13 +254,13 @@ function ensureRuntimeStatusDashboard() { async function refreshRuntimeStatusDashboard() { const el = ensureRuntimeStatusDashboard(); - if (!el) return; + if (!el) return false; try { const res = await getRuntimeStatus(); if (!res?.ok || !res?.data) { el.textContent = "Runtime: unavailable"; el.title = "Runtime metrics unavailable"; - return; + return true; } const db = res.data.db || {}; const idx = res.data.index || {}; @@ -260,18 +270,44 @@ async function refreshRuntimeStatusDashboard() { const pending = Number(w.pending_files || 0); el.textContent = `DB active: ${active} | Enrich Q: ${enrichQ} | Watcher pending: ${pending}`; el.title = `Runtime Metrics\nDB active connections: ${active}\nEnrichment queue: ${enrichQ}\nWatcher pending files: ${pending}`; + return true; } catch { el.textContent = "Runtime: unavailable"; el.title = "Runtime metrics unavailable"; + return true; } } export function startRuntimeStatusDashboard() { try { refreshRuntimeStatusDashboard().catch(() => {}); + if (window.__MJR_RUNTIME_STATUS_INFLIGHT__ == null) { + window.__MJR_RUNTIME_STATUS_INFLIGHT__ = false; + } + if (window.__MJR_RUNTIME_STATUS_MISS_COUNT__ == null) { + window.__MJR_RUNTIME_STATUS_MISS_COUNT__ = 0; + } if (!window.__MJR_RUNTIME_STATUS_INTERVAL__) { window.__MJR_RUNTIME_STATUS_INTERVAL__ = setInterval(() => { - refreshRuntimeStatusDashboard().catch(() => {}); + if (window.__MJR_RUNTIME_STATUS_INFLIGHT__) return; + window.__MJR_RUNTIME_STATUS_INFLIGHT__ = true; + refreshRuntimeStatusDashboard() + .then((visible) => { + if (visible) { + window.__MJR_RUNTIME_STATUS_MISS_COUNT__ = 0; + return; + } + window.__MJR_RUNTIME_STATUS_MISS_COUNT__ = Number(window.__MJR_RUNTIME_STATUS_MISS_COUNT__ || 0) + 1; + // Stop background polling if panel is absent for ~30s. + if (window.__MJR_RUNTIME_STATUS_MISS_COUNT__ >= 10 && window.__MJR_RUNTIME_STATUS_INTERVAL__) { + clearInterval(window.__MJR_RUNTIME_STATUS_INTERVAL__); + window.__MJR_RUNTIME_STATUS_INTERVAL__ = null; + } + }) + .catch(() => {}) + .finally(() => { + window.__MJR_RUNTIME_STATUS_INFLIGHT__ = false; + }); }, 3000); } } catch {} @@ -367,7 +403,18 @@ const applySettingsToConfig = (settings) => { APP_CONFIG.GRID_SHOW_DETAILS_DIMENSIONS = !!(settings.grid?.showDimensions ?? APP_DEFAULTS.GRID_SHOW_DETAILS_DIMENSIONS); APP_CONFIG.GRID_SHOW_DETAILS_GENTIME = !!(settings.grid?.showGenTime ?? APP_DEFAULTS.GRID_SHOW_DETAILS_GENTIME); APP_CONFIG.GRID_SHOW_WORKFLOW_DOT = !!(settings.grid?.showWorkflowDot ?? APP_DEFAULTS.GRID_SHOW_WORKFLOW_DOT); - APP_CONFIG.GRID_VIDEO_HOVER_AUTOPLAY = !!(settings.grid?.videoHoverAutoplay ?? APP_DEFAULTS.GRID_VIDEO_HOVER_AUTOPLAY); + // Video autoplay mode: migrate old boolean → new tri-state + { + let mode = settings.grid?.videoAutoplayMode ?? APP_DEFAULTS.GRID_VIDEO_AUTOPLAY_MODE; + // Backward compat: old boolean "videoHoverAutoplay" → "hover" + if (mode === undefined || mode === null) { + mode = settings.grid?.videoHoverAutoplay ? "hover" : "off"; + } + if (mode === true) mode = "hover"; + if (mode === false) mode = "off"; + if (mode !== "hover" && mode !== "always") mode = "off"; + APP_CONFIG.GRID_VIDEO_AUTOPLAY_MODE = mode; + } const _safeColor = (v, fallback) => { let c = String(v || "").trim(); @@ -379,6 +426,7 @@ const applySettingsToConfig = (settings) => { APP_CONFIG.BADGE_VIDEO_COLOR = _safeColor(settings.grid?.badgeVideoColor, APP_DEFAULTS.BADGE_VIDEO_COLOR); APP_CONFIG.BADGE_AUDIO_COLOR = _safeColor(settings.grid?.badgeAudioColor, APP_DEFAULTS.BADGE_AUDIO_COLOR); APP_CONFIG.BADGE_MODEL3D_COLOR = _safeColor(settings.grid?.badgeModel3dColor, APP_DEFAULTS.BADGE_MODEL3D_COLOR); + APP_CONFIG.BADGE_DUPLICATE_ALERT_COLOR = _safeColor(settings.grid?.badgeDuplicateAlertColor, APP_DEFAULTS.BADGE_DUPLICATE_ALERT_COLOR); APP_CONFIG.UI_CARD_HOVER_COLOR = _safeColor(settings.ui?.cardHoverColor, APP_DEFAULTS.UI_CARD_HOVER_COLOR); APP_CONFIG.UI_CARD_SELECTION_COLOR = _safeColor(settings.ui?.cardSelectionColor, APP_DEFAULTS.UI_CARD_SELECTION_COLOR); APP_CONFIG.UI_RATING_COLOR = _safeColor(settings.ui?.ratingColor, APP_DEFAULTS.UI_RATING_COLOR); @@ -392,6 +440,7 @@ const applySettingsToConfig = (settings) => { root.style.setProperty("--mjr-badge-video", APP_CONFIG.BADGE_VIDEO_COLOR); root.style.setProperty("--mjr-badge-audio", APP_CONFIG.BADGE_AUDIO_COLOR); root.style.setProperty("--mjr-badge-model3d", APP_CONFIG.BADGE_MODEL3D_COLOR); + root.style.setProperty("--mjr-badge-duplicate-alert", APP_CONFIG.BADGE_DUPLICATE_ALERT_COLOR); root.style.setProperty("--mjr-card-hover-color", APP_CONFIG.UI_CARD_HOVER_COLOR); root.style.setProperty("--mjr-card-selection-color", APP_CONFIG.UI_CARD_SELECTION_COLOR); root.style.setProperty("--mjr-rating-color", APP_CONFIG.UI_RATING_COLOR); @@ -478,6 +527,7 @@ export const registerMajoorSettings = (app, onApplied) => { "grid.badgeVideoColor", "grid.badgeAudioColor", "grid.badgeModel3dColor", + "grid.badgeDuplicateAlertColor", "ui.cardHoverColor", "ui.cardSelectionColor", "ui.ratingColor", @@ -534,13 +584,7 @@ export const registerMajoorSettings = (app, onApplied) => { } const tryRegister = () => { - // ComfyUI API variants (legacy/new UI builds): try multiple known paths. - const settingsApi = - app?.ui?.settings || - app?.settings || - app?.ui?.api?.settings || - app?.api?.settings || - null; + const settingsApi = getSettingsApi(app); const addSetting = settingsApi?.addSetting; if (typeof addSetting !== "function") { try { @@ -898,6 +942,21 @@ export const registerMajoorSettings = (app, onApplied) => { }, }); + safeAddSetting({ + id: `${SETTINGS_PREFIX}.Badges.DuplicateAlertColor`, + category: colorCat(t("setting.badgeDuplicateAlertColor", "Duplicate alert badge color")), + name: t("setting.badgeDuplicateAlertColor", "Majoor: Duplicate alert badge color"), + tooltip: t("setting.badgeDuplicateAlertColor.tooltip", "Color for duplicate extension badges (PNG+, JPG+, etc)."), + type: "color", + defaultValue: normalizeHexColor(settings.grid?.badgeDuplicateAlertColor, APP_DEFAULTS.BADGE_DUPLICATE_ALERT_COLOR), + onChange: (value) => { + settings.grid.badgeDuplicateAlertColor = normalizeHexColor(value, APP_DEFAULTS.BADGE_DUPLICATE_ALERT_COLOR); + saveMajoorSettings(settings); + applySettingsToConfig(settings); + notifyApplied("grid.badgeDuplicateAlertColor"); + }, + }); + // ────────────────────────────────────────────── // Section: Grid // ────────────────────────────────────────────── @@ -982,21 +1041,49 @@ export const registerMajoorSettings = (app, onApplied) => { }); safeAddSetting({ - id: `${SETTINGS_PREFIX}.Grid.VideoHoverAutoplay`, - category: cat(t("cat.grid"), t("setting.grid.videoHoverAutoplay.name", "Video hover autoplay").replace("Majoor: ", "")), - name: t("setting.grid.videoHoverAutoplay.name", "Majoor: Video hover autoplay"), + id: `${SETTINGS_PREFIX}.Grid.VideoAutoplayMode`, + category: cat(t("cat.grid"), t("setting.grid.videoAutoplayMode.name", "Video autoplay").replace("Majoor: ", "")), + name: t("setting.grid.videoAutoplayMode.name", "Majoor: Video autoplay"), tooltip: t( - "setting.grid.videoHoverAutoplay.desc", - "Play video thumbnails only while hovered and visible in the grid." + "setting.grid.videoAutoplayMode.desc", + "Controls video thumbnail playback in the grid. Off: static frame. Hover: play on mouse hover. Always: loop while visible." ), - type: "boolean", - defaultValue: !!(settings.grid?.videoHoverAutoplay ?? APP_DEFAULTS.GRID_VIDEO_HOVER_AUTOPLAY), + type: "combo", + defaultValue: (() => { + let mode = settings.grid?.videoAutoplayMode; + // Backward compat from old boolean + if (mode === undefined || mode === null) { + mode = settings.grid?.videoHoverAutoplay ? "hover" : "off"; + } + if (mode === true) mode = "hover"; + if (mode === false) mode = "off"; + if (mode !== "hover" && mode !== "always") mode = "off"; + const labels = { + off: t("setting.grid.videoAutoplayMode.off", "Off"), + hover: t("setting.grid.videoAutoplayMode.hover", "Hover"), + always: t("setting.grid.videoAutoplayMode.always", "Always"), + }; + return labels[mode] || labels.off; + })(), + options: [ + t("setting.grid.videoAutoplayMode.off", "Off"), + t("setting.grid.videoAutoplayMode.hover", "Hover"), + t("setting.grid.videoAutoplayMode.always", "Always"), + ], onChange: (value) => { + const labelToMode = { + [t("setting.grid.videoAutoplayMode.off", "Off")]: "off", + [t("setting.grid.videoAutoplayMode.hover", "Hover")]: "hover", + [t("setting.grid.videoAutoplayMode.always", "Always")]: "always", + }; + const mode = labelToMode[value] || "off"; settings.grid = settings.grid || {}; - settings.grid.videoHoverAutoplay = !!value; + settings.grid.videoAutoplayMode = mode; + // Clean up legacy key + delete settings.grid.videoHoverAutoplay; saveMajoorSettings(settings); applySettingsToConfig(settings); - notifyApplied("grid.videoHoverAutoplay"); + notifyApplied("grid.videoAutoplayMode"); }, }); @@ -1501,6 +1588,89 @@ export const registerMajoorSettings = (app, onApplied) => { }, }); + safeAddSetting({ + id: `${SETTINGS_PREFIX}.MetadataFallback.Image`, + category: cat(t("cat.advanced"), "Metadata"), + name: "Majoor: Metadata Fallback (Images)", + tooltip: "Enable Pillow fallback when ExifTool is missing or fails.", + type: "boolean", + defaultValue: settings.metadataFallback?.image ?? DEFAULT_SETTINGS.metadataFallback.image, + onChange: async (value) => { + const next = !!value; + const previous = !!(settings.metadataFallback?.image ?? DEFAULT_SETTINGS.metadataFallback.image); + settings.metadataFallback = settings.metadataFallback || {}; + settings.metadataFallback.image = next; + saveMajoorSettings(settings); + notifyApplied("metadataFallback.image"); + try { + const res = await setMetadataFallbackSettings({ + image: next, + media: settings.metadataFallback?.media ?? DEFAULT_SETTINGS.metadataFallback.media, + }); + if (!res?.ok) throw new Error(res?.error || "Failed to update metadata fallback settings"); + } catch (error) { + settings.metadataFallback.image = previous; + saveMajoorSettings(settings); + notifyApplied("metadataFallback.image"); + comfyToast(error?.message || "Failed to update metadata fallback settings", "error"); + } + }, + }); + + safeAddSetting({ + id: `${SETTINGS_PREFIX}.MetadataFallback.Media`, + category: cat(t("cat.advanced"), "Metadata"), + name: "Majoor: Metadata Fallback (Audio/Video)", + tooltip: "Enable hachoir fallback when ffprobe is missing or fails.", + type: "boolean", + defaultValue: settings.metadataFallback?.media ?? DEFAULT_SETTINGS.metadataFallback.media, + onChange: async (value) => { + const next = !!value; + const previous = !!(settings.metadataFallback?.media ?? DEFAULT_SETTINGS.metadataFallback.media); + settings.metadataFallback = settings.metadataFallback || {}; + settings.metadataFallback.media = next; + saveMajoorSettings(settings); + notifyApplied("metadataFallback.media"); + try { + const res = await setMetadataFallbackSettings({ + image: settings.metadataFallback?.image ?? DEFAULT_SETTINGS.metadataFallback.image, + media: next, + }); + if (!res?.ok) throw new Error(res?.error || "Failed to update metadata fallback settings"); + } catch (error) { + settings.metadataFallback.media = previous; + saveMajoorSettings(settings); + notifyApplied("metadataFallback.media"); + comfyToast(error?.message || "Failed to update metadata fallback settings", "error"); + } + }, + }); + + try { + getMetadataFallbackSettings() + .then((res) => { + if (!res?.ok || !res?.data?.prefs) return; + const prefs = res.data.prefs || {}; + const image = !!(prefs.image ?? DEFAULT_SETTINGS.metadataFallback.image); + const media = !!(prefs.media ?? DEFAULT_SETTINGS.metadataFallback.media); + settings.metadataFallback = settings.metadataFallback || {}; + let changed = false; + if (settings.metadataFallback.image !== image) { + settings.metadataFallback.image = image; + changed = true; + } + if (settings.metadataFallback.media !== media) { + settings.metadataFallback.media = media; + changed = true; + } + if (changed) { + saveMajoorSettings(settings); + notifyApplied("metadataFallback"); + } + }) + .catch(() => {}); + } catch {} + safeAddSetting({ id: `${SETTINGS_PREFIX}.Paths.OutputDirectory`, category: cat(t("cat.advanced"), "Paths"), diff --git a/js/app/toast.js b/js/app/toast.js index 081e505f..c5ee5a45 100644 --- a/js/app/toast.js +++ b/js/app/toast.js @@ -4,7 +4,7 @@ * Provides transient notification messages (Toasts) that mimic ComfyUI native style. * Supports: success, error, warning, info. */ -import { app } from "../../../scripts/app.js"; +import { getComfyApp } from "./comfyApiBridge.js"; import { t } from "./i18n.js"; const TOAST_CONTAINER_ID = "mjr-toast-container"; @@ -125,6 +125,7 @@ function getToastContainer() { */ export function comfyToast(message, type = "info", duration = 3000) { message = translateToastMessage(message); + const app = getComfyApp(); // 1. Try ComfyUI extensionManager toast (ComfyUI v1.3+ standard) // See: ComfyUI-Majoor-NodeFlow reference implementation try { diff --git a/js/components/Badges.js b/js/components/Badges.js index d7583b01..e708d32b 100644 --- a/js/components/Badges.js +++ b/js/components/Badges.js @@ -5,7 +5,26 @@ /** * Create file type badge (overlaid on thumbnail) */ -export function createFileBadge(filename, kind, nameCollision = false) { +function _buildCollisionTitle({ ext = "", filename = "", count = 0, paths = [] } = {}) { + const safeExt = String(ext || "").trim(); + const safeName = String(filename || "").trim(); + const n = Math.max(0, Number(count) || 0); + const list = Array.isArray(paths) ? paths.map((p) => String(p || "").trim()).filter(Boolean) : []; + if (n < 2) return `${safeExt} file`; + const lines = [ + `${safeExt}+ name collision in current view (${n})`, + ]; + if (safeName) lines.push(`Name: ${safeName}`); + if (list.length) { + lines.push("Paths:"); + for (const p of list.slice(0, 4)) lines.push(`- ${p}`); + if (list.length > 4) lines.push(`- ... +${list.length - 4} more`); + } + lines.push("Click to select collisions in current view"); + return lines.join("\n"); +} + +export function createFileBadge(filename, kind, nameCollision = false, collisionMeta = null) { const badge = document.createElement("div"); badge.className = "mjr-file-badge"; @@ -39,8 +58,18 @@ export function createFileBadge(filename, kind, nameCollision = false) { ? `var(${cssVar}, #607D8B)` : "#607D8B"; + const alertBg = "var(--mjr-badge-duplicate-alert, #ff1744)"; + const normalBg = bgColor; + const currentBg = nameCollision ? alertBg : normalBg; badge.textContent = ext + (nameCollision ? "+" : ""); - badge.title = nameCollision ? `${ext} file (duplicate filename in view)` : `${ext} file`; + badge.title = nameCollision + ? _buildCollisionTitle({ + ext, + filename, + count: collisionMeta?.count, + paths: collisionMeta?.paths, + }) + : `${ext} file`; badge.style.cssText = ` position: absolute; top: 6px; @@ -49,7 +78,7 @@ export function createFileBadge(filename, kind, nameCollision = false) { border-radius: 4px; font-size: 10px; font-weight: 700; - background: ${bgColor}; + background: ${currentBg}; opacity: 0.85; color: white; text-transform: uppercase; @@ -57,16 +86,32 @@ export function createFileBadge(filename, kind, nameCollision = false) { z-index: 10; letter-spacing: 0.5px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); + cursor: ${nameCollision ? "pointer" : "default"}; `; + try { + badge.dataset.mjrBadgeBg = normalBg; + } catch {} return badge; } -export function setFileBadgeCollision(badgeEl, nameCollision) { +export function setFileBadgeCollision(badgeEl, nameCollision, collisionMeta = null) { if (!badgeEl) return; try { const ext = badgeEl.dataset?.mjrExt || ""; + const normalBg = badgeEl.dataset?.mjrBadgeBg || "var(--mjr-badge-image, #607D8B)"; + const alertBg = "var(--mjr-badge-duplicate-alert, #ff1744)"; badgeEl.textContent = String(ext || "") + (nameCollision ? "+" : ""); + badgeEl.title = nameCollision + ? _buildCollisionTitle({ + ext, + filename: collisionMeta?.filename || "", + count: collisionMeta?.count, + paths: collisionMeta?.paths, + }) + : `${ext} file`; + badgeEl.style.background = nameCollision ? alertBg : normalBg; + badgeEl.style.cursor = nameCollision ? "pointer" : "default"; } catch {} } diff --git a/js/components/Card.js b/js/components/Card.js index c5649831..62d9ca00 100644 --- a/js/components/Card.js +++ b/js/components/Card.js @@ -37,18 +37,26 @@ const AUDIO_THUMB_URL = (() => { */ const VIDEO_THUMBS_KEY = "__MJR_VIDEO_THUMBS__"; -const VIDEO_THUMB_MAX_AUTOPLAY = 6; +const VIDEO_THUMB_MAX_AUTOPLAY_HOVER = 6; +const VIDEO_THUMB_MAX_AUTOPLAY_ALWAYS = 12; const VIDEO_THUMB_MAX_PREPARED = 48; const VIDEO_THUMB_LOAD_TIMEOUT_MS = 5000; const VIDEO_THUMB_FIRST_FRAME_SEEK_S = 1.0; -const isVideoHoverAutoplayEnabled = () => { +/** @returns {"off"|"hover"|"always"} */ +const getVideoAutoplayMode = () => { try { - return !!APP_CONFIG.GRID_VIDEO_HOVER_AUTOPLAY; + const mode = APP_CONFIG.GRID_VIDEO_AUTOPLAY_MODE; + if (mode === "hover" || mode === "always") return mode; + // Backward compat: old boolean setting + if (mode === true || APP_CONFIG.GRID_VIDEO_HOVER_AUTOPLAY === true) return "hover"; } catch {} - return true; + return "off"; }; +const isVideoAutoplayAlways = () => getVideoAutoplayMode() === "always"; +const getMaxAutoplay = () => isVideoAutoplayAlways() ? VIDEO_THUMB_MAX_AUTOPLAY_ALWAYS : VIDEO_THUMB_MAX_AUTOPLAY_HOVER; + export function cleanupVideoThumbsIn(rootEl) { try { const mgr = getVideoThumbManager(); @@ -232,7 +240,7 @@ const getVideoThumbManager = () => { if (!playing.has(video)) { playing.add(video); - while (playing.size > VIDEO_THUMB_MAX_AUTOPLAY) { + while (playing.size > getMaxAutoplay()) { const oldest = playing.values().next().value; // Demote to prepared (paused with frame kept) pauseVideo(oldest); @@ -279,7 +287,9 @@ const getVideoThumbManager = () => { }; const applyAutoplayModeToObserved = () => { - const enabled = isVideoHoverAutoplayEnabled(); + const mode = getVideoAutoplayMode(); + const enabled = mode !== "off"; + const always = mode === "always"; for (const v of Array.from(observed)) { if (!(v instanceof HTMLVideoElement)) continue; const overlay = v._mjrPlayOverlay || null; @@ -289,7 +299,9 @@ const getVideoThumbManager = () => { if (overlay) overlay.style.opacity = "1"; continue; } - if (v._mjrVisible && v._mjrHovered && !document.hidden) { + const shouldPlay = v._mjrVisible && !document.hidden + && (always || v._mjrHovered); + if (shouldPlay) { playVideo(v); if (overlay) overlay.style.opacity = "0"; } else { @@ -350,7 +362,9 @@ const getVideoThumbManager = () => { const video = entry.target; if (!(video instanceof HTMLVideoElement)) continue; const overlay = video._mjrPlayOverlay || null; - const autoplayEnabled = isVideoHoverAutoplayEnabled(); + const mode = getVideoAutoplayMode(); + const autoplayEnabled = mode !== "off"; + const always = mode === "always"; if (!video.isConnected || document.hidden) { try { video._mjrVisible = false; } catch {} @@ -365,14 +379,15 @@ const getVideoThumbManager = () => { const isVisible = entry.isIntersecting && entry.intersectionRatio >= 0.2; try { video._mjrVisible = isVisible; } catch {} - const isHovered = !!video._mjrHovered; - if (autoplayEnabled && isVisible && isHovered) { - // Play only if visible + hovered/focused. + // "always": play when visible; "hover": play when visible + hovered + const shouldPlay = autoplayEnabled && isVisible + && (always || !!video._mjrHovered); + + if (shouldPlay) { playVideo(video); if (overlay) overlay.style.opacity = "0"; } else { - // Keep a prepared frame, but avoid background playback. if (autoplayEnabled) pauseVideo(video); else stopVideo(video); if (overlay) overlay.style.opacity = "1"; @@ -442,11 +457,15 @@ const getVideoThumbManager = () => { }; const onEnter = () => { - if (!isVideoHoverAutoplayEnabled()) { + const mode = getVideoAutoplayMode(); + if (mode === "off") { showOverlay("1"); return; } + // In "always" mode the IO handles play; just track hover state. try { video._mjrHovered = true; } catch {} + if (mode === "always") return; + // "hover" mode: play on enter const canPlay = !!video?._mjrVisible && !document.hidden; if (canPlay) { showOverlay("0"); @@ -457,6 +476,8 @@ const getVideoThumbManager = () => { }; const onLeave = () => { try { video._mjrHovered = false; } catch {} + // In "always" mode the IO keeps it playing; don't pause. + if (isVideoAutoplayAlways()) return; showOverlay("1"); pauseVideo(video); }; @@ -544,8 +565,36 @@ export function createAssetCard(asset) { const thumb = createThumbnail(asset, viewUrl); // --- Badges (Always created, visibility controlled via parent CSS) --- - const fileBadge = createFileBadge(asset.filename, asset.kind, !!asset?._mjrNameCollision); + const fileBadge = createFileBadge( + asset.filename, + asset.kind, + !!asset?._mjrNameCollision, + { + count: asset?._mjrNameCollisionCount || 0, + paths: asset?._mjrNameCollisionPaths || [], + } + ); fileBadge.classList.add("mjr-badge-ext"); // Class helper + try { + fileBadge.addEventListener("click", (event) => { + if (!asset?._mjrNameCollision) return; + event.preventDefault(); + event.stopPropagation(); + const name = String(asset?.filename || "").trim(); + const filenameKey = String(card?.dataset?.mjrFilenameKey || "").trim().toLowerCase(); + card.dispatchEvent?.( + new CustomEvent("mjr:badge-duplicates-focus", { + bubbles: true, + detail: { + filename: name, + filenameKey, + count: Number(asset?._mjrNameCollisionCount || 0), + paths: Array.isArray(asset?._mjrNameCollisionPaths) ? asset._mjrNameCollisionPaths : [], + }, + }) + ); + }); + } catch {} thumb.appendChild(fileBadge); const ratingBadge = createRatingBadge(asset.rating || 0); diff --git a/js/entry.js b/js/entry.js index bf1f0651..4d0d5347 100644 --- a/js/entry.js +++ b/js/entry.js @@ -3,11 +3,11 @@ * Registers extension with ComfyUI and mounts UI. */ -import { app } from "../../scripts/app.js"; import { testAPI, triggerStartupScan } from "./app/bootstrap.js"; import { checkMajoorVersion } from "./app/versionCheck.js"; import { ensureStyleLoaded } from "./app/style.js"; import { registerMajoorSettings, startRuntimeStatusDashboard } from "./app/settings.js"; +import { getComfyApi, registerSidebarTabCompat } from "./app/comfyApiBridge.js"; import { initDragDrop } from "./features/dnd/DragDrop.js"; import { loadAssets, upsertAsset } from "./features/grid/GridView.js"; import { renderAssetsManager, getActiveGridContainer } from "./features/panel/AssetsManagerPanel.js"; @@ -16,6 +16,7 @@ import { post } from "./api/client.js"; import { ENDPOINTS } from "./api/endpoints.js"; import { comfyToast } from "./app/toast.js"; import { t } from "./app/i18n.js"; +import { app } from "../../scripts/app.js"; const UI_FLAGS = { useComfyThemeUI: true, @@ -103,7 +104,7 @@ app.registerExtension({ void checkMajoorVersion(); // Get ComfyUI API - const api = app.api || (app.ui ? app.ui.api : null); + const api = getComfyApi(app); if (api) { // Clean up previous handlers (hot-reload safety) try { @@ -203,10 +204,8 @@ app.registerExtension({ const prev = !!globalThis?._mjrEnrichmentActive; globalThis._mjrEnrichmentActive = active; window.dispatchEvent(new CustomEvent("mjr-enrichment-status", { detail })); - if (prev !== active) { - const grid = getActiveGridContainer(); - if (grid) loadAssets(grid, undefined, { reset: true }); - } + // Do not force a full grid reset on enrichment state flips. + // Panel-level listeners handle refresh with scroll/selection anchor preservation. if (prev && !active) { comfyToast(t("toast.enrichmentComplete", "Metadata enrichment complete"), "success", 2600); } @@ -263,8 +262,7 @@ app.registerExtension({ } // Register sidebar tab - if (app.extensionManager?.registerSidebarTab) { - app.extensionManager.registerSidebarTab({ + if (registerSidebarTabCompat(app, { id: "majoor-assets", icon: "pi pi-folder", title: "Assets Manager", @@ -273,7 +271,7 @@ app.registerExtension({ render: (el) => { void renderAssetsManager(el, { useComfyThemeUI: UI_FLAGS.useComfyThemeUI }); }, - }); + })) { console.log("📂 Majoor Assets Manager: Sidebar tab registered"); } else { diff --git a/js/features/collections/controllers/collectionsController.js b/js/features/collections/controllers/collectionsController.js index 46a868e7..f620e503 100644 --- a/js/features/collections/controllers/collectionsController.js +++ b/js/features/collections/controllers/collectionsController.js @@ -8,10 +8,26 @@ function createMenuItem(label, { right = null, checked = false, danger = false } btn.type = "button"; btn.className = "mjr-menu-item"; if (danger) btn.style.color = "var(--error-text, #f44336)"; + btn.style.cssText = ` + display:flex; + align-items:center; + justify-content:space-between; + width:100%; + gap:10px; + padding:9px 10px; + border-radius:9px; + border:1px solid ${checked ? "rgba(120,190,255,0.68)" : "rgba(120,190,255,0.18)"}; + background:${checked + ? "linear-gradient(135deg, rgba(70,130,255,0.28), rgba(20,95,185,0.22))" + : "linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01))"}; + box-shadow:${checked ? "0 0 0 1px rgba(160,210,255,0.22) inset, 0 8px 18px rgba(35,95,185,0.26)" : "none"}; + transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease; + `; const left = document.createElement("span"); left.className = "mjr-menu-item-label"; left.textContent = label; + left.style.cssText = `font-weight:${checked ? "700" : "600"}; color:${checked ? "rgba(208,234,255,0.98)" : "var(--fg-color, #e6edf7)"};`; const rightWrap = document.createElement("span"); rightWrap.style.cssText = "display:flex; align-items:center; gap:10px;"; @@ -20,24 +36,34 @@ function createMenuItem(label, { right = null, checked = false, danger = false } const hint = document.createElement("span"); hint.className = "mjr-menu-item-hint"; hint.textContent = String(right); - hint.style.opacity = "0.65"; + hint.style.opacity = checked ? "0.9" : "0.65"; hint.style.fontSize = "11px"; rightWrap.appendChild(hint); } const check = document.createElement("i"); check.className = "pi pi-check mjr-menu-item-check"; - check.style.opacity = checked ? "1" : "0"; + check.style.cssText = `opacity:${checked ? "1" : "0"}; color: rgba(133,203,255,0.98);`; rightWrap.appendChild(check); btn.appendChild(left); btn.appendChild(rightWrap); + btn.addEventListener("mouseenter", () => { + if (checked) return; + btn.style.borderColor = "rgba(145,205,255,0.4)"; + btn.style.background = "linear-gradient(135deg, rgba(80,140,255,0.18), rgba(32,100,200,0.14))"; + }); + btn.addEventListener("mouseleave", () => { + if (checked) return; + btn.style.borderColor = "rgba(120,190,255,0.18)"; + btn.style.background = "linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01))"; + }); return btn; } function createDivider() { const d = document.createElement("div"); - d.style.cssText = "height:1px; background: var(--border-color); margin: 6px 0;"; + d.style.cssText = "height:1px; background: rgba(120,190,255,0.3); margin: 4px 0;"; return d; } @@ -118,12 +144,20 @@ export function createCollectionsController({ state, collectionsBtn, collections delBtn.className = "mjr-menu-item"; delBtn.title = "Delete collection"; delBtn.style.cssText = - "width:42px; justify-content:center; padding:0; border-left: 1px solid var(--border-color);"; + "width:42px; justify-content:center; padding:0; border:1px solid rgba(255,95,95,0.35); border-radius:9px; margin-left:6px; background: linear-gradient(135deg, rgba(255,70,70,0.16), rgba(160,20,20,0.12));"; const trash = document.createElement("i"); trash.className = "pi pi-trash"; - trash.style.opacity = "0.75"; + trash.style.cssText = "opacity:0.88; color: rgba(255,130,130,0.96);"; delBtn.appendChild(trash); + delBtn.addEventListener("mouseenter", () => { + delBtn.style.borderColor = "rgba(255,120,120,0.62)"; + delBtn.style.background = "linear-gradient(135deg, rgba(255,70,70,0.24), rgba(170,30,30,0.2))"; + }); + delBtn.addEventListener("mouseleave", () => { + delBtn.style.borderColor = "rgba(255,95,95,0.35)"; + delBtn.style.background = "linear-gradient(135deg, rgba(255,70,70,0.16), rgba(160,20,20,0.12))"; + }); delBtn.addEventListener("click", async (e) => { e.preventDefault(); diff --git a/js/features/collections/views/collectionsPopoverView.js b/js/features/collections/views/collectionsPopoverView.js index fa923c81..88869cc0 100644 --- a/js/features/collections/views/collectionsPopoverView.js +++ b/js/features/collections/views/collectionsPopoverView.js @@ -2,9 +2,19 @@ export function createCollectionsPopoverView() { const collectionsPopover = document.createElement("div"); collectionsPopover.className = "mjr-popover mjr-collections-popover"; collectionsPopover.style.display = "none"; + collectionsPopover.style.cssText = ` + display:none; + border: 1px solid rgba(120,180,255,0.35); + background: linear-gradient(180deg, rgba(16,24,36,0.96) 0%, rgba(11,16,24,0.96) 100%); + box-shadow: 0 14px 34px rgba(0,0,0,0.42), 0 0 0 1px rgba(130,185,255,0.16) inset; + border-radius: 12px; + padding: 10px; + min-width: 280px; + `; const collectionsMenu = document.createElement("div"); collectionsMenu.className = "mjr-menu"; + collectionsMenu.style.cssText = "display:grid; gap:6px;"; collectionsPopover.appendChild(collectionsMenu); return { collectionsPopover, collectionsMenu }; diff --git a/js/features/dnd/DragDrop.js b/js/features/dnd/DragDrop.js index 8437c891..d3ba743c 100644 --- a/js/features/dnd/DragDrop.js +++ b/js/features/dnd/DragDrop.js @@ -2,7 +2,7 @@ * Drag & Drop support for staging assets to input (video-focused). */ -import { app } from "../../../../scripts/app.js"; +import { getComfyApp } from "../../app/comfyApiBridge.js"; import { get, post } from "../../api/client.js"; import { ENDPOINTS, buildCustomViewURL, buildViewURL } from "../../api/endpoints.js"; import { comfyToast } from "../../app/toast.js"; @@ -23,6 +23,8 @@ import { } from "./targets/node.js"; import { stageToInput, stageToInputDetailed } from "./staging/stageToInput.js"; +const app = getComfyApp(); + const buildURL = (payload) => buildPayloadViewURL(payload, { buildCustomViewURL, buildViewURL }); diff --git a/js/features/grid/GridView.js b/js/features/grid/GridView.js index 56f09a27..a0757652 100644 --- a/js/features/grid/GridView.js +++ b/js/features/grid/GridView.js @@ -406,6 +406,7 @@ function _updateGridSettingsClasses(container) { container.style.setProperty("--mjr-badge-video", APP_CONFIG.BADGE_VIDEO_COLOR); container.style.setProperty("--mjr-badge-audio", APP_CONFIG.BADGE_AUDIO_COLOR); container.style.setProperty("--mjr-badge-model3d", APP_CONFIG.BADGE_MODEL3D_COLOR); + container.style.setProperty("--mjr-badge-duplicate-alert", APP_CONFIG.BADGE_DUPLICATE_ALERT_COLOR); container.style.setProperty("--mjr-card-hover-color", APP_CONFIG.UI_CARD_HOVER_COLOR); container.style.setProperty("--mjr-card-selection-color", APP_CONFIG.UI_CARD_SELECTION_COLOR); container.style.setProperty("--mjr-rating-color", APP_CONFIG.UI_RATING_COLOR); @@ -584,6 +585,7 @@ export function createGridContainer() { const OVERLAY_CLASS = "mjr-grid-loading-overlay"; const SENTINEL_CLASS = "mjr-grid-sentinel"; +const LOAD_ASSETS_TIMEOUT_MS = 25000; function showLoadingOverlay(gridContainer, message = "Loading assets...") { let overlay = gridContainer.querySelector(`.${OVERLAY_CLASS}`); @@ -887,7 +889,11 @@ function ensureVirtualGrid(gridContainer, state) { // Handle collision badge if already known if (asset?._mjrNameCollision) { const badge = card.querySelector?.(".mjr-file-badge"); - setFileBadgeCollision(badge, true); + setFileBadgeCollision(badge, true, { + filename: asset?.filename || "", + count: asset?._mjrNameCollisionCount || 2, + paths: asset?._mjrNameCollisionPaths || [], + }); _setCollisionTooltip(card, asset?.filename, asset?._mjrNameCollisionCount || 2); } return card; @@ -1140,12 +1146,12 @@ function _setCollisionTooltip(card, filename, count) { try { const row = card.querySelector?.(".mjr-card-filename"); if (row) { - row.title = `${String(filename || "")}\nDuplicate filename: ${n} items in this view`; + row.title = `${String(filename || "")}\nName collision: ${n} items in this view`; return; } } catch {} try { - card.title = `Duplicate filename: ${n} items in this view`; + card.title = `Name collision: ${n} items in this view`; } catch {} } @@ -1198,6 +1204,7 @@ function appendAssets(gridContainer, assets, state) { for (const asset of bucket) { asset._mjrNameCollision = false; delete asset._mjrNameCollisionCount; + delete asset._mjrNameCollisionPaths; } const renderedList = state.renderedFilenameMap.get(key); if (renderedList) { @@ -1208,16 +1215,22 @@ function appendAssets(gridContainer, assets, state) { } continue; } + const paths = _buildCollisionPaths(bucket); for (const asset of bucket) { asset._mjrNameCollision = true; asset._mjrNameCollisionCount = count; + asset._mjrNameCollisionPaths = paths; } const renderedList = state.renderedFilenameMap.get(key); if (renderedList) { for (const card of renderedList) { const badge = card.querySelector?.(".mjr-file-badge"); - setFileBadgeCollision(badge, true); const asset = card._mjrAsset; + setFileBadgeCollision(badge, true, { + filename: asset?.filename || "", + count, + paths: asset?._mjrNameCollisionPaths || paths, + }); _setCollisionTooltip(card, asset?.filename, count); } } @@ -1431,6 +1444,11 @@ async function fetchPage(gridContainer, query, limit, offset, { requestId = 0, s const kind = gridContainer?.dataset?.mjrFilterKind || ""; const workflowOnly = gridContainer?.dataset?.mjrFilterWorkflowOnly === "1"; const minRating = Number(gridContainer?.dataset?.mjrFilterMinRating || 0) || 0; + const minSizeMB = Number(gridContainer?.dataset?.mjrFilterMinSizeMB || 0) || 0; + const maxSizeMB = Number(gridContainer?.dataset?.mjrFilterMaxSizeMB || 0) || 0; + const minWidth = Number(gridContainer?.dataset?.mjrFilterMinWidth || 0) || 0; + const minHeight = Number(gridContainer?.dataset?.mjrFilterMinHeight || 0) || 0; + const workflowType = String(gridContainer?.dataset?.mjrFilterWorkflowType || "").trim().toUpperCase(); const dateRange = String(gridContainer?.dataset?.mjrFilterDateRange || "").trim().toLowerCase(); const dateExact = String(gridContainer?.dataset?.mjrFilterDateExact || "").trim(); const sortKey = gridContainer?.dataset?.mjrSort || "mtime_desc"; @@ -1450,6 +1468,11 @@ async function fetchPage(gridContainer, query, limit, offset, { requestId = 0, s kind: kind || null, hasWorkflow: workflowOnly ? true : null, minRating: minRating > 0 ? minRating : null, + minSizeMB: minSizeMB > 0 ? minSizeMB : null, + maxSizeMB: maxSizeMB > 0 ? maxSizeMB : null, + minWidth: minWidth > 0 ? minWidth : null, + minHeight: minHeight > 0 ? minHeight : null, + workflowType: workflowType || null, dateRange: dateRange || null, dateExact: dateExact || null, sort: sortKey, @@ -1783,7 +1806,23 @@ export async function loadAssets(gridContainer, query = "*", options = {}) { } try { - await loadNextPage(gridContainer, state); + if (reset) { + let timeoutId = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const err = new Error(`Load assets timeout after ${LOAD_ASSETS_TIMEOUT_MS}ms`); + err.name = "LoadTimeoutError"; + reject(err); + }, LOAD_ASSETS_TIMEOUT_MS); + }); + try { + await Promise.race([loadNextPage(gridContainer, state), timeoutPromise]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + } else { + await loadNextPage(gridContainer, state); + } if (reset) { hideLoadingOverlay(gridContainer); @@ -1799,6 +1838,25 @@ export async function loadAssets(gridContainer, query = "*", options = {}) { } return { ok: true, count: state.offset, total: state.total || 0 }; } catch (err) { + try { + if (String(err?.name || "") === "LoadTimeoutError") { + // Force-unblock state so future reloads are not stuck behind a stale in-flight request. + try { + state.abortController?.abort?.(); + } catch {} + try { + state.abortController = typeof AbortController !== "undefined" ? new AbortController() : null; + } catch { + state.abortController = null; + } + try { + state.requestId = (Number(state.requestId) || 0) + 1; + } catch { + state.requestId = 1; + } + state.loading = false; + } + } catch {} try { if (String(err?.name || "") === "AbortError") { return { ok: false, aborted: true, error: "Aborted" }; @@ -2369,6 +2427,24 @@ function getFirstVisibleAssetElement(gridContainer) { return cards[0] || null; } +function _buildCollisionPaths(bucket) { + const out = []; + const seen = new Set(); + for (const asset of bucket || []) { + const raw = + asset?.filepath + || asset?.path + || (asset?.subfolder ? `${asset.subfolder}/${asset.filename || ""}` : `${asset?.filename || ""}`); + const p = String(raw || "").trim(); + if (!p) continue; + const key = p.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(p); + } + return out; +} + /** * Restore scroll position based on anchor */ diff --git a/js/features/panel/AssetsManagerPanel.js b/js/features/panel/AssetsManagerPanel.js index bd505d8b..1273241f 100644 --- a/js/features/panel/AssetsManagerPanel.js +++ b/js/features/panel/AssetsManagerPanel.js @@ -9,6 +9,8 @@ import { prepareGridForScopeSwitch, disposeGrid, refreshGrid, + captureAnchor, + restoreAnchor, bindGridScanListeners, disposeGridScanListeners, } from "../grid/GridView.js"; @@ -116,7 +118,21 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } const { header, headerActions, tabButtons, customMenuBtn, filterBtn, sortBtn, collectionsBtn } = headerView; const { customPopover, customSelect, customAddBtn, customRemoveBtn } = createCustomPopoverView(); - const { filterPopover, kindSelect, wfCheckbox, ratingSelect, dateRangeSelect, dateExactInput, agendaContainer } = createFilterPopoverView(); + const { + filterPopover, + kindSelect, + wfCheckbox, + workflowTypeSelect, + ratingSelect, + minSizeInput, + maxSizeInput, + resolutionPresetSelect, + minWidthInput, + minHeightInput, + dateRangeSelect, + dateExactInput, + agendaContainer + } = createFilterPopoverView(); const { sortPopover, sortMenu } = createSortPopoverView(); const { collectionsPopover, collectionsMenu } = createCollectionsPopoverView(); @@ -173,7 +189,16 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } // Restoring scroll on an empty grid would be useless. let _scrollTimer = null; + let _lastUserInteractionAt = 0; + const markUserInteraction = () => { + try { + _lastUserInteractionAt = Date.now(); + } catch { + _lastUserInteractionAt = 0; + } + }; gridWrapper.addEventListener("scroll", () => { + markUserInteraction(); if (_scrollTimer) clearTimeout(_scrollTimer); _scrollTimer = setTimeout(() => { try { @@ -439,10 +464,38 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } } catch {} }; gridContainer.addEventListener("mjr:reload-grid", _reloadGridHandler); + const onDuplicateBadgeFocus = (e) => { + try { + const detail = e?.detail || {}; + const filenameKey = String(detail?.filenameKey || "").trim().toLowerCase(); + if (!filenameKey || !gridContainer) return; + const cards = Array.from(gridContainer.querySelectorAll(".mjr-asset-card")) + .filter((card) => String(card?.dataset?.mjrFilenameKey || "").trim().toLowerCase() === filenameKey); + if (!cards.length) return; + const ids = cards + .map((card) => String(card?.dataset?.mjrAssetId || "").trim()) + .filter(Boolean); + const activeId = String(cards[0]?.dataset?.mjrAssetId || ids[0] || "").trim(); + if (ids.length && typeof gridContainer?._mjrSetSelection === "function") { + gridContainer._mjrSetSelection(ids, activeId); + } + cards[0]?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); + markUserInteraction(); + notifyContextChanged(); + const n = Number(detail?.count || ids.length || cards.length || 0); + try { + comfyToast(`Name collision in view: ${n} item(s) selected`, "info", 1800); + } catch {} + } catch {} + }; + gridContainer.addEventListener("mjr:badge-duplicates-focus", onDuplicateBadgeFocus); registerSummaryDispose(() => { try { if (_reloadGridHandler) gridContainer.removeEventListener("mjr:reload-grid", _reloadGridHandler); } catch {} + try { + gridContainer.removeEventListener("mjr:badge-duplicates-focus", onDuplicateBadgeFocus); + } catch {} _reloadGridHandler = null; }); } catch {} @@ -585,6 +638,24 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } // Calendar UI (separate module) for day indicators. let agendaCalendar = null; try { + try { + kindSelect.value = state.kindFilter || ""; + wfCheckbox.checked = !!state.workflowOnly; + workflowTypeSelect.value = String(state.workflowType || "").trim().toUpperCase(); + ratingSelect.value = String(Number(state.minRating || 0) || 0); + minSizeInput.value = Number(state.minSizeMB || 0) > 0 ? String(state.minSizeMB) : ""; + maxSizeInput.value = Number(state.maxSizeMB || 0) > 0 ? String(state.maxSizeMB) : ""; + minWidthInput.value = Number(state.minWidth || 0) > 0 ? String(state.minWidth) : ""; + minHeightInput.value = Number(state.minHeight || 0) > 0 ? String(state.minHeight) : ""; + const w = Number(state.minWidth || 0) || 0; + const h = Number(state.minHeight || 0) || 0; + if (w >= 3840 && h >= 2160) resolutionPresetSelect.value = "uhd"; + else if (w >= 2560 && h >= 1440) resolutionPresetSelect.value = "qhd"; + else if (w >= 1920 && h >= 1080) resolutionPresetSelect.value = "fhd"; + else if (w >= 1280 && h >= 720) resolutionPresetSelect.value = "hd"; + else resolutionPresetSelect.value = ""; + dateRangeSelect.value = state.dateRangeFilter || ""; + } catch {} agendaCalendar = createAgendaCalendar({ container: agendaContainer, hiddenInput: dateExactInput, @@ -597,7 +668,13 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } state, kindSelect, wfCheckbox, + workflowTypeSelect, ratingSelect, + minSizeInput, + maxSizeInput, + resolutionPresetSelect, + minWidthInput, + minHeightInput, dateRangeSelect, dateExactInput, reloadGrid: gridController.reloadGrid, @@ -620,7 +697,13 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } searchInputEl, kindSelect, wfCheckbox, + workflowTypeSelect, ratingSelect, + minSizeInput, + maxSizeInput, + minWidthInput, + minHeightInput, + resolutionPresetSelect, dateRangeSelect, dateExactInput, scopeController, @@ -672,6 +755,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } notifyContextChanged(); const onStats = () => notifyContextChanged(); const onSelectionChanged = (e) => { + markUserInteraction(); try { const detail = e?.detail || {}; const ids = Array.isArray(detail.selectedIds) ? detail.selectedIds.map(String).filter(Boolean) : []; @@ -856,6 +940,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } let hasSeenFirstCounters = false; let pendingReloadCount = 0; let isReloading = false; + let _lastAutoReloadAt = 0; const queuedReload = async () => { if (!gridContainer) return; @@ -866,7 +951,11 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } try { while (pendingReloadCount > 0) { pendingReloadCount = 0; + const anchor = captureAnchor(gridContainer); await gridController.reloadGrid(); + try { + await restoreAnchor(gridContainer, anchor); + } catch {} } } finally { isReloading = false; @@ -893,16 +982,46 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } !state.kindFilter && !state.workflowOnly && !(Number(state.minRating || 0) > 0) && + !(Number(state.minSizeMB || 0) > 0) && + !(Number(state.maxSizeMB || 0) > 0) && + !(Number(state.minWidth || 0) > 0) && + !(Number(state.minHeight || 0) > 0) && + !String(state.workflowType || "").trim() && !state.dateRangeFilter && !state.dateExactFilter; - const hasNewTotal = Number.isFinite(totalAssets) && Number.isFinite(lastKnownTotalAssets) && totalAssets > lastKnownTotalAssets; + const totalDelta = + Number.isFinite(totalAssets) && Number.isFinite(lastKnownTotalAssets) + ? totalAssets - lastKnownTotalAssets + : 0; + const hasNewTotal = totalDelta >= 3; + + // Keep baseline current even if we skip auto-reload; avoids repeated trigger loops. + if (Number.isFinite(totalAssets)) { + lastKnownTotalAssets = totalAssets; + } if (!hasNewScan && !(isDefaultBrowse && hasNewTotal)) return; + // Global throttle against reload storms from frequent watcher/enrichment updates. + try { + const now = Date.now(); + if (now - Number(_lastAutoReloadAt || 0) < 8000) return; + } catch {} + + // Avoid disruptive auto-reload while user is actively interacting. + try { + const recentInteraction = (Date.now() - Number(_lastUserInteractionAt || 0)) < 1500; + if (recentInteraction) return; + } catch {} + lastKnownScan = counters.last_scan_end; - lastKnownTotalAssets = Number.isFinite(totalAssets) ? totalAssets : lastKnownTotalAssets; await queuedReload(); + try { + _lastAutoReloadAt = Date.now(); + } catch { + _lastAutoReloadAt = 0; + } }; setupStatusPolling( diff --git a/js/features/panel/controllers/contextController.js b/js/features/panel/controllers/contextController.js index a86446f8..211c7737 100644 --- a/js/features/panel/controllers/contextController.js +++ b/js/features/panel/controllers/contextController.js @@ -24,7 +24,13 @@ export function createContextController({ searchInputEl, kindSelect, wfCheckbox, + workflowTypeSelect, ratingSelect, + minSizeInput, + maxSizeInput, + minWidthInput, + minHeightInput, + resolutionPresetSelect, dateRangeSelect, dateExactInput, scopeController, @@ -46,6 +52,7 @@ export function createContextController({ root.style.setProperty("--mjr-badge-video", String(APP_CONFIG.BADGE_VIDEO_COLOR || "#9C27B0")); root.style.setProperty("--mjr-badge-audio", String(APP_CONFIG.BADGE_AUDIO_COLOR || "#FF9800")); root.style.setProperty("--mjr-badge-model3d", String(APP_CONFIG.BADGE_MODEL3D_COLOR || "#4CAF50")); + root.style.setProperty("--mjr-badge-duplicate-alert", String(APP_CONFIG.BADGE_DUPLICATE_ALERT_COLOR || "#FF1744")); } catch {} }; @@ -141,6 +148,38 @@ export function createContextController({ await reloadGrid?.(); } catch {} }, + clearWorkflowType: async () => { + try { + state.workflowType = ""; + _safeSetValue(workflowTypeSelect, ""); + } catch {} + try { + await reloadGrid?.(); + } catch {} + }, + clearSize: async () => { + try { + state.minSizeMB = 0; + state.maxSizeMB = 0; + _safeSetValue(minSizeInput, ""); + _safeSetValue(maxSizeInput, ""); + } catch {} + try { + await reloadGrid?.(); + } catch {} + }, + clearResolution: async () => { + try { + state.minWidth = 0; + state.minHeight = 0; + _safeSetValue(minWidthInput, ""); + _safeSetValue(minHeightInput, ""); + _safeSetValue(resolutionPresetSelect, ""); + } catch {} + try { + await reloadGrid?.(); + } catch {} + }, clearDateRange: async () => { try { state.dateRangeFilter = ""; @@ -180,6 +219,11 @@ export function createContextController({ state.kindFilter = ""; state.workflowOnly = false; state.minRating = 0; + state.minSizeMB = 0; + state.maxSizeMB = 0; + state.minWidth = 0; + state.minHeight = 0; + state.workflowType = ""; state.dateRangeFilter = ""; state.dateExactFilter = ""; state.sort = "mtime_desc"; @@ -197,7 +241,13 @@ export function createContextController({ try { _safeSetValue(kindSelect, ""); _safeSetChecked(wfCheckbox, false); + _safeSetValue(workflowTypeSelect, ""); _safeSetValue(ratingSelect, "0"); + _safeSetValue(minSizeInput, ""); + _safeSetValue(maxSizeInput, ""); + _safeSetValue(minWidthInput, ""); + _safeSetValue(minHeightInput, ""); + _safeSetValue(resolutionPresetSelect, ""); _safeSetValue(dateRangeSelect, ""); _safeSetValue(dateExactInput, ""); } catch {} @@ -224,6 +274,11 @@ export function createContextController({ (state?.kindFilter || "") || state?.workflowOnly || (Number(state?.minRating || 0) || 0) > 0 + || (Number(state?.minSizeMB || 0) || 0) > 0 + || (Number(state?.maxSizeMB || 0) || 0) > 0 + || (Number(state?.minWidth || 0) || 0) > 0 + || (Number(state?.minHeight || 0) || 0) > 0 + || String(state?.workflowType || "").trim().length > 0 || (state?.dateRangeFilter || "") || (state?.dateExactFilter || "") ); diff --git a/js/features/panel/controllers/filtersController.js b/js/features/panel/controllers/filtersController.js index 9ea11a43..96433e62 100644 --- a/js/features/panel/controllers/filtersController.js +++ b/js/features/panel/controllers/filtersController.js @@ -4,7 +4,13 @@ export function bindFilters({ state, kindSelect, wfCheckbox, + workflowTypeSelect, ratingSelect, + minSizeInput, + maxSizeInput, + resolutionPresetSelect, + minWidthInput, + minHeightInput, dateRangeSelect, dateExactInput, reloadGrid, @@ -33,6 +39,61 @@ export function bindFilters({ try { onFiltersChanged?.(); } catch {} await reloadGrid(); }); + if (workflowTypeSelect) { + workflowTypeSelect.addEventListener("change", async () => { + state.workflowType = String(workflowTypeSelect.value || "").trim().toUpperCase(); + try { onFiltersChanged?.(); } catch {} + await reloadGrid(); + }); + } + + const applySizeFilter = async () => { + const minVal = Number(minSizeInput?.value || 0); + const maxVal = Number(maxSizeInput?.value || 0); + state.minSizeMB = Number.isFinite(minVal) && minVal > 0 ? minVal : 0; + state.maxSizeMB = Number.isFinite(maxVal) && maxVal > 0 ? maxVal : 0; + if (state.maxSizeMB > 0 && state.minSizeMB > 0 && state.maxSizeMB < state.minSizeMB) { + state.maxSizeMB = state.minSizeMB; + if (maxSizeInput) maxSizeInput.value = String(state.maxSizeMB); + } + try { onFiltersChanged?.(); } catch {} + await reloadGrid(); + }; + minSizeInput?.addEventListener("change", applySizeFilter); + maxSizeInput?.addEventListener("change", applySizeFilter); + + const applyResolutionFilter = async () => { + const minW = Number(minWidthInput?.value || 0); + const minH = Number(minHeightInput?.value || 0); + state.minWidth = Number.isFinite(minW) && minW > 0 ? Math.round(minW) : 0; + state.minHeight = Number.isFinite(minH) && minH > 0 ? Math.round(minH) : 0; + try { + if (resolutionPresetSelect) resolutionPresetSelect.value = ""; + } catch {} + try { onFiltersChanged?.(); } catch {} + await reloadGrid(); + }; + minWidthInput?.addEventListener("change", applyResolutionFilter); + minHeightInput?.addEventListener("change", applyResolutionFilter); + + if (resolutionPresetSelect) { + resolutionPresetSelect.addEventListener("change", async () => { + const preset = String(resolutionPresetSelect.value || ""); + const map = { + hd: [1280, 720], + fhd: [1920, 1080], + qhd: [2560, 1440], + uhd: [3840, 2160], + }; + const pair = map[preset] || [0, 0]; + state.minWidth = Number(pair[0] || 0); + state.minHeight = Number(pair[1] || 0); + if (minWidthInput) minWidthInput.value = state.minWidth > 0 ? String(state.minWidth) : ""; + if (minHeightInput) minHeightInput.value = state.minHeight > 0 ? String(state.minHeight) : ""; + try { onFiltersChanged?.(); } catch {} + await reloadGrid(); + }); + } if (dateRangeSelect) { dateRangeSelect.addEventListener("change", async () => { state.dateRangeFilter = dateRangeSelect.value || ""; diff --git a/js/features/panel/controllers/gridController.js b/js/features/panel/controllers/gridController.js index 82d46dc6..192c0143 100644 --- a/js/features/panel/controllers/gridController.js +++ b/js/features/panel/controllers/gridController.js @@ -3,6 +3,23 @@ import { t } from "../../../app/i18n.js"; export function createGridController({ gridContainer, loadAssets, loadAssetsFromList, getCollectionAssets, disposeGrid, getQuery, state }) { let _isReloading = false; let _pendingReload = false; + const RELOAD_WATCHDOG_MS = 30000; + + const runWithWatchdog = async (promiseFactory, timeoutMs = RELOAD_WATCHDOG_MS) => { + let timer = null; + try { + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + const err = new Error(`Grid reload watchdog timeout (${timeoutMs}ms)`); + err.name = "GridReloadTimeout"; + reject(err); + }, timeoutMs); + }); + return await Promise.race([promiseFactory(), timeoutPromise]); + } finally { + if (timer) clearTimeout(timer); + } + }; const runReloadOnce = async () => { // Expose the current query on the container so external listeners (ComfyUI executed events) @@ -16,6 +33,11 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom gridContainer.dataset.mjrFilterKind = state.kindFilter || ""; gridContainer.dataset.mjrFilterWorkflowOnly = state.workflowOnly ? "1" : "0"; gridContainer.dataset.mjrFilterMinRating = String(state.minRating || 0); + gridContainer.dataset.mjrFilterMinSizeMB = String(state.minSizeMB || 0); + gridContainer.dataset.mjrFilterMaxSizeMB = String(state.maxSizeMB || 0); + gridContainer.dataset.mjrFilterMinWidth = String(state.minWidth || 0); + gridContainer.dataset.mjrFilterMinHeight = String(state.minHeight || 0); + gridContainer.dataset.mjrFilterWorkflowType = String(state.workflowType || ""); gridContainer.dataset.mjrFilterDateRange = state.dateRangeFilter || ""; gridContainer.dataset.mjrFilterDateExact = state.dateExactFilter || ""; gridContainer.dataset.mjrSort = state.sort || "mtime_desc"; @@ -82,7 +104,11 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom try { while (_pendingReload) { _pendingReload = false; - await runReloadOnce(); + try { + await runWithWatchdog(() => runReloadOnce()); + } catch { + // Keep UI responsive even if one reload got stuck. + } } } finally { _isReloading = false; diff --git a/js/features/panel/controllers/sortController.js b/js/features/panel/controllers/sortController.js index 851895d7..17c09cb0 100644 --- a/js/features/panel/controllers/sortController.js +++ b/js/features/panel/controllers/sortController.js @@ -21,20 +21,47 @@ export function createSortController({ state, sortBtn, sortMenu, sortPopover, po sortMenu.replaceChildren(); const sortOptions = getSortOptions(); for (const opt of sortOptions) { + const isActive = opt.key === state.sort; const btn = document.createElement("button"); btn.type = "button"; btn.className = "mjr-menu-item"; + btn.style.cssText = ` + display:flex; + align-items:center; + justify-content:space-between; + width:100%; + gap:10px; + padding:9px 10px; + border-radius:9px; + border:1px solid ${isActive ? "rgba(120,190,255,0.68)" : "rgba(120,190,255,0.18)"}; + background:${isActive + ? "linear-gradient(135deg, rgba(70,130,255,0.28), rgba(20,95,185,0.22))" + : "linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01))"}; + box-shadow:${isActive ? "0 0 0 1px rgba(160,210,255,0.22) inset, 0 8px 18px rgba(35,95,185,0.26)" : "none"}; + transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease; + `; const label = document.createElement("span"); label.className = "mjr-menu-item-label"; label.textContent = opt.label; + label.style.cssText = `font-weight:${isActive ? "700" : "600"}; color:${isActive ? "rgba(208,234,255,0.98)" : "var(--fg-color, #e6edf7)"};`; const check = document.createElement("i"); check.className = "pi pi-check mjr-menu-item-check"; - check.style.opacity = opt.key === state.sort ? "1" : "0"; + check.style.cssText = `opacity:${isActive ? "1" : "0"}; color: rgba(133,203,255,0.98);`; btn.appendChild(label); btn.appendChild(check); + btn.addEventListener("mouseenter", () => { + if (isActive) return; + btn.style.borderColor = "rgba(145,205,255,0.4)"; + btn.style.background = "linear-gradient(135deg, rgba(80,140,255,0.18), rgba(32,100,200,0.14))"; + }); + btn.addEventListener("mouseleave", () => { + if (isActive) return; + btn.style.borderColor = "rgba(120,190,255,0.18)"; + btn.style.background = "linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01))"; + }); btn.addEventListener("click", async () => { state.sort = opt.key; diff --git a/js/features/panel/state/panelState.js b/js/features/panel/state/panelState.js index 3c7c54e4..b3b05692 100644 --- a/js/features/panel/state/panelState.js +++ b/js/features/panel/state/panelState.js @@ -39,6 +39,11 @@ export function createPanelState() { dateExactFilter: saved.dateExactFilter || "", workflowOnly: saved.workflowOnly || false, minRating: saved.minRating || 0, + minSizeMB: Number(saved.minSizeMB || 0) || 0, + maxSizeMB: Number(saved.maxSizeMB || 0) || 0, + minWidth: Number(saved.minWidth || 0) || 0, + minHeight: Number(saved.minHeight || 0) || 0, + workflowType: saved.workflowType || "", sort: saved.sort || "mtime_desc", // Search query and scroll position persistence searchQuery: saved.searchQuery || "", diff --git a/js/features/panel/views/contextPillsView.js b/js/features/panel/views/contextPillsView.js index 32cf2063..81ac57ec 100644 --- a/js/features/panel/views/contextPillsView.js +++ b/js/features/panel/views/contextPillsView.js @@ -60,6 +60,11 @@ export function createContextPillsView() { _safeText(state?.kindFilter || "").trim() || !!state?.workflowOnly || (Number(state?.minRating || 0) || 0) > 0 + || (Number(state?.minSizeMB || 0) || 0) > 0 + || (Number(state?.maxSizeMB || 0) || 0) > 0 + || (Number(state?.minWidth || 0) || 0) > 0 + || (Number(state?.minHeight || 0) || 0) > 0 + || _safeText(state?.workflowType || "").trim() || _safeText(state?.dateRangeFilter || "").trim() || _safeText(state?.dateExactFilter || "").trim() ); @@ -148,6 +153,38 @@ export function createContextPillsView() { }) ); } + if (_safeText(state?.workflowType || "").trim()) { + root.appendChild( + _createPill({ + label: "WF Type", + value: _safeText(state?.workflowType || "").trim(), + onClear: () => safeActions?.clearWorkflowType?.() + }) + ); + } + if ((Number(state?.minSizeMB || 0) || 0) > 0 || (Number(state?.maxSizeMB || 0) || 0) > 0) { + const min = Number(state?.minSizeMB || 0) || 0; + const max = Number(state?.maxSizeMB || 0) || 0; + const label = min > 0 && max > 0 ? `${min}-${max} MB` : min > 0 ? `>= ${min} MB` : `<= ${max} MB`; + root.appendChild( + _createPill({ + label: "Size", + value: label, + onClear: () => safeActions?.clearSize?.() + }) + ); + } + if ((Number(state?.minWidth || 0) || 0) > 0 || (Number(state?.minHeight || 0) || 0) > 0) { + const w = Number(state?.minWidth || 0) || 0; + const h = Number(state?.minHeight || 0) || 0; + root.appendChild( + _createPill({ + label: "Resolution", + value: `>= ${w || 0}x${h || 0}`, + onClear: () => safeActions?.clearResolution?.() + }) + ); + } if (_safeText(state?.dateRangeFilter || "").trim()) { root.appendChild( _createPill({ diff --git a/js/features/panel/views/filterPopoverView.js b/js/features/panel/views/filterPopoverView.js index d38bc0f4..f865336f 100644 --- a/js/features/panel/views/filterPopoverView.js +++ b/js/features/panel/views/filterPopoverView.js @@ -4,6 +4,39 @@ export function createFilterPopoverView() { const filterPopover = document.createElement("div"); filterPopover.className = "mjr-popover mjr-filter-popover"; filterPopover.style.display = "none"; + filterPopover.style.cssText = ` + display:none; + border: 1px solid rgba(120,180,255,0.35); + background: linear-gradient(180deg, rgba(16,24,36,0.96) 0%, rgba(11,16,24,0.96) 100%); + box-shadow: 0 14px 34px rgba(0,0,0,0.42), 0 0 0 1px rgba(130,185,255,0.16) inset; + border-radius: 12px; + padding: 12px; + min-width: 340px; + `; + + const makeGroup = (title) => { + const g = document.createElement("div"); + g.className = "mjr-filter-group"; + g.style.cssText = ` + border: 1px solid rgba(120,180,255,0.24); + background: linear-gradient(180deg, rgba(22,32,48,0.55), rgba(15,22,34,0.55)); + border-radius: 10px; + padding: 10px; + margin-bottom: 10px; + `; + const h = document.createElement("div"); + h.textContent = title; + h.style.cssText = ` + font-size: 11px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + color: rgba(167,209,255,0.95); + margin-bottom: 8px; + `; + g.appendChild(h); + return g; + }; const kindRow = document.createElement("div"); kindRow.className = "mjr-popover-row"; @@ -27,6 +60,7 @@ export function createFilterPopoverView() { }); kindRow.appendChild(kindLabel); kindRow.appendChild(kindSelect); + kindRow.style.cssText = "display:grid; grid-template-columns: 110px 1fr; gap:8px; align-items:center; margin-bottom:8px;"; const wfRow = document.createElement("div"); wfRow.className = "mjr-popover-row"; @@ -44,6 +78,7 @@ export function createFilterPopoverView() { wfToggle.appendChild(wfText); wfRow.appendChild(wfLabel); wfRow.appendChild(wfToggle); + wfRow.style.cssText = "display:grid; grid-template-columns: 110px 1fr; gap:8px; align-items:center; margin-bottom:8px;"; const ratingRow = document.createElement("div"); ratingRow.className = "mjr-popover-row"; @@ -68,6 +103,36 @@ export function createFilterPopoverView() { }); ratingRow.appendChild(ratingLabel); ratingRow.appendChild(ratingSelect); + ratingRow.style.cssText = "display:grid; grid-template-columns: 110px 1fr; gap:8px; align-items:center; margin-bottom:8px;"; + + const workflowTypeRow = document.createElement("div"); + workflowTypeRow.className = "mjr-popover-row"; + workflowTypeRow.style.cssText = "display:grid; grid-template-columns: 110px 1fr; gap:8px; align-items:center; margin-bottom:8px;"; + const workflowTypeLabel = document.createElement("div"); + workflowTypeLabel.className = "mjr-popover-label"; + workflowTypeLabel.textContent = "Workflow type"; + const workflowTypeSelect = document.createElement("select"); + workflowTypeSelect.className = "mjr-select"; + [ + ["", "Any"], + ["T2I", "T2I"], + ["I2I", "I2I"], + ["T2V", "T2V"], + ["I2V", "I2V"], + ["V2V", "V2V"], + ["FLF", "FLF (First/Last Frame)"], + ["UPSCL", "UPSCL"], + ["INPT", "INPT"], + ["TTS", "TTS (Text to Speech)"], + ["A2A", "A2A (Audio to Audio)"], + ].forEach(([val, text]) => { + const opt = document.createElement("option"); + opt.value = val; + opt.textContent = text; + workflowTypeSelect.appendChild(opt); + }); + workflowTypeRow.appendChild(workflowTypeLabel); + workflowTypeRow.appendChild(workflowTypeSelect); const dateRow = document.createElement("div"); dateRow.className = "mjr-popover-row"; @@ -90,6 +155,74 @@ export function createFilterPopoverView() { }); dateRow.appendChild(dateLabel); dateRow.appendChild(dateRangeSelect); + dateRow.style.cssText = "display:grid; grid-template-columns: 110px 1fr; gap:8px; align-items:center; margin-bottom:8px;"; + + const sizeRow = document.createElement("div"); + sizeRow.className = "mjr-popover-row"; + sizeRow.style.cssText = "display:grid; grid-template-columns: 110px 1fr 1fr; gap:8px; align-items:center; margin-bottom:8px;"; + const sizeLabel = document.createElement("div"); + sizeLabel.className = "mjr-popover-label"; + sizeLabel.textContent = "File size (MB)"; + const minSizeInput = document.createElement("input"); + minSizeInput.type = "number"; + minSizeInput.min = "0"; + minSizeInput.step = "0.1"; + minSizeInput.className = "mjr-input"; + minSizeInput.placeholder = "Min"; + const maxSizeInput = document.createElement("input"); + maxSizeInput.type = "number"; + maxSizeInput.min = "0"; + maxSizeInput.step = "0.1"; + maxSizeInput.className = "mjr-input"; + maxSizeInput.placeholder = "Max"; + sizeRow.appendChild(sizeLabel); + sizeRow.appendChild(minSizeInput); + sizeRow.appendChild(maxSizeInput); + + const resPresetRow = document.createElement("div"); + resPresetRow.className = "mjr-popover-row"; + resPresetRow.style.cssText = "display:grid; grid-template-columns: 110px 1fr; gap:8px; align-items:center; margin-bottom:8px;"; + const resPresetLabel = document.createElement("div"); + resPresetLabel.className = "mjr-popover-label"; + resPresetLabel.textContent = "Resolution"; + const resolutionPresetSelect = document.createElement("select"); + resolutionPresetSelect.className = "mjr-select"; + [ + ["", "Any"], + ["hd", "HD (>=1280x720)"], + ["fhd", "FHD (>=1920x1080)"], + ["qhd", "QHD (>=2560x1440)"], + ["uhd", "4K (>=3840x2160)"], + ].forEach(([val, text]) => { + const opt = document.createElement("option"); + opt.value = val; + opt.textContent = text; + resolutionPresetSelect.appendChild(opt); + }); + resPresetRow.appendChild(resPresetLabel); + resPresetRow.appendChild(resolutionPresetSelect); + + const resRow = document.createElement("div"); + resRow.className = "mjr-popover-row"; + resRow.style.cssText = "display:grid; grid-template-columns: 110px 1fr 1fr; gap:8px; align-items:center; margin-bottom:8px;"; + const resLabel = document.createElement("div"); + resLabel.className = "mjr-popover-label"; + resLabel.textContent = "Min WxH"; + const minWidthInput = document.createElement("input"); + minWidthInput.type = "number"; + minWidthInput.min = "0"; + minWidthInput.step = "1"; + minWidthInput.className = "mjr-input"; + minWidthInput.placeholder = "Width"; + const minHeightInput = document.createElement("input"); + minHeightInput.type = "number"; + minHeightInput.min = "0"; + minHeightInput.step = "1"; + minHeightInput.className = "mjr-input"; + minHeightInput.placeholder = "Height"; + resRow.appendChild(resLabel); + resRow.appendChild(minWidthInput); + resRow.appendChild(minHeightInput); const agendaRow = document.createElement("div"); agendaRow.className = "mjr-popover-row"; @@ -111,17 +244,36 @@ export function createFilterPopoverView() { agendaContainer.appendChild(agendaInput); agendaRow.appendChild(agendaContainer); - filterPopover.appendChild(kindRow); - filterPopover.appendChild(wfRow); - filterPopover.appendChild(ratingRow); - filterPopover.appendChild(dateRow); - filterPopover.appendChild(agendaRow); + const groupMain = makeGroup("Core"); + groupMain.appendChild(kindRow); + groupMain.appendChild(wfRow); + groupMain.appendChild(workflowTypeRow); + groupMain.appendChild(ratingRow); + + const groupMedia = makeGroup("Media"); + groupMedia.appendChild(sizeRow); + groupMedia.appendChild(resPresetRow); + groupMedia.appendChild(resRow); + + const groupTime = makeGroup("Time"); + groupTime.appendChild(dateRow); + groupTime.appendChild(agendaRow); + + filterPopover.appendChild(groupMain); + filterPopover.appendChild(groupMedia); + filterPopover.appendChild(groupTime); return { filterPopover, kindSelect, wfCheckbox, + workflowTypeSelect, ratingSelect, + minSizeInput, + maxSizeInput, + resolutionPresetSelect, + minWidthInput, + minHeightInput, dateRangeSelect, dateExactInput: agendaInput, agendaContainer diff --git a/js/features/panel/views/sortPopoverView.js b/js/features/panel/views/sortPopoverView.js index 436dc56a..c1813e4d 100644 --- a/js/features/panel/views/sortPopoverView.js +++ b/js/features/panel/views/sortPopoverView.js @@ -2,9 +2,19 @@ export function createSortPopoverView() { const sortPopover = document.createElement("div"); sortPopover.className = "mjr-popover mjr-sort-popover"; sortPopover.style.display = "none"; + sortPopover.style.cssText = ` + display:none; + border: 1px solid rgba(120,180,255,0.35); + background: linear-gradient(180deg, rgba(16,24,36,0.96) 0%, rgba(11,16,24,0.96) 100%); + box-shadow: 0 14px 34px rgba(0,0,0,0.42), 0 0 0 1px rgba(130,185,255,0.16) inset; + border-radius: 12px; + padding: 10px; + min-width: 220px; + `; const sortMenu = document.createElement("div"); sortMenu.className = "mjr-menu"; + sortMenu.style.cssText = "display:grid; gap:6px;"; sortPopover.appendChild(sortMenu); return { sortPopover, sortMenu }; diff --git a/js/features/status/StatusDot.js b/js/features/status/StatusDot.js index e94b8a6a..c137d67b 100644 --- a/js/features/status/StatusDot.js +++ b/js/features/status/StatusDot.js @@ -914,18 +914,42 @@ export async function updateStatus(statusDot, statusText, capabilitiesSection = desiredScope === "custom" ? `${ENDPOINTS.HEALTH_COUNTERS}?scope=custom&custom_root_id=${encodeURIComponent(String(desiredCustomRootId || ""))}` : `${ENDPOINTS.HEALTH_COUNTERS}?scope=${encodeURIComponent(desiredScope || "output")}`; - const [result, healthResult, dbDiagResult] = await Promise.all([ - get(url, signal ? { signal } : undefined), - get(ENDPOINTS.HEALTH, signal ? { signal } : undefined), - get(ENDPOINTS.HEALTH_DB, signal ? { signal } : undefined), - ]); + const lightweight = !!options?.lightweight; + const shouldFetchAux = + force || + !lightweight || + !(meta && typeof meta === "object" && meta._auxCached); + + let result = null; + let healthResult = null; + let dbDiagResult = null; let toolStatusData = null; - try { - const toolsResult = await getToolsStatus(signal ? { signal } : undefined); - if (toolsResult?.ok) { - toolStatusData = toolsResult.data; + + if (shouldFetchAux) { + const [countersRes, healthRes, dbRes] = await Promise.all([ + get(url, signal ? { signal } : undefined), + get(ENDPOINTS.HEALTH, signal ? { signal } : undefined), + get(ENDPOINTS.HEALTH_DB, signal ? { signal } : undefined), + ]); + result = countersRes; + healthResult = healthRes; + dbDiagResult = dbRes; + try { + const toolsResult = await getToolsStatus(signal ? { signal } : undefined); + if (toolsResult?.ok) toolStatusData = toolsResult.data; + } catch {} + if (meta && typeof meta === "object") { + meta._auxCached = true; + meta._healthCache = healthResult; + meta._dbDiagCache = dbDiagResult; + meta._toolsCache = toolStatusData; } - } catch {} + } else { + result = await get(url, signal ? { signal } : undefined); + healthResult = meta?._healthCache || null; + dbDiagResult = meta?._dbDiagCache || null; + toolStatusData = meta?._toolsCache || null; + } try { if (signal?.aborted) return null; } catch {} @@ -1126,19 +1150,24 @@ export function setupStatusPolling( getScanTarget = null ) { const GLOBAL_POLL_KEY = "__MJR_STATUS_POLL_DISPOSE__"; - const pollMeta = { lastCode: null, lastStatus: null }; + const pollMeta = { lastCode: null, lastStatus: null, _pollTick: 0, _auxCached: false }; const pollingAC = typeof AbortController !== "undefined" ? new AbortController() : null; const handleUpdate = async () => { try { if (pollingAC?.signal?.aborted) return null; } catch {} + pollMeta._pollTick = Number(pollMeta._pollTick || 0) + 1; + const lightweight = pollMeta._pollTick > 1 && (pollMeta._pollTick % 4 !== 0); const target = typeof getScanTarget === "function" ? getScanTarget() : null; const counters = await updateStatus(statusDot, statusText, capabilitiesSection, target, pollMeta, { - signal: pollingAC?.signal || null + signal: pollingAC?.signal || null, + lightweight }); if (counters && typeof onCountersUpdate === "function") { - onCountersUpdate(counters); + try { + await onCountersUpdate(counters); + } catch {} } return counters; }; diff --git a/mjr_am_backend/adapters/db/schema.py b/mjr_am_backend/adapters/db/schema.py index be342824..2bb3c77a 100644 --- a/mjr_am_backend/adapters/db/schema.py +++ b/mjr_am_backend/adapters/db/schema.py @@ -223,15 +223,17 @@ async def table_has_column(db, table_name: str, column_name: str) -> bool: ) return False - return column_name in columns_result.data + columns = columns_result.data or [] + return column_name in columns async def _ensure_column(db, table_name: str, column_name: str, definition: str) -> Result[bool]: columns_result = await _get_table_columns(db, table_name) if not columns_result.ok: - return columns_result + return Result.Err(columns_result.code or "PRAGMA_FAILED", columns_result.error or "Unable to inspect table") - if column_name in columns_result.data: + columns = columns_result.data or [] + if column_name in columns: return Result.Ok(True) logger.info("Adding missing column %s.%s", table_name, column_name) diff --git a/mjr_am_backend/adapters/db/sqlite.py b/mjr_am_backend/adapters/db/sqlite.py index 9d5d12d9..7c06b96d 100644 --- a/mjr_am_backend/adapters/db/sqlite.py +++ b/mjr_am_backend/adapters/db/sqlite.py @@ -421,17 +421,23 @@ class Sqlite: out: Dict[str, Any] = {} try: if "timeout" in data: - out["timeout"] = max(1.0, float(data.get("timeout"))) + timeout_raw = data.get("timeout") + if timeout_raw is not None: + out["timeout"] = max(1.0, float(timeout_raw)) except Exception: pass try: if "maxConnections" in data: - out["maxConnections"] = max(1, int(data.get("maxConnections"))) + max_connections_raw = data.get("maxConnections") + if max_connections_raw is not None: + out["maxConnections"] = max(1, int(max_connections_raw)) except Exception: pass try: if "queryTimeout" in data: - out["queryTimeout"] = max(0.0, float(data.get("queryTimeout"))) + query_timeout_raw = data.get("queryTimeout") + if query_timeout_raw is not None: + out["queryTimeout"] = max(0.0, float(query_timeout_raw)) except Exception: pass return out @@ -972,6 +978,7 @@ class Sqlite: await self._sleep_backoff(attempt) continue raise + return Result.Err(ErrorCode.DB_ERROR, "Query failed after retries") except Exception: raise @@ -1148,6 +1155,7 @@ class Sqlite: await self._sleep_backoff(attempt) continue raise + return Result.Err(ErrorCode.DB_ERROR, "Batch execute failed after retries") except Exception: raise @@ -1198,6 +1206,7 @@ class Sqlite: await self._sleep_backoff(attempt) continue raise + return Result.Err(ErrorCode.DB_ERROR, "Script execution failed after retries") except sqlite3.OperationalError as exc: if "interrupted" in str(exc).lower(): return Result.Err(ErrorCode.TIMEOUT, "Database operation interrupted (query timeout)") diff --git a/mjr_am_backend/adapters/tools/exiftool.py b/mjr_am_backend/adapters/tools/exiftool.py index 31d05e8d..3e69886e 100644 --- a/mjr_am_backend/adapters/tools/exiftool.py +++ b/mjr_am_backend/adapters/tools/exiftool.py @@ -391,15 +391,17 @@ class ExifTool: Dict mapping file path to Result with metadata """ if not self._available: - err = Result.Err(ErrorCode.TOOL_MISSING, "ExifTool not found in PATH", quality="none") + err: Result[Dict[str, Any]] = Result.Err( + ErrorCode.TOOL_MISSING, "ExifTool not found in PATH", quality="none" + ) return {str(p): err for p in paths} if not paths: return {} # Filter valid paths - valid_paths = [] - results = {} + valid_paths: List[str] = [] + results: Dict[str, Result[Dict[str, Any]]] = {} for path in paths: if not path or "\x00" in str(path): diff --git a/mjr_am_backend/adapters/tools/ffprobe.py b/mjr_am_backend/adapters/tools/ffprobe.py index faef22d6..d6c8a627 100644 --- a/mjr_am_backend/adapters/tools/ffprobe.py +++ b/mjr_am_backend/adapters/tools/ffprobe.py @@ -198,14 +198,14 @@ class FFProbe: Dict mapping file path to Result with metadata """ if not self._available: - err = Result.Err(ErrorCode.TOOL_MISSING, "ffprobe not found in PATH", quality="none") + err: Result[dict] = Result.Err(ErrorCode.TOOL_MISSING, "ffprobe not found in PATH", quality="none") return {str(p): err for p in paths} if not paths: return {} from concurrent.futures import ThreadPoolExecutor, as_completed - results = {} + results: Dict[str, Result[dict]] = {} # Use thread pool for parallel execution (I/O bound) max_workers = min(self._max_workers, len(paths)) @@ -249,10 +249,11 @@ class FFProbe: """ result = self.read(path) if not result.ok: - return result + return Result.Err(result.code or ErrorCode.FFPROBE_ERROR, result.error or "ffprobe failed") # Try format duration first - format_info = result.data.get("format", {}) + data = result.data if isinstance(result.data, dict) else {} + format_info = data.get("format", {}) duration_str = format_info.get("duration") if duration_str: @@ -262,7 +263,7 @@ class FFProbe: pass # Try video stream duration - video_stream = result.data.get("video_stream", {}) + video_stream = data.get("video_stream", {}) duration_str = video_stream.get("duration") if duration_str: @@ -288,9 +289,10 @@ class FFProbe: """ result = self.read(path) if not result.ok: - return result + return Result.Err(result.code or ErrorCode.FFPROBE_ERROR, result.error or "ffprobe failed") - video_stream = result.data.get("video_stream", {}) + data = result.data if isinstance(result.data, dict) else {} + video_stream = data.get("video_stream", {}) width = video_stream.get("width") height = video_stream.get("height") diff --git a/mjr_am_backend/config.py b/mjr_am_backend/config.py index 04426ebd..9aad7c04 100644 --- a/mjr_am_backend/config.py +++ b/mjr_am_backend/config.py @@ -6,12 +6,73 @@ import sys import logging from pathlib import Path -from .utils import env_float +from .utils import env_bool logger = logging.getLogger(__name__) + +def _env_raw(*names: str, default: str | None = None) -> str | None: + for name in names: + if not name: + continue + try: + val = os.getenv(name) + except Exception: + val = None + if val is not None and str(val).strip() != "": + return str(val).strip() + return default + + +def _env_int(default: int, *names: str, min_value: int | None = None, max_value: int | None = None) -> int: + raw = _env_raw(*names) + if raw is None: + return default + try: + value = int(raw) + except (TypeError, ValueError): + logger.warning("Invalid integer for %s=%r, using default=%s", names[0] if names else "", raw, default) + return default + if min_value is not None and value < min_value: + logger.warning("Value too small for %s=%s, clamped to %s", names[0] if names else "", value, min_value) + value = min_value + if max_value is not None and value > max_value: + logger.warning("Value too large for %s=%s, clamped to %s", names[0] if names else "", value, max_value) + value = max_value + return value + + +def _env_float(default: float, *names: str, min_value: float | None = None, max_value: float | None = None) -> float: + raw = _env_raw(*names) + if raw is None: + return default + try: + value = float(raw) + except (TypeError, ValueError): + logger.warning("Invalid float for %s=%r, using default=%s", names[0] if names else "", raw, default) + return default + if min_value is not None and value < min_value: + logger.warning("Value too small for %s=%s, clamped to %s", names[0] if names else "", value, min_value) + value = min_value + if max_value is not None and value > max_value: + logger.warning("Value too large for %s=%s, clamped to %s", names[0] if names else "", value, max_value) + value = max_value + return value + + +def _env_bool(default: bool, *names: str) -> bool: + for name in names: + if not name: + continue + try: + if name in os.environ: + return env_bool(name, default) + except Exception: + continue + return default + def _resolve_output_root() -> Path: - env_path = os.getenv("MAJOOR_OUTPUT_DIRECTORY") + env_path = _env_raw("MJR_AM_OUTPUT_DIRECTORY", "MAJOOR_OUTPUT_DIRECTORY") if env_path: try: return Path(env_path).expanduser().resolve() @@ -69,9 +130,10 @@ def _resolve_output_root() -> Path: return Path(tok.split("=", 1)[1]).expanduser().resolve() except Exception: pass - output_dir = comfy_root / "output" - if output_dir.is_dir(): - return output_dir.resolve() + if comfy_root is not None: + output_dir = comfy_root / "output" + if output_dir.is_dir(): + return output_dir.resolve() # Strategy 2: fallback to CWD/output. logger.warning("Output directory not found from ComfyUI hints, creating fallback in current working directory") @@ -96,7 +158,7 @@ def get_runtime_output_root() -> str: 1) live override from MAJOOR_OUTPUT_DIRECTORY 2) startup-resolved OUTPUT_ROOT fallback """ - env_path = str(os.getenv("MAJOOR_OUTPUT_DIRECTORY") or "").strip() + env_path = str(_env_raw("MJR_AM_OUTPUT_DIRECTORY", "MAJOOR_OUTPUT_DIRECTORY", default="") or "").strip() if env_path: try: return str(Path(env_path).expanduser().resolve()) @@ -125,8 +187,8 @@ os.makedirs(INDEX_DIR, exist_ok=True) os.makedirs(COLLECTIONS_DIR, exist_ok=True) # External tool overrides (portable vs. system-wide) -EXIFTOOL_BIN = os.getenv("MAJOOR_EXIFTOOL_PATH") or os.getenv("MAJOOR_EXIFTOOL_BIN") or "exiftool" -FFPROBE_BIN = os.getenv("MAJOOR_FFPROBE_PATH") or os.getenv("MAJOOR_FFPROBE_BIN") or "ffprobe" +EXIFTOOL_BIN = _env_raw("MJR_AM_EXIFTOOL_PATH", "MAJOOR_EXIFTOOL_PATH", "MAJOOR_EXIFTOOL_BIN", default="exiftool") +FFPROBE_BIN = _env_raw("MJR_AM_FFPROBE_PATH", "MAJOOR_FFPROBE_PATH", "MAJOOR_FFPROBE_BIN", default="ffprobe") TOOL_LOCATIONS = { "exiftool": EXIFTOOL_BIN, @@ -137,44 +199,38 @@ def get_tool_paths(): """Return the resolved external tool executable paths.""" return TOOL_LOCATIONS.copy() -EXIFTOOL_MIN_VERSION = os.getenv("MAJOOR_EXIFTOOL_MIN_VERSION", "").strip() -FFPROBE_MIN_VERSION = os.getenv("MAJOOR_FFPROBE_MIN_VERSION", "").strip() +EXIFTOOL_MIN_VERSION = str(_env_raw("MJR_AM_EXIFTOOL_MIN_VERSION", "MAJOOR_EXIFTOOL_MIN_VERSION", default="") or "").strip() +FFPROBE_MIN_VERSION = str(_env_raw("MJR_AM_FFPROBE_MIN_VERSION", "MAJOOR_FFPROBE_MIN_VERSION", default="") or "").strip() # Media probe backend setting # Options: "auto" (recommended), "exiftool", "ffprobe", "both" MEDIA_PROBE_BACKEND = os.getenv("MAJOOR_MEDIA_PROBE_BACKEND", "auto") - -# Performance tuning defaults -def _env_int(name: str, default: int) -> int: - try: - return int(os.getenv(name, default)) - except (TypeError, ValueError): - return default +MEDIA_PROBE_BACKEND = str(_env_raw("MJR_AM_MEDIA_PROBE_BACKEND", "MAJOOR_MEDIA_PROBE_BACKEND", default="auto") or "auto") # Tool timeouts -EXIFTOOL_TIMEOUT = _env_int("MAJOOR_EXIFTOOL_TIMEOUT", 15) -FFPROBE_TIMEOUT = _env_int("MAJOOR_FFPROBE_TIMEOUT", 10) +EXIFTOOL_TIMEOUT = _env_int(15, "MJR_AM_EXIFTOOL_TIMEOUT", "MAJOOR_EXIFTOOL_TIMEOUT", min_value=1, max_value=120) +FFPROBE_TIMEOUT = _env_int(10, "MJR_AM_FFPROBE_TIMEOUT", "MAJOOR_FFPROBE_TIMEOUT", min_value=1, max_value=120) # Database tuning -DB_TIMEOUT = env_float("MAJOOR_DB_TIMEOUT", 30.0) -DB_MAX_CONNECTIONS = _env_int("MAJOOR_DB_MAX_CONNECTIONS", 8) -DB_QUERY_TIMEOUT = env_float("MAJOOR_DB_QUERY_TIMEOUT", 60.0) -TO_THREAD_TIMEOUT_S = env_float("MAJOOR_TO_THREAD_TIMEOUT", 30.0) -MAX_METADATA_JSON_BYTES = _env_int("MAJOOR_MAX_METADATA_JSON_BYTES", 2 * 1024 * 1024) # 2MB -METADATA_CACHE_MAX = _env_int("MAJOOR_METADATA_CACHE_MAX", 100_000) -METADATA_CACHE_TTL_SECONDS = env_float("MAJOOR_METADATA_CACHE_TTL_SECONDS", 90.0 * 24.0 * 3600.0) -METADATA_CACHE_CLEANUP_INTERVAL_SECONDS = env_float("MAJOOR_METADATA_CACHE_CLEANUP_INTERVAL_SECONDS", 300.0) -METADATA_EXTRACT_CONCURRENCY = _env_int("MAJOOR_METADATA_EXTRACT_CONCURRENCY", 1) +DB_TIMEOUT = _env_float(30.0, "MJR_AM_DB_TIMEOUT", "MAJOOR_DB_TIMEOUT", min_value=1.0, max_value=300.0) +DB_MAX_CONNECTIONS = _env_int(8, "MJR_AM_DB_MAX_CONNECTIONS", "MAJOOR_DB_MAX_CONNECTIONS", min_value=1, max_value=64) +DB_QUERY_TIMEOUT = _env_float(60.0, "MJR_AM_DB_QUERY_TIMEOUT", "MAJOOR_DB_QUERY_TIMEOUT", min_value=1.0, max_value=600.0) +TO_THREAD_TIMEOUT_S = _env_float(30.0, "MJR_AM_TO_THREAD_TIMEOUT", "MAJOOR_TO_THREAD_TIMEOUT", min_value=1.0, max_value=300.0) +MAX_METADATA_JSON_BYTES = _env_int(2 * 1024 * 1024, "MJR_AM_MAX_METADATA_JSON_BYTES", "MAJOOR_MAX_METADATA_JSON_BYTES", min_value=64 * 1024, max_value=32 * 1024 * 1024) +METADATA_CACHE_MAX = _env_int(100_000, "MJR_AM_METADATA_CACHE_MAX", "MAJOOR_METADATA_CACHE_MAX", min_value=1000, max_value=5_000_000) +METADATA_CACHE_TTL_SECONDS = _env_float(90.0 * 24.0 * 3600.0, "MJR_AM_METADATA_CACHE_TTL_SECONDS", "MAJOOR_METADATA_CACHE_TTL_SECONDS", min_value=60.0, max_value=3650.0 * 24.0 * 3600.0) +METADATA_CACHE_CLEANUP_INTERVAL_SECONDS = _env_float(300.0, "MJR_AM_METADATA_CACHE_CLEANUP_INTERVAL_SECONDS", "MAJOOR_METADATA_CACHE_CLEANUP_INTERVAL_SECONDS", min_value=5.0, max_value=3600.0) +METADATA_EXTRACT_CONCURRENCY = _env_int(1, "MJR_AM_METADATA_EXTRACT_CONCURRENCY", "MAJOOR_METADATA_EXTRACT_CONCURRENCY", min_value=1, max_value=16) # Index dedupe (avoid double-indexing bursts from multiple event sources) -INDEX_DEDUPE_TTL_SECONDS = env_float("MAJOOR_INDEX_DEDUPE_TTL_SECONDS", 2.0) -INDEX_DEDUPE_MAX = _env_int("MAJOOR_INDEX_DEDUPE_MAX", 5000) +INDEX_DEDUPE_TTL_SECONDS = _env_float(2.0, "MJR_AM_INDEX_DEDUPE_TTL_SECONDS", "MAJOOR_INDEX_DEDUPE_TTL_SECONDS", min_value=0.1, max_value=60.0) +INDEX_DEDUPE_MAX = _env_int(5000, "MJR_AM_INDEX_DEDUPE_MAX", "MAJOOR_INDEX_DEDUPE_MAX", min_value=100, max_value=200_000) # File watcher (watches for manual file additions in output/custom directories) # Disable with MJR_ENABLE_WATCHER=0 -WATCHER_ENABLED = os.getenv("MJR_ENABLE_WATCHER", "1").strip().lower() not in ("0", "false", "no", "off") -WATCHER_DEFAULT_DEBOUNCE_MS = _env_int("MJR_WATCHER_DEBOUNCE_MS", 3000) -WATCHER_DEFAULT_DEDUPE_TTL_MS = _env_int("MJR_WATCHER_DEDUPE_TTL_MS", 3000) +WATCHER_ENABLED = _env_bool(True, "MJR_AM_ENABLE_WATCHER", "MJR_ENABLE_WATCHER") +WATCHER_DEFAULT_DEBOUNCE_MS = _env_int(3000, "MJR_AM_WATCHER_DEBOUNCE_MS", "MJR_WATCHER_DEBOUNCE_MS", min_value=0, max_value=120_000) +WATCHER_DEFAULT_DEDUPE_TTL_MS = _env_int(3000, "MJR_AM_WATCHER_DEDUPE_TTL_MS", "MJR_WATCHER_DEDUPE_TTL_MS", min_value=0, max_value=120_000) WATCHER_DEBOUNCE_MS = WATCHER_DEFAULT_DEBOUNCE_MS WATCHER_DEDUPE_TTL_MS = WATCHER_DEFAULT_DEDUPE_TTL_MS # Backward-compatible aliases: @@ -182,54 +238,66 @@ WATCHER_DEDUPE_TTL_MS = WATCHER_DEFAULT_DEDUPE_TTL_MS # - MAJOOR_WATCHER_MIN_FILE_SIZE # - MAJOOR_WATCHER_MAX_FILE_SIZE WATCHER_MAX_FILE_SIZE_BYTES = _env_int( + _env_int(512 * 1024 * 1024, "MJR_AM_WATCHER_MAX_FILE_SIZE_BYTES", "MJR_WATCHER_MAX_FILE_SIZE_BYTES", "MAJOOR_WATCHER_MAX_FILE_SIZE"), + "MJR_AM_WATCHER_MAX_FILE_SIZE_BYTES", "MJR_WATCHER_MAX_FILE_SIZE_BYTES", - _env_int("MAJOOR_WATCHER_MAX_FILE_SIZE", 512 * 1024 * 1024), + "MAJOOR_WATCHER_MAX_FILE_SIZE", + min_value=1024, + max_value=32 * 1024 * 1024 * 1024, ) WATCHER_MIN_FILE_SIZE_BYTES = _env_int( + _env_int(100, "MJR_AM_WATCHER_MIN_FILE_SIZE_BYTES", "MJR_WATCHER_MIN_FILE_SIZE_BYTES", "MAJOOR_WATCHER_MIN_FILE_SIZE"), + "MJR_AM_WATCHER_MIN_FILE_SIZE_BYTES", "MJR_WATCHER_MIN_FILE_SIZE_BYTES", - _env_int("MAJOOR_WATCHER_MIN_FILE_SIZE", 100), + "MAJOOR_WATCHER_MIN_FILE_SIZE", + min_value=0, + max_value=10_000_000, ) -WATCHER_FLUSH_MAX_FILES = _env_int("MJR_WATCHER_FLUSH_MAX_FILES", 256) -WATCHER_STREAM_ALERT_WINDOW_SECONDS = env_float("MJR_WATCHER_STREAM_ALERT_WINDOW_SECONDS", 60.0) -WATCHER_STREAM_ALERT_THRESHOLD = _env_int("MJR_WATCHER_STREAM_ALERT_THRESHOLD", 512) -WATCHER_STREAM_ALERT_COOLDOWN_SECONDS = env_float("MJR_WATCHER_STREAM_ALERT_COOLDOWN_SECONDS", 300.0) -WATCHER_MAX_FLUSH_CONCURRENCY = max(1, _env_int("MJR_WATCHER_MAX_FLUSH_CONCURRENCY", 2)) +WATCHER_FLUSH_MAX_FILES = _env_int(256, "MJR_AM_WATCHER_FLUSH_MAX_FILES", "MJR_WATCHER_FLUSH_MAX_FILES", min_value=1, max_value=5000) +WATCHER_STREAM_ALERT_WINDOW_SECONDS = _env_float(60.0, "MJR_AM_WATCHER_STREAM_ALERT_WINDOW_SECONDS", "MJR_WATCHER_STREAM_ALERT_WINDOW_SECONDS", min_value=1.0, max_value=3600.0) +WATCHER_STREAM_ALERT_THRESHOLD = _env_int(512, "MJR_AM_WATCHER_STREAM_ALERT_THRESHOLD", "MJR_WATCHER_STREAM_ALERT_THRESHOLD", min_value=1, max_value=100_000) +WATCHER_STREAM_ALERT_COOLDOWN_SECONDS = _env_float(300.0, "MJR_AM_WATCHER_STREAM_ALERT_COOLDOWN_SECONDS", "MJR_WATCHER_STREAM_ALERT_COOLDOWN_SECONDS", min_value=1.0, max_value=86400.0) +WATCHER_MAX_FLUSH_CONCURRENCY = max(1, _env_int(2, "MJR_AM_WATCHER_MAX_FLUSH_CONCURRENCY", "MJR_WATCHER_MAX_FLUSH_CONCURRENCY", min_value=1, max_value=32)) WATCHER_PENDING_MAX = _env_int( + _env_int(500, "MJR_AM_WATCHER_PENDING_MAX", "MJR_WATCHER_PENDING_MAX", "MAJOOR_WATCHER_MAX_PENDING_FILES"), + "MJR_AM_WATCHER_PENDING_MAX", "MJR_WATCHER_PENDING_MAX", - _env_int("MAJOOR_WATCHER_MAX_PENDING_FILES", 500), + "MAJOOR_WATCHER_MAX_PENDING_FILES", + min_value=10, + max_value=50000, ) # Background scan / filesystem listing tuning -BG_SCAN_FAILURE_HISTORY_MAX = _env_int("MAJOOR_BG_SCAN_FAILURE_HISTORY_MAX", 50) -SCAN_PENDING_MAX = _env_int("MAJOOR_SCAN_PENDING_MAX", 64) -MANUAL_BG_SCAN_GRACE_SECONDS = env_float("MAJOOR_MANUAL_BG_SCAN_GRACE_SECONDS", 30.0) -BG_SCAN_ON_LIST = os.getenv("MAJOOR_BG_SCAN_ON_LIST", "0").strip().lower() in ("1", "true", "yes", "on") -BG_SCAN_MIN_INTERVAL_SECONDS = env_float("MAJOOR_BG_SCAN_MIN_INTERVAL_SECONDS", 30.0) -SEARCH_MAX_LIMIT = _env_int("MAJOOR_SEARCH_MAX_LIMIT", 500) -SEARCH_MAX_OFFSET = _env_int("MAJOOR_SEARCH_MAX_OFFSET", 10_000) +BG_SCAN_FAILURE_HISTORY_MAX = _env_int(50, "MJR_AM_BG_SCAN_FAILURE_HISTORY_MAX", "MAJOOR_BG_SCAN_FAILURE_HISTORY_MAX", min_value=10, max_value=10000) +SCAN_PENDING_MAX = _env_int(64, "MJR_AM_SCAN_PENDING_MAX", "MAJOOR_SCAN_PENDING_MAX", min_value=1, max_value=10000) +MANUAL_BG_SCAN_GRACE_SECONDS = _env_float(30.0, "MJR_AM_MANUAL_BG_SCAN_GRACE_SECONDS", "MAJOOR_MANUAL_BG_SCAN_GRACE_SECONDS", min_value=0.0, max_value=3600.0) +BG_SCAN_ON_LIST = _env_bool(False, "MJR_AM_BG_SCAN_ON_LIST", "MAJOOR_BG_SCAN_ON_LIST") +BG_SCAN_MIN_INTERVAL_SECONDS = _env_float(30.0, "MJR_AM_BG_SCAN_MIN_INTERVAL_SECONDS", "MAJOOR_BG_SCAN_MIN_INTERVAL_SECONDS", min_value=0.0, max_value=3600.0) +SEARCH_MAX_LIMIT = _env_int(500, "MJR_AM_SEARCH_MAX_LIMIT", "MAJOOR_SEARCH_MAX_LIMIT", min_value=1, max_value=100000) +SEARCH_MAX_OFFSET = _env_int(10_000, "MJR_AM_SEARCH_MAX_OFFSET", "MAJOOR_SEARCH_MAX_OFFSET", min_value=0, max_value=10_000_000) # Filesystem listing cache (used by filesystem fallback search/list) -FS_LIST_CACHE_MAX = _env_int("MAJOOR_FS_LIST_CACHE_MAX", 32) -FS_LIST_CACHE_TTL_SECONDS = env_float("MAJOOR_FS_LIST_CACHE_TTL_SECONDS", 1.5) +FS_LIST_CACHE_MAX = _env_int(32, "MJR_AM_FS_LIST_CACHE_MAX", "MAJOOR_FS_LIST_CACHE_MAX", min_value=1, max_value=10000) +FS_LIST_CACHE_TTL_SECONDS = _env_float(1.5, "MJR_AM_FS_LIST_CACHE_TTL_SECONDS", "MAJOOR_FS_LIST_CACHE_TTL_SECONDS", min_value=0.1, max_value=3600.0) # Scanner batching (bounded transactions) # Tweak only if you know your workload; larger batches reduce transaction overhead but increase lock time. -SCAN_BATCH_SMALL_THRESHOLD = _env_int("MAJOOR_SCAN_BATCH_SMALL_THRESHOLD", 100) -SCAN_BATCH_MED_THRESHOLD = _env_int("MAJOOR_SCAN_BATCH_MED_THRESHOLD", 1000) -SCAN_BATCH_LARGE_THRESHOLD = _env_int("MAJOOR_SCAN_BATCH_LARGE_THRESHOLD", 10000) +SCAN_BATCH_SMALL_THRESHOLD = _env_int(100, "MJR_AM_SCAN_BATCH_SMALL_THRESHOLD", "MAJOOR_SCAN_BATCH_SMALL_THRESHOLD", min_value=1, max_value=1_000_000) +SCAN_BATCH_MED_THRESHOLD = _env_int(1000, "MJR_AM_SCAN_BATCH_MED_THRESHOLD", "MAJOOR_SCAN_BATCH_MED_THRESHOLD", min_value=1, max_value=1_000_000) +SCAN_BATCH_LARGE_THRESHOLD = _env_int(10000, "MJR_AM_SCAN_BATCH_LARGE_THRESHOLD", "MAJOOR_SCAN_BATCH_LARGE_THRESHOLD", min_value=1, max_value=1_000_000) # Default to fewer DB transactions for typical directories (~100-500 files). -SCAN_BATCH_SMALL = _env_int("MAJOOR_SCAN_BATCH_SMALL", 100) -SCAN_BATCH_MED = _env_int("MAJOOR_SCAN_BATCH_MED", 150) -SCAN_BATCH_LARGE = _env_int("MAJOOR_SCAN_BATCH_LARGE", 250) -SCAN_BATCH_INITIAL = _env_int("MAJOOR_SCAN_BATCH_INITIAL", 1000) -SCAN_BATCH_MIN = _env_int("MAJOOR_SCAN_BATCH_MIN", 100) +SCAN_BATCH_SMALL = _env_int(100, "MJR_AM_SCAN_BATCH_SMALL", "MAJOOR_SCAN_BATCH_SMALL", min_value=1, max_value=10000) +SCAN_BATCH_MED = _env_int(150, "MJR_AM_SCAN_BATCH_MED", "MAJOOR_SCAN_BATCH_MED", min_value=1, max_value=10000) +SCAN_BATCH_LARGE = _env_int(250, "MJR_AM_SCAN_BATCH_LARGE", "MAJOOR_SCAN_BATCH_LARGE", min_value=1, max_value=10000) +SCAN_BATCH_INITIAL = _env_int(1000, "MJR_AM_SCAN_BATCH_INITIAL", "MAJOOR_SCAN_BATCH_INITIAL", min_value=1, max_value=100000) +SCAN_BATCH_MIN = _env_int(100, "MJR_AM_SCAN_BATCH_MIN", "MAJOOR_SCAN_BATCH_MIN", min_value=1, max_value=10000) # Search limits -SEARCH_MAX_QUERY_LENGTH = _env_int("MJR_SEARCH_MAX_QUERY_LENGTH", 512) -SEARCH_MAX_TOKENS = _env_int("MJR_SEARCH_MAX_TOKENS", 16) -SEARCH_MAX_TOKEN_LENGTH = _env_int("MJR_SEARCH_MAX_TOKEN_LENGTH", 64) -SEARCH_MAX_BATCH_IDS = _env_int("MJR_SEARCH_MAX_BATCH_IDS", 200) -SEARCH_MAX_FILEPATH_LOOKUP = _env_int("MJR_SEARCH_MAX_FILEPATH_LOOKUP", 5000) -SCAN_BATCH_XL = _env_int("MAJOOR_SCAN_BATCH_XL", 400) +SEARCH_MAX_QUERY_LENGTH = _env_int(512, "MJR_AM_SEARCH_MAX_QUERY_LENGTH", "MJR_SEARCH_MAX_QUERY_LENGTH", min_value=16, max_value=8192) +SEARCH_MAX_TOKENS = _env_int(16, "MJR_AM_SEARCH_MAX_TOKENS", "MJR_SEARCH_MAX_TOKENS", min_value=1, max_value=128) +SEARCH_MAX_TOKEN_LENGTH = _env_int(64, "MJR_AM_SEARCH_MAX_TOKEN_LENGTH", "MJR_SEARCH_MAX_TOKEN_LENGTH", min_value=1, max_value=512) +SEARCH_MAX_BATCH_IDS = _env_int(200, "MJR_AM_SEARCH_MAX_BATCH_IDS", "MJR_SEARCH_MAX_BATCH_IDS", min_value=1, max_value=5000) +SEARCH_MAX_FILEPATH_LOOKUP = _env_int(5000, "MJR_AM_SEARCH_MAX_FILEPATH_LOOKUP", "MJR_SEARCH_MAX_FILEPATH_LOOKUP", min_value=1, max_value=100000) +SCAN_BATCH_XL = _env_int(400, "MJR_AM_SCAN_BATCH_XL", "MAJOOR_SCAN_BATCH_XL", min_value=1, max_value=20000) # Scanner limits -MAX_TO_ENRICH_ITEMS = _env_int("MAJOOR_MAX_TO_ENRICH_ITEMS", 10000) +MAX_TO_ENRICH_ITEMS = _env_int(10000, "MJR_AM_MAX_TO_ENRICH_ITEMS", "MAJOOR_MAX_TO_ENRICH_ITEMS", min_value=1, max_value=1_000_000) diff --git a/mjr_am_backend/custom_roots.py b/mjr_am_backend/custom_roots.py index 27baa123..96f59fcc 100644 --- a/mjr_am_backend/custom_roots.py +++ b/mjr_am_backend/custom_roots.py @@ -235,7 +235,7 @@ def resolve_custom_root(root_id: str) -> Result[Path]: roots_result = list_custom_roots() if not roots_result.ok: - return Result.Err(roots_result.code, roots_result.error) + return Result.Err(roots_result.code, roots_result.error or "Failed to list custom roots") for r in roots_result.data or []: if str(r.get("id") or "") == rid: normalized = _normalize_dir_path(str(r.get("path") or "")) diff --git a/mjr_am_backend/deps.py b/mjr_am_backend/deps.py index c7adb441..5528864f 100644 --- a/mjr_am_backend/deps.py +++ b/mjr_am_backend/deps.py @@ -5,6 +5,7 @@ Simple, debug-friendly DI without framework magic. import asyncio from pathlib import Path +from typing import Optional from .shared import Result, get_logger, log_success from .adapters.db.sqlite import Sqlite @@ -31,7 +32,7 @@ from .config import ( logger = get_logger(__name__) -async def build_services(db_path: str = None) -> Result[dict]: +async def build_services(db_path: Optional[str] = None) -> Result[dict]: """ Build all services (DI container). @@ -62,8 +63,8 @@ async def build_services(db_path: str = None) -> Result[dict]: return Result.Err(migrate_result.code or "DB_ERROR", f"Failed to initialize database: {migrate_result.error}") # Initialize adapters - exiftool = ExifTool(bin_name=EXIFTOOL_BIN, timeout=EXIFTOOL_TIMEOUT) - ffprobe = FFProbe(bin_name=FFPROBE_BIN, timeout=FFPROBE_TIMEOUT) + exiftool = ExifTool(bin_name=EXIFTOOL_BIN or "exiftool", timeout=EXIFTOOL_TIMEOUT) + ffprobe = FFProbe(bin_name=FFPROBE_BIN or "ffprobe", timeout=FFPROBE_TIMEOUT) settings_service = AppSettings(db) # Log tool availability @@ -189,7 +190,9 @@ async def _create_watcher(index_service: IndexService) -> OutputWatcher: except Exception: scope_cfg = {"scope": "output", "custom_root_id": ""} - watch_paths = build_watch_paths(scope_cfg.get("scope"), scope_cfg.get("custom_root_id")) + scope = str((scope_cfg or {}).get("scope") or "output") + custom_root_id = (scope_cfg or {}).get("custom_root_id") + watch_paths = build_watch_paths(scope, custom_root_id) if watch_paths: loop = asyncio.get_event_loop() diff --git a/mjr_am_backend/features/audio/metadata.py b/mjr_am_backend/features/audio/metadata.py index 2e997482..1bffed13 100644 --- a/mjr_am_backend/features/audio/metadata.py +++ b/mjr_am_backend/features/audio/metadata.py @@ -5,7 +5,7 @@ Audio metadata/geninfo extraction helpers. from __future__ import annotations import os -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Optional, Tuple, List from ...shared import ErrorCode, Result from ..metadata.parsing_utils import ( @@ -134,8 +134,8 @@ def extract_audio_metadata( } try: - audio_stream = {} - fmt = {} + audio_stream: Dict[str, Any] = {} + fmt: Dict[str, Any] = {} if isinstance(ffprobe_data, dict): audio_stream = ffprobe_data.get("audio_stream") or {} fmt = ffprobe_data.get("format") or {} @@ -170,11 +170,13 @@ def extract_audio_metadata( if metadata["quality"] != "full": metadata["quality"] = "partial" - text_candidates = [] + text_candidates: List[str] = [] if isinstance(exif_data, dict): text_candidates.extend(v for v in exif_data.values() if isinstance(v, str)) if isinstance(fmt, dict) and isinstance(fmt.get("tags"), dict): - text_candidates.extend(v for v in fmt.get("tags").values() if isinstance(v, str)) + tags_dict = fmt.get("tags") + if isinstance(tags_dict, dict): + text_candidates.extend(v for v in tags_dict.values() if isinstance(v, str)) for text in text_candidates: t = str(text or "").strip() if t.startswith("{") or t.startswith("["): diff --git a/mjr_am_backend/features/collections/service.py b/mjr_am_backend/features/collections/service.py index 91e9f69d..927a77b3 100644 --- a/mjr_am_backend/features/collections/service.py +++ b/mjr_am_backend/features/collections/service.py @@ -273,14 +273,17 @@ class CollectionsService: return err assert path is not None and data is not None - existing = data.get("items") if isinstance(data.get("items"), list) else [] + raw_items = data.get("items") + existing: List[Dict[str, Any]] = ( + [it for it in raw_items if isinstance(it, dict)] if isinstance(raw_items, list) else [] + ) seen = set() existing_seen = set() for it in existing: if isinstance(it, dict): - fp = it.get("filepath") - if fp: - k = _normalize_fp(str(fp)) + existing_fp = it.get("filepath") + if existing_fp: + k = _normalize_fp(str(existing_fp)) seen.add(k) existing_seen.add(k) @@ -338,11 +341,14 @@ class CollectionsService: return err assert path is not None and data is not None - existing = data.get("items") if isinstance(data.get("items"), list) else [] + raw_items = data.get("items") + existing: List[Dict[str, Any]] = ( + [it for it in raw_items if isinstance(it, dict)] if isinstance(raw_items, list) else [] + ) kept: List[Any] = [] removed = 0 for it in existing: - fp = it.get("filepath") if isinstance(it, dict) else None + fp = it.get("filepath") if fp and _normalize_fp(str(fp)) in targets: removed += 1 continue diff --git a/mjr_am_backend/features/duplicates/service.py b/mjr_am_backend/features/duplicates/service.py index 3c9bea5b..dbbdb578 100644 --- a/mjr_am_backend/features/duplicates/service.py +++ b/mjr_am_backend/features/duplicates/service.py @@ -221,7 +221,7 @@ class DuplicatesService: if len(items) < 2: continue exact_groups.append({"hash": h, "count": len(items), "assets": items[:25]}) - exact_groups.sort(key=lambda g: g.get("count", 0), reverse=True) + exact_groups.sort(key=lambda g: _safe_int(g.get("count")), reverse=True) exact_groups = exact_groups[:max_groups] sim_q = f""" @@ -268,7 +268,7 @@ class DuplicatesService: if len(similar_pairs) >= max_pairs: break - similar_pairs.sort(key=lambda p: p.get("distance", 64)) + similar_pairs.sort(key=lambda p: _safe_int(p.get("distance"), 64)) return Result.Ok({ "exact_groups": exact_groups, diff --git a/mjr_am_backend/features/geninfo/parser.py b/mjr_am_backend/features/geninfo/parser.py index 3040f75d..540024fe 100644 --- a/mjr_am_backend/features/geninfo/parser.py +++ b/mjr_am_backend/features/geninfo/parser.py @@ -10,7 +10,7 @@ from __future__ import annotations import os from collections import deque from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple, cast from ...shared import Result, get_logger @@ -680,7 +680,7 @@ def _extract_lyrics_from_prompt_nodes(nodes_by_id: Dict[str, Dict[str, Any]]) -> is_acestep_task = ("acestep15tasktextencode" in ct) or ("acesteptasktextencode" in ct) lyrics = None - lyric_keys = ("lyrics", "lyric", "lyric_text", "text_lyrics") + lyric_keys: Tuple[str, ...] = ("lyrics", "lyric", "lyric_text", "text_lyrics") # ACEStep task encoders often store the full song text in a generic textbox. if is_acestep_task: lyric_keys = ("lyrics", "lyric", "lyric_text", "text_lyrics", "task_text", "task", "text") @@ -1392,7 +1392,10 @@ def _detect_input_role(nodes_by_id: Dict[str, Any], subject_node_id: str) -> str for k, v in ins.items(): if _is_link(v): - src_id, _ = _resolve_link(v) + resolved = _resolve_link(v) + if not resolved: + continue + src_id, _ = resolved if str(src_id) in frontier: linked = True hit_input_name = str(k).lower() @@ -1826,7 +1829,7 @@ def _normalize_graph_input(prompt_graph: Any, workflow: Any) -> Optional[Dict[st if isinstance(node, dict): node_id = str(node.get("id")) # Convert LiteGraph node to prompt-graph-like format - converted = { + converted: Dict[str, Any] = { "class_type": node.get("type"), "type": node.get("type"), # Keep both for compatibility "id": node.get("id"), @@ -1868,25 +1871,27 @@ def _normalize_graph_input(prompt_graph: Any, workflow: Any) -> Optional[Dict[st widget_idx += 1 elif isinstance(raw_inputs, dict): # Already in dict format (shouldn't happen for LiteGraph but handle it) - converted["inputs"] = raw_inputs + converted["inputs"] = cast(Dict[str, Any], raw_inputs) # Handle widgets_values as dict (VHS and some other nodes) if isinstance(widgets_values, dict): for k, v in widgets_values.items(): - if k not in converted["inputs"]: - converted["inputs"][k] = v + converted_inputs = converted.get("inputs") + if isinstance(converted_inputs, dict) and k not in converted_inputs: + converted_inputs[k] = v # Also copy widgets_values to inputs if they have meaningful names # This helps with nodes that have prompts in widgets but no widget definition if widgets_list: # For text encoder nodes, widgets_values[0] is usually the text node_type_lower = _lower(node.get("type")) - if "text" not in converted["inputs"]: + converted_inputs = converted.get("inputs") + if isinstance(converted_inputs, dict) and "text" not in converted_inputs: if any(x in node_type_lower for x in ["primitive", "string", "text", "encode"]): for wv in widgets_list: if isinstance(wv, str) and len(wv.strip()) > 10: - converted["inputs"]["text"] = wv - converted["inputs"]["value"] = wv + converted_inputs["text"] = wv + converted_inputs["value"] = wv break nodes_by_id[node_id] = converted diff --git a/mjr_am_backend/features/index/scanner.py b/mjr_am_backend/features/index/scanner.py index c9a09b9f..264a42e4 100644 --- a/mjr_am_backend/features/index/scanner.py +++ b/mjr_am_backend/features/index/scanner.py @@ -11,7 +11,7 @@ import sqlite3 from concurrent.futures import ThreadPoolExecutor from pathlib import Path from queue import Empty, Queue -from typing import List, Dict, Any, Iterable, Optional +from typing import List, Dict, Any, Iterable, Optional, cast from datetime import datetime from uuid import uuid4 @@ -62,7 +62,7 @@ except Exception: def _is_fatal_db_error(exc: Exception) -> bool: if not isinstance(exc, sqlite3.DatabaseError): return False - if isinstance(exc, sqlite3.BusyError): + if "busy" in str(exc).lower() or "locked" in str(exc).lower(): return False return True @@ -94,7 +94,48 @@ class IndexScanner: self.metadata = metadata_service self._scan_lock = scan_lock self._index_lock = index_lock or scan_lock - self._current_scan_id = None + self._current_scan_id: Optional[str] = None + + @staticmethod + def _drain_walk_queue(q: "Queue[Optional[Path]]", max_items: int) -> list[Optional[Path]]: + """Read one-or-more items from walk queue with bounded non-blocking drain.""" + items: list[Optional[Path]] = [] + try: + first = q.get() + except Exception: + return items + items.append(first) + try: + limit = max(1, int(max_items or 1)) + except (TypeError, ValueError): + limit = 1 + while len(items) < limit: + try: + items.append(q.get_nowait()) + except Empty: + break + except Exception: + break + return items + + def _walk_and_enqueue(self, dir_path: Path, recursive: bool, stop_event: threading.Event, q: "Queue[Optional[Path]]") -> None: + """Producer running on executor: walks filesystem and pushes paths into queue.""" + try: + for fp in self._iter_files(dir_path, recursive): + if stop_event.is_set(): + break + try: + q.put(fp) + except Exception: + logger.debug("Walk queue push failed; stopping producer for %s", dir_path, exc_info=True) + break + except Exception: + logger.debug("Filesystem walk failed for %s", dir_path, exc_info=True) + finally: + try: + q.put(None) + except Exception: + logger.debug("Walk queue sentinel push failed for %s", dir_path, exc_info=True) async def scan_directory( self, @@ -144,7 +185,7 @@ class IndexScanner: files_root=str(dir_path) ) - stats = { + stats: Dict[str, Any] = { "scanned": 0, "added": 0, "updated": 0, @@ -175,49 +216,21 @@ class IndexScanner: stop_event = threading.Event() q: "Queue[Optional[Path]]" = Queue(maxsize=max(1000, int(SCAN_BATCH_XL) * 4)) - def _walk_and_put() -> None: - try: - for fp in self._iter_files(dir_path, recursive): - if stop_event.is_set(): - break - try: - q.put(fp) - except Exception: - break - except Exception: - pass - finally: - try: - q.put(None) - except Exception: - pass - - walk_future = loop.run_in_executor(_FS_WALK_EXECUTOR, _walk_and_put) - - def _get_many(max_items: int) -> list[Optional[Path]]: - items: list[Optional[Path]] = [] - try: - first = q.get() - except Exception: - return items - items.append(first) - # Drain without blocking. - try: - while len(items) < max(1, int(max_items or 1)): - try: - items.append(q.get_nowait()) - except Empty: - break - except Exception: - pass - return items + walk_future = loop.run_in_executor( + _FS_WALK_EXECUTOR, + self._walk_and_enqueue, + dir_path, + recursive, + stop_event, + q, + ) batch: List[Path] = [] done = False try: while not done: target = _stream_batch_target(stats["scanned"]) - pulled = await asyncio.to_thread(_get_many, target) + pulled = await asyncio.to_thread(self._drain_walk_queue, q, target) if not pulled: # Yield and try again. await asyncio.sleep(0) @@ -409,7 +422,7 @@ class IndexScanner: incremental=incremental ) - stats = { + stats: Dict[str, Any] = { "scanned": len(paths), "added": 0, "updated": 0, @@ -532,10 +545,10 @@ class IndexScanner: # Prefetch asset_metadata entries asset_ids = [] for fp in filepaths: - existing_state = existing_map.get(fp) - if existing_state and existing_state.get("id"): + existing_row = existing_map.get(fp) + if existing_row and existing_row.get("id"): try: - existing_id = int(existing_state.get("id") or 0) + existing_id = int(existing_row.get("id") or 0) if existing_id: asset_ids.append(existing_id) except (ValueError, TypeError): @@ -651,8 +664,8 @@ class IndexScanner: paths_to_extract = [item[0] for item in needs_metadata] batch_metadata = await self.metadata.get_metadata_batch([str(p) for p in paths_to_extract], scan_id=self._current_scan_id) - for file_path, filepath, mtime, size, state_hash, existing_id in needs_metadata: - metadata_result = batch_metadata.get(str(file_path)) + for file_path, filepath, mtime, size, state_hash, existing_id_opt in needs_metadata: + metadata_result: Result[Dict[str, Any]] | None = batch_metadata.get(str(file_path)) if not metadata_result: metadata_result = MetadataHelpers.metadata_error_payload(Result.Err("METADATA_MISSING", "No metadata returned"), filepath) elif not metadata_result.ok: @@ -660,10 +673,10 @@ class IndexScanner: cache_store = metadata_result.ok - if existing_id: + if existing_id_opt: prepared.append({ "action": "updated", - "asset_id": existing_id, + "asset_id": existing_id_opt, "metadata_result": metadata_result, "filepath": filepath, "file_path": file_path, @@ -743,6 +756,10 @@ class IndexScanner: if not asset_id or not isinstance(metadata_result, Result): stats["errors"] += 1 continue + file_path_value = entry.get("file_path") + if not isinstance(file_path_value, Path): + stats["errors"] += 1 + continue cache_store = bool(entry.get("cache_store")) if cache_store: try: @@ -756,7 +773,7 @@ class IndexScanner: pass res = await self._update_asset( int(asset_id), - entry.get("file_path"), + file_path_value, int(entry.get("mtime") or 0), int(entry.get("size") or 0), metadata_result, @@ -799,15 +816,20 @@ class IndexScanner: if not isinstance(metadata_result, Result): stats["errors"] += 1 continue + kind_value = entry.get("kind") + file_path_value = entry.get("file_path") + if not isinstance(kind_value, str) or not isinstance(file_path_value, Path): + stats["errors"] += 1 + continue cache_store = bool(entry.get("cache_store")) res = await self._add_asset( entry.get("filename") or "", entry.get("subfolder") or "", entry.get("filepath") or "", - entry.get("kind"), + cast(FileKind, kind_value), int(entry.get("mtime") or 0), int(entry.get("size") or 0), - entry.get("file_path"), + file_path_value, metadata_result, source=source, root_id=root_id, @@ -919,6 +941,10 @@ class IndexScanner: if not asset_id or not isinstance(metadata_result, Result): failed_entries.append(filepath_value) continue + file_path_value = entry.get("file_path") + if not isinstance(file_path_value, Path): + failed_entries.append(filepath_value) + continue cache_store = bool(entry.get("cache_store")) if cache_store: try: @@ -932,7 +958,7 @@ class IndexScanner: pass res = await self._update_asset( int(asset_id), - entry.get("file_path"), + file_path_value, int(entry.get("mtime") or 0), int(entry.get("size") or 0), metadata_result, @@ -969,15 +995,20 @@ class IndexScanner: if not isinstance(metadata_result, Result): failed_entries.append(filepath_value) continue + kind_value = entry.get("kind") + file_path_value = entry.get("file_path") + if not isinstance(kind_value, str) or not isinstance(file_path_value, Path): + failed_entries.append(filepath_value) + continue cache_store = bool(entry.get("cache_store")) res = await self._add_asset( entry.get("filename") or "", entry.get("subfolder") or "", entry.get("filepath") or "", - entry.get("kind"), + cast(FileKind, kind_value), int(entry.get("mtime") or 0), int(entry.get("size") or 0), - entry.get("file_path"), + file_path_value, metadata_result, source=source, root_id=root_id, @@ -1025,7 +1056,12 @@ class IndexScanner: except Exception as individual_error: # If individual processing also fails, log and keep as error failed_entries.append(filepath_value) - logger.warning("Individual processing failed for entry: %s. Error: %s", filepath_value, str(individual_error)) + logger.warning( + "Individual processing failed for entry: %s. Error: %s", + filepath_value, + str(individual_error), + exc_info=individual_error, + ) # Error count is already correct if failed_entries: sample = failed_entries[:5] @@ -1450,7 +1486,7 @@ class IndexScanner: # Compute metadata (may run tools) only when needed. if fast: - metadata_result = Result.Ok({}) + metadata_result: Result[Dict[str, Any]] = Result.Ok({}) else: metadata_result = await self.metadata.get_metadata(filepath, scan_id=self._current_scan_id) if metadata_result.ok: @@ -1521,10 +1557,11 @@ class IndexScanner: ) if refreshed: await self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) - except sqlite3.BusyError as exc: - logger.warning("Database busy while refreshing metadata (asset=%s): %s", existing_id, exc) + except sqlite3.OperationalError as exc: + if self.db._is_locked_error(exc): + logger.warning("Database busy while refreshing metadata (asset=%s): %s", existing_id, exc) raise - except sqlite3.BusyError: + except sqlite3.OperationalError: return Result.Err("DB_BUSY", "Database busy while refreshing metadata") if not tx_state or not tx_state.ok: return Result.Err("DB_ERROR", tx_state.error or "Commit failed") @@ -1554,10 +1591,11 @@ class IndexScanner: ) if result.ok: await self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) - except sqlite3.BusyError as exc: - logger.warning("Database busy while updating asset %s: %s", existing_id, exc) + except sqlite3.OperationalError as exc: + if self.db._is_locked_error(exc): + logger.warning("Database busy while updating asset %s: %s", existing_id, exc) raise - except sqlite3.BusyError: + except sqlite3.OperationalError: return Result.Err("DB_BUSY", "Database busy while updating asset") if not tx_state or not tx_state.ok: return Result.Err("DB_ERROR", tx_state.error or "Commit failed") @@ -1588,13 +1626,17 @@ class IndexScanner: ) if result.ok: await self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) - except sqlite3.BusyError as exc: - logger.warning("Database busy while inserting asset %s: %s", filepath, exc) - return Result.Err("DB_BUSY", "Database busy while inserting asset") + except sqlite3.OperationalError as exc: + if self.db._is_locked_error(exc): + logger.warning("Database busy while inserting asset %s: %s", filepath, exc) + return Result.Err("DB_BUSY", "Database busy while inserting asset") + raise if not tx_state or not tx_state.ok: return Result.Err("DB_ERROR", tx_state.error or "Commit failed") - if result.ok: - await self._write_metadata_row(result.data.get("asset_id"), metadata_result, filepath=filepath) + if result.ok and isinstance(result.data, dict): + asset_id = result.data.get("asset_id") + if asset_id is not None: + await self._write_metadata_row(int(asset_id), metadata_result, filepath=filepath) return result async def _add_asset( @@ -1649,7 +1691,7 @@ class IndexScanner: ) if not insert_result.ok: - return Result.Err("INSERT_FAILED", insert_result.error) + return Result.Err("INSERT_FAILED", insert_result.error or "Failed to insert asset") asset_id = insert_result.data if insert_result.ok else None if not asset_id: diff --git a/mjr_am_backend/features/index/searcher.py b/mjr_am_backend/features/index/searcher.py index 877ffccd..e1c3c410 100644 --- a/mjr_am_backend/features/index/searcher.py +++ b/mjr_am_backend/features/index/searcher.py @@ -89,6 +89,44 @@ def _build_filter_clauses(filters: Optional[Dict[str, Any]], alias: str = "a") - if "min_rating" in filters: clauses.append("AND COALESCE(m.rating, 0) >= ?") params.append(filters["min_rating"]) + if "min_size_bytes" in filters: + clauses.append(f"AND COALESCE({alias}.size, 0) >= ?") + params.append(int(filters["min_size_bytes"])) + if "max_size_bytes" in filters: + clauses.append(f"AND COALESCE({alias}.size, 0) <= ?") + params.append(int(filters["max_size_bytes"])) + if "min_width" in filters: + clauses.append(f"AND COALESCE({alias}.width, 0) >= ?") + params.append(int(filters["min_width"])) + if "min_height" in filters: + clauses.append(f"AND COALESCE({alias}.height, 0) >= ?") + params.append(int(filters["min_height"])) + if "workflow_type" in filters: + raw = str(filters.get("workflow_type") or "").strip().upper() + alias_map = { + "T2I": ["T2I"], + "I2I": ["I2I"], + "T2V": ["T2V"], + "I2V": ["I2V"], + "V2V": ["V2V"], + "A2A": ["A2A"], + "TTS": ["TTS", "T2A"], + "UPSCL": ["UPSCL", "UPSCALE", "UPSCALER"], + "INPT": ["INPT", "INPUT", "LOAD_INPUT"], + "FLF": ["FLF", "FIRST_LAST_FRAME", "FIRST_FRAME_LAST_FRAME"], + } + variants = alias_map.get(raw, [raw] if raw else []) + if variants: + placeholders = ", ".join("?" for _ in variants) + clauses.append( + "AND UPPER(COALESCE(" + "json_extract(m.metadata_raw, '$.workflow_type'), " + "json_extract(m.metadata_raw, '$.geninfo.engine.type'), " + "json_extract(m.metadata_raw, '$.engine.type'), " + "''" + f")) IN ({placeholders})" + ) + params.extend(variants) if "has_workflow" in filters: # Backward-compatible: older/stale rows may have has_workflow=0 even though metadata_raw # contains a workflow/prompt. Prefer not to hide such assets when filtering. @@ -117,6 +155,15 @@ def _build_filter_clauses(filters: Optional[Dict[str, Any]], alias: str = "a") - if "mtime_end" in filters: clauses.append(f"AND {alias}.mtime < ?") params.append(filters["mtime_end"]) + exclude_root = filters.get("exclude_root") + if isinstance(exclude_root, str) and exclude_root.strip(): + try: + root = str(Path(exclude_root).resolve(strict=False)) + esc = root.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + clauses.append(f"AND NOT ({alias}.filepath = ? OR {alias}.filepath LIKE ? ESCAPE '\\')") + params.extend([root, f"{esc}%"]) + except Exception: + pass return clauses, params @@ -234,8 +281,8 @@ class IndexSearcher: result = await self.db.aquery(" ".join(sql_parts), tuple(params)) if not result.ok: - return Result.Err("SEARCH_FAILED", result.error) - rows = result.data + return Result.Err("SEARCH_FAILED", result.error or "Search query failed") + rows = result.data or [] total = None if include_total: @@ -298,8 +345,8 @@ class IndexSearcher: result = await self.db.aquery(" ".join(sql_parts), tuple(params)) if not result.ok: - return Result.Err("SEARCH_FAILED", result.error) - rows = result.data + return Result.Err("SEARCH_FAILED", result.error or "Search query failed") + rows = result.data or [] total = None if include_total and rows and "_total" in rows[0]: @@ -323,11 +370,11 @@ class IndexSearcher: LEFT JOIN asset_metadata m ON a.id = m.asset_id WHERE 1=1 """ - count_params = [fts_query, fts_query] + count_params2 = [fts_query, fts_query] if filter_clauses: count_sql += " " + " ".join(filter_clauses) - count_params.extend(filter_params) - count_result = await self.db.aquery(count_sql, tuple(count_params)) + count_params2.extend(filter_params) + count_result = await self.db.aquery(count_sql, tuple(count_params2)) total = count_result.data[0]["total"] if count_result.ok and count_result.data else 0 assets = [] @@ -441,9 +488,9 @@ class IndexSearcher: sql = " ".join(sql_parts) result = await self.db.aquery(sql, tuple(params)) if not result.ok: - return Result.Err("SEARCH_FAILED", result.error) + return Result.Err("SEARCH_FAILED", result.error or "Scoped search query failed") - rows = result.data + rows = result.data or [] total = None if include_total: @@ -499,7 +546,7 @@ class IndexSearcher: """ ] - params: List[Any] = [fts_query, fts_query] + params = [fts_query, fts_query] params.extend(roots_params) filter_clauses, filter_params = _build_filter_clauses(filters) @@ -513,9 +560,9 @@ class IndexSearcher: sql = " ".join(sql_parts) result = await self.db.aquery(sql, tuple(params)) if not result.ok: - return Result.Err("SEARCH_FAILED", result.error) + return Result.Err("SEARCH_FAILED", result.error or "Scoped search query failed") - rows = result.data + rows = result.data or [] total = None if include_total: @@ -537,14 +584,14 @@ class IndexSearcher: LEFT JOIN asset_metadata m ON a.id = m.asset_id WHERE {roots_clause} """ - count_params: List[Any] = [fts_query, fts_query] - count_params.extend(roots_params) + count_params2: List[Any] = [fts_query, fts_query] + count_params2.extend(roots_params) if filter_clauses: count_sql += " " + " ".join(filter_clauses) - count_params.extend(filter_params) + count_params2.extend(filter_params) - count_result = await self.db.aquery(count_sql, tuple(count_params)) + count_result = await self.db.aquery(count_sql, tuple(count_params2)) total = count_result.data[0]["total"] if count_result.ok and count_result.data else 0 assets = [] @@ -588,7 +635,7 @@ class IndexSearcher: """ result = await self.db.aquery(sql, (resolved, f"{escaped_prefix}%")) if not result.ok: - return Result.Err("DB_ERROR", result.error) + return Result.Err("DB_ERROR", result.error or "Root lookup query failed") return Result.Ok(bool(result.data)) async def date_histogram_scoped( @@ -671,7 +718,7 @@ class IndexSearcher: sql = " ".join(sql_parts) result = await self.db.aquery(sql, tuple(params)) if not result.ok: - return Result.Err("DB_ERROR", result.error) + return Result.Err("DB_ERROR", result.error or "Histogram query failed") days: Dict[str, int] = {} for row in result.data or []: @@ -715,7 +762,7 @@ class IndexSearcher: ) if not result.ok: - return Result.Err("QUERY_FAILED", result.error) + return Result.Err("QUERY_FAILED", result.error or "Asset lookup failed") if not result.data: return Result.Ok(None) @@ -769,7 +816,7 @@ class IndexSearcher: ) if not result.ok: - return Result.Err("QUERY_FAILED", result.error) + return Result.Err("QUERY_FAILED", result.error or "Batch asset lookup failed") by_id: Dict[int, Dict[str, Any]] = {} for row in result.data or []: @@ -778,7 +825,10 @@ class IndexSearcher: except Exception: continue try: - aid = int(asset.get("id")) + raw_id = asset.get("id") + if raw_id is None: + continue + aid = int(raw_id) except Exception: continue by_id[aid] = asset @@ -852,7 +902,7 @@ class IndexSearcher: cleaned, ) if not result.ok: - return Result.Err("DB_ERROR", result.error) + return Result.Err("DB_ERROR", result.error or "Filepath lookup failed") out: Dict[str, Dict[str, Any]] = {} for row in result.data or []: diff --git a/mjr_am_backend/features/index/service.py b/mjr_am_backend/features/index/service.py index 4afd2b77..59b640e5 100644 --- a/mjr_am_backend/features/index/service.py +++ b/mjr_am_backend/features/index/service.py @@ -108,14 +108,15 @@ class IndexService: # Handle background enrichment if requested if result.ok and fast and background_metadata: - to_enrich = result.data.get("to_enrich", []) + result_data = result.data if isinstance(result.data, dict) else {} + to_enrich = result_data.get("to_enrich", []) if to_enrich: try: await self._enricher.start_enrichment(to_enrich) except Exception as exc: logger.debug("Background enrichment start skipped: %s", exc) # Remove from stats as it's internal - result.data.pop("to_enrich", None) + result_data.pop("to_enrich", None) return result diff --git a/mjr_am_backend/features/index/updater.py b/mjr_am_backend/features/index/updater.py index 3c94ead8..36f776c2 100644 --- a/mjr_am_backend/features/index/updater.py +++ b/mjr_am_backend/features/index/updater.py @@ -63,7 +63,7 @@ class AssetUpdater: ) if not result.ok: - return Result.Err("UPDATE_FAILED", result.error) + return Result.Err("UPDATE_FAILED", result.error or "Failed to update rating") return Result.Ok({"asset_id": asset_id, "rating": rating}) @@ -127,7 +127,7 @@ class AssetUpdater: ) if not result.ok: - return Result.Err("UPDATE_FAILED", result.error) + return Result.Err("UPDATE_FAILED", result.error or "Failed to update tags") return Result.Ok({"asset_id": asset_id, "tags": sanitized}) @@ -147,7 +147,7 @@ class AssetUpdater: ) if not result.ok: - return Result.Err("DB_ERROR", result.error) + return Result.Err("DB_ERROR", result.error or "Failed to read tags") # Collect all unique tags all_tags = set() diff --git a/mjr_am_backend/features/index/watcher.py b/mjr_am_backend/features/index/watcher.py index 0b86282c..001e623d 100644 --- a/mjr_am_backend/features/index/watcher.py +++ b/mjr_am_backend/features/index/watcher.py @@ -11,7 +11,7 @@ import time from collections import deque from pathlib import Path from threading import Lock -from typing import Deque, Dict, Set, Tuple, Optional, Callable, Awaitable +from typing import Deque, Dict, Set, Tuple, Optional, Callable, Awaitable, Coroutine, Any, cast from watchdog.observers import Observer from watchdog.events import ( @@ -299,7 +299,8 @@ class DebouncedWatchHandler(FileSystemEventHandler): return key = self._normalize_path(path) try: - asyncio.run_coroutine_threadsafe(self._on_files_removed([key]), self._loop) + coro = cast(Coroutine[Any, Any, None], self._on_files_removed([key])) + asyncio.run_coroutine_threadsafe(coro, self._loop) except Exception: return @@ -320,7 +321,8 @@ class DebouncedWatchHandler(FileSystemEventHandler): # If extension changed from supported->unsupported, treat as deletion. if src_ok and not dst_ok and self._on_files_removed: try: - asyncio.run_coroutine_threadsafe(self._on_files_removed([src_norm]), self._loop) + coro = cast(Coroutine[Any, Any, None], self._on_files_removed([src_norm])) + asyncio.run_coroutine_threadsafe(coro, self._loop) except Exception: pass return @@ -332,7 +334,8 @@ class DebouncedWatchHandler(FileSystemEventHandler): if self._on_files_moved: try: - asyncio.run_coroutine_threadsafe(self._on_files_moved([(src_norm, dst_norm)]), self._loop) + coro = cast(Coroutine[Any, Any, None], self._on_files_moved([(src_norm, dst_norm)])) + asyncio.run_coroutine_threadsafe(coro, self._loop) return except Exception: pass @@ -341,7 +344,8 @@ class DebouncedWatchHandler(FileSystemEventHandler): self._handle_file(dst_norm) if self._on_files_removed: try: - asyncio.run_coroutine_threadsafe(self._on_files_removed([src_norm]), self._loop) + coro = cast(Coroutine[Any, Any, None], self._on_files_removed([src_norm])) + asyncio.run_coroutine_threadsafe(coro, self._loop) except Exception: pass @@ -530,7 +534,7 @@ class OutputWatcher: self._index_callback = index_callback self._remove_callback = remove_callback self._move_callback = move_callback - self._observer: Optional[Observer] = None + self._observer: Optional[Any] = None self._handler: Optional[DebouncedWatchHandler] = None self._watched_paths: Dict[str, dict] = {} # watch_key -> {path, source, root_id, watch} self._lock = Lock() @@ -791,7 +795,7 @@ class OutputWatcher: break if to_remove: - entry = self._watched_paths.pop(to_remove, None) or {} + entry = self._watched_paths.pop(to_remove, {"path": None, "source": None, "root_id": None, "watch": None}) try: if self._observer and entry.get("watch"): self._observer.unschedule(entry.get("watch")) diff --git a/mjr_am_backend/features/metadata/extractors.py b/mjr_am_backend/features/metadata/extractors.py index 362d1624..1edba6a6 100644 --- a/mjr_am_backend/features/metadata/extractors.py +++ b/mjr_am_backend/features/metadata/extractors.py @@ -25,7 +25,7 @@ def _reconstruct_params_from_workflow(workflow: Dict[str, Any]) -> Dict[str, Any Fallback: Extract prompts and parameters directly from the Workflow nodes. Uses link traversal to distinguish Positive vs Negative prompts. """ - params = {} + params: Dict[str, Any] = {} nodes = workflow.get("nodes", []) links = workflow.get("links", []) # [[id, src_node, src_slot, tgt_node, tgt_slot, type], ...] if not nodes: @@ -183,7 +183,7 @@ def _reconstruct_params_from_workflow(workflow: Dict[str, Any]) -> Dict[str, Any if pos_info: # Use a local set for this trace to allow the same node to be visited # in the negative trace (e.g. dual-role nodes like PromptToLoras) - trace_seen = set() + trace_seen: set[Any] = set() pos_texts = find_upstream_text(pos_info[0], context="positive", seen=trace_seen) unique_prompts.update(pos_texts) visited_nodes.update(trace_seen) @@ -642,26 +642,31 @@ def _build_a1111_geninfo(parsed: Dict[str, Any]) -> Optional[Dict[str, Any]]: out["negative"] = {"value": neg.strip(), "confidence": "high", "source": "parameters"} try: - if parsed.get("steps") is not None: - out["steps"] = {"value": int(parsed.get("steps")), "confidence": "high", "source": "parameters"} + steps = parsed.get("steps") + if steps is not None: + out["steps"] = {"value": int(steps), "confidence": "high", "source": "parameters"} except Exception: pass try: - if parsed.get("cfg") is not None: - out["cfg"] = {"value": float(parsed.get("cfg")), "confidence": "high", "source": "parameters"} + cfg = parsed.get("cfg") + if cfg is not None: + out["cfg"] = {"value": float(cfg), "confidence": "high", "source": "parameters"} except Exception: pass try: - if parsed.get("seed") is not None: - out["seed"] = {"value": int(parsed.get("seed")), "confidence": "high", "source": "parameters"} + seed = parsed.get("seed") + if seed is not None: + out["seed"] = {"value": int(seed), "confidence": "high", "source": "parameters"} except Exception: pass try: - if parsed.get("width") is not None and parsed.get("height") is not None: + width = parsed.get("width") + height = parsed.get("height") + if width is not None and height is not None: out["size"] = { - "width": int(parsed.get("width")), - "height": int(parsed.get("height")), + "width": int(width), + "height": int(height), "confidence": "high", "source": "parameters", } @@ -830,7 +835,7 @@ def extract_video_metadata(file_path: str, exif_data: Optional[Dict] = None, ffp exif_data = exif_data or {} # Start with all raw data - metadata = { + metadata: Dict[str, Any] = { "raw": exif_data or {}, "raw_ffprobe": ffprobe_data or {}, "workflow": None, diff --git a/mjr_am_backend/features/metadata/fallback_readers.py b/mjr_am_backend/features/metadata/fallback_readers.py new file mode 100644 index 00000000..b0b323ba --- /dev/null +++ b/mjr_am_backend/features/metadata/fallback_readers.py @@ -0,0 +1,169 @@ +""" +Fallback metadata readers that do not depend on external binaries. + +These readers provide a best-effort degraded mode when ExifTool/ffprobe +are unavailable. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from PIL import Image, ExifTags + + +def read_image_exif_like(path: str) -> Dict[str, Any]: + """ + Build an ExifTool-like dict from Pillow data for image files. + + Returns an empty dict on failure. + """ + out: Dict[str, Any] = {} + try: + with Image.open(path) as img: + out["Composite:ImageSize"] = f"{img.width} {img.height}" + out["Image:ImageWidth"] = int(img.width) + out["Image:ImageHeight"] = int(img.height) + + info = dict(getattr(img, "info", {}) or {}) + for key, value in info.items(): + if value is None: + continue + key_s = str(key) + key_l = key_s.lower() + if key_l == "parameters": + out["PNG:Parameters"] = value + out["Parameters"] = value + elif key_l == "workflow": + out["PNG:Workflow"] = value + out["Keys:Workflow"] = value + out["comfyui:workflow"] = value + elif key_l == "prompt": + out["PNG:Prompt"] = value + out["Keys:Prompt"] = value + out["comfyui:prompt"] = value + elif key_l in ("comment", "description"): + out["EXIF:ImageDescription"] = value + out["ImageDescription"] = value + else: + out[f"Pillow:{key_s}"] = value + + try: + exif = img.getexif() + except Exception: + exif = None + if exif: + for tag_id, value in dict(exif).items(): + try: + tag_name = ExifTags.TAGS.get(tag_id, str(tag_id)) + except Exception: + tag_name = str(tag_id) + out[f"EXIF:{tag_name}"] = value + if tag_name == "ImageDescription": + out["IFD0:ImageDescription"] = value + if tag_name == "Make": + out["IFD0:Make"] = value + if tag_name == "Model": + out["IFD0:Model"] = value + except Exception: + return {} + return out + + +def read_media_probe_like(path: str) -> Dict[str, Any]: + """ + Build an ffprobe-like dict from hachoir (if installed) for audio/video files. + + Returns an empty dict when hachoir is unavailable or parsing fails. + """ + try: + from hachoir.parser import createParser + from hachoir.metadata import extractMetadata + except Exception: + return {} + + try: + parser = createParser(path) + if parser is None: + return {} + except Exception: + return {} + + format_info: Dict[str, Any] = {"tags": {}} + video_stream: Dict[str, Any] = {} + audio_stream: Dict[str, Any] = {} + + try: + with parser: + meta = extractMetadata(parser) + if meta is None: + return {} + lines = list(meta.exportPlaintext() or []) + except Exception: + return {} + + for raw_line in lines: + line = str(raw_line).strip() + if ":" not in line: + continue + key, value = line.split(":", 1) + key_l = key.strip().lower() + val = value.strip() + + if key_l == "duration": + format_info["duration"] = val + elif key_l in ("image width", "width"): + w = _to_int(val) + if w is not None: + video_stream["width"] = w + elif key_l in ("image height", "height"): + h = _to_int(val) + if h is not None: + video_stream["height"] = h + elif key_l in ("frame rate", "framerate", "fps"): + video_stream["r_frame_rate"] = val + elif key_l in ("audio codec", "codec"): + audio_stream["codec_name"] = val + elif key_l in ("sample rate", "samplerate"): + sr = _to_int(val) + audio_stream["sample_rate"] = sr if sr is not None else val + elif key_l in ("channel", "channels"): + ch = _to_int(val) + audio_stream["channels"] = ch if ch is not None else val + elif key_l in ("bit rate", "bitrate"): + br = _to_int(val) + if br is not None: + format_info["bit_rate"] = str(br) + audio_stream["bit_rate"] = str(br) + else: + if val: + format_info["tags"][key.strip()] = val + + streams = [] + if video_stream: + video_stream["codec_type"] = "video" + streams.append(video_stream) + if audio_stream: + audio_stream["codec_type"] = "audio" + streams.append(audio_stream) + + if not format_info.get("duration") and not streams and not format_info.get("tags"): + return {} + + return { + "format": format_info, + "streams": streams, + "video_stream": video_stream, + "audio_stream": audio_stream, + } + + +def _to_int(text: Any) -> Optional[int]: + s = str(text or "") + digits = "".join(ch for ch in s if ch.isdigit()) + if not digits: + return None + try: + return int(digits) + except Exception: + return None diff --git a/mjr_am_backend/features/metadata/graph_traversal.py b/mjr_am_backend/features/metadata/graph_traversal.py index 0bf1c29f..f6fd3f24 100644 --- a/mjr_am_backend/features/metadata/graph_traversal.py +++ b/mjr_am_backend/features/metadata/graph_traversal.py @@ -10,7 +10,7 @@ how deeply it is tucked in. from __future__ import annotations from collections.abc import Iterable, Mapping -from typing import Any, Dict, Iterator, Set +from typing import Any, Dict, Iterator, Set, cast def iter_nested_dicts(obj: Any, *, _seen: Set[int] | None = None) -> Iterator[Dict[str, Any]]: @@ -30,7 +30,7 @@ def iter_nested_dicts(obj: Any, *, _seen: Set[int] | None = None) -> Iterator[Di _seen.add(obj_id) if isinstance(obj, Mapping): - yield obj + yield cast(Dict[str, Any], obj) for value in obj.values(): yield from iter_nested_dicts(value, _seen=_seen) elif isinstance(obj, Iterable) and not isinstance(obj, (str, bytes, bytearray)): diff --git a/mjr_am_backend/features/metadata/service.py b/mjr_am_backend/features/metadata/service.py index 2b2ca30c..2e4bbdc6 100644 --- a/mjr_am_backend/features/metadata/service.py +++ b/mjr_am_backend/features/metadata/service.py @@ -19,6 +19,7 @@ from .extractors import ( extract_video_metadata, extract_rating_tags_from_exif, ) +from .fallback_readers import read_image_exif_like, read_media_probe_like from ..audio import extract_audio_metadata from ..geninfo.parser import parse_geninfo_from_prompt from .parsing_utils import parse_auto1111_params @@ -241,6 +242,24 @@ class MetadataService: mode = await self._resolve_probe_mode(override) return mode, pick_probe_backend(file_path, settings_override=mode) + async def _resolve_fallback_prefs(self) -> tuple[bool, bool]: + """ + Resolve runtime fallback toggles from settings. + Returns (image_fallback_enabled, media_fallback_enabled). + """ + image_enabled = True + media_enabled = True + try: + getter = getattr(self._settings, "get_metadata_fallback_prefs", None) + if callable(getter): + prefs = await getter() + if isinstance(prefs, dict): + image_enabled = bool(prefs.get("image", True)) + media_enabled = bool(prefs.get("media", True)) + except Exception: + pass + return image_enabled, media_enabled + async def get_metadata( self, file_path: str, @@ -339,6 +358,7 @@ class MetadataService: exif_result = await asyncio.to_thread(self.exiftool.read, file_path) exif_duration = time.perf_counter() - exif_start exif_data = exif_result.data if exif_result.ok else None + image_fallback_enabled, _ = await self._resolve_fallback_prefs() if not exif_result.ok: self._log_metadata_issue( logging.DEBUG, @@ -349,6 +369,11 @@ class MetadataService: error=exif_result.error, duration_seconds=exif_duration, ) + if kind == "image" and image_fallback_enabled: + exif_data = await asyncio.to_thread(read_image_exif_like, file_path) + else: + exif_data = {} + if not exif_data: return Result.Ok({"workflow": None, "prompt": None, "quality": "none"}, quality="none") if kind == "image": @@ -382,32 +407,25 @@ class MetadataService: """Extract metadata from image file.""" ext = os.path.splitext(file_path)[1].lower() - if not allow_exif: - return Result.Ok({ - "file_info": self._get_file_info(file_path), - "exif": None, - "quality": "none" - }, quality="none") - - exif_start = time.perf_counter() - exif_result = await asyncio.to_thread(self.exiftool.read, file_path) - exif_duration = time.perf_counter() - exif_start - exif_data = exif_result.data if exif_result.ok else None - if not exif_result.ok: - self._log_metadata_issue( - logging.WARNING, - "ExifTool failed while reading image metadata", - file_path, - scan_id=scan_id, - tool="exiftool", - error=exif_result.error, - duration_seconds=exif_duration - ) - return Result.Ok({ - "file_info": self._get_file_info(file_path), - "exif": None, - "quality": "none" - }, quality="none") + exif_data = None + image_fallback_enabled, _ = await self._resolve_fallback_prefs() + if allow_exif: + exif_start = time.perf_counter() + exif_result = await asyncio.to_thread(self.exiftool.read, file_path) + exif_duration = time.perf_counter() - exif_start + exif_data = exif_result.data if exif_result.ok else None + if not exif_result.ok: + self._log_metadata_issue( + logging.WARNING, + "ExifTool failed while reading image metadata", + file_path, + scan_id=scan_id, + tool="exiftool", + error=exif_result.error, + duration_seconds=exif_duration + ) + if not exif_data and image_fallback_enabled: + exif_data = await asyncio.to_thread(read_image_exif_like, file_path) # Extract based on format if ext == ".png": @@ -438,7 +456,7 @@ class MetadataService: combined = { "file_info": self._get_file_info(file_path), "exif": exif_data, - **metadata_result.data + **(metadata_result.data or {}) } # Optional: parse deterministic generation info from ComfyUI prompt graph (backend-first). @@ -456,8 +474,9 @@ class MetadataService: allow_ffprobe: bool = True, ) -> Result[Dict[str, Any]]: """Extract metadata from video file.""" - exif_data = {} + exif_data: Dict[str, Any] | None = {} exif_duration = 0.0 + _, media_fallback_enabled = await self._resolve_fallback_prefs() if allow_exif: exif_start = time.perf_counter() exif_result = await asyncio.to_thread(self.exiftool.read, file_path) @@ -477,7 +496,7 @@ class MetadataService: else: exif_data = {} - ffprobe_data = {} + ffprobe_data: Dict[str, Any] | None = {} ffprobe_duration = 0.0 if allow_ffprobe: ffprobe_start = time.perf_counter() @@ -495,7 +514,9 @@ class MetadataService: ) ffprobe_data = None else: - ffprobe_data = ffprobe_result.data + ffprobe_data = ffprobe_result.data or {} + if not ffprobe_data and media_fallback_enabled: + ffprobe_data = await asyncio.to_thread(read_media_probe_like, file_path) # Extract video-specific metadata metadata_result = extract_video_metadata(file_path, exif_data, ffprobe_data) @@ -527,7 +548,7 @@ class MetadataService: "file_info": self._get_file_info(file_path), "exif": exif_data, "ffprobe": ffprobe_data, - **metadata_result.data + **(metadata_result.data or {}) } try: @@ -547,15 +568,23 @@ class MetadataService: ) -> Result[Dict[str, Any]]: """Extract metadata from audio file.""" if not allow_exif and not allow_ffprobe: - return Result.Ok( - { - "file_info": self._get_file_info(file_path), - "exif": None, - "ffprobe": None, - "quality": "none", - }, - quality="none", + _, media_fallback_enabled = await self._resolve_fallback_prefs() + fallback_ffprobe = ( + await asyncio.to_thread(read_media_probe_like, file_path) + if media_fallback_enabled + else None ) + metadata_result = extract_audio_metadata(file_path, exif_data=None, ffprobe_data=fallback_ffprobe or None) + if not metadata_result.ok: + return metadata_result + combined = { + "file_info": self._get_file_info(file_path), + "exif": None, + "ffprobe": fallback_ffprobe or None, + **(metadata_result.data or {}), + } + quality = metadata_result.meta.get("quality", "none") if metadata_result.ok else "none" + return Result.Ok(combined, quality=quality) exif_data = None ffprobe_data = None @@ -593,6 +622,9 @@ class MetadataService: error=ffprobe_result.error, duration_seconds=ffprobe_duration, ) + _, media_fallback_enabled = await self._resolve_fallback_prefs() + if not ffprobe_data and media_fallback_enabled: + ffprobe_data = await asyncio.to_thread(read_media_probe_like, file_path) metadata_result = extract_audio_metadata(file_path, exif_data=exif_data, ffprobe_data=ffprobe_data) if not metadata_result.ok: @@ -683,6 +715,7 @@ class MetadataService: results: Dict[str, Result[Dict[str, Any]]] = {} probe_mode = await self._resolve_probe_mode(probe_mode_override) + image_fallback_enabled, media_fallback_enabled = await self._resolve_fallback_prefs() # Schedule tool targets (dedupe to avoid duplicate subprocess work). exif_targets: list[str] = [] @@ -724,6 +757,8 @@ class MetadataService: continue ext = os.path.splitext(path)[1].lower() exif_data = _exif_for(path) + if not exif_data and image_fallback_enabled: + exif_data = read_image_exif_like(path) if ext == ".png": metadata_result = extract_png_metadata(path, exif_data) @@ -740,7 +775,7 @@ class MetadataService: combined = { "file_info": self._get_file_info(path), "exif": exif_data, - **metadata_result.data, + **(metadata_result.data or {}), } await _finalize_ok(path, combined, metadata_result) else: @@ -752,6 +787,8 @@ class MetadataService: continue exif_data = _exif_for(path) ffprobe_data = _ffprobe_for(path) + if not ffprobe_data and media_fallback_enabled: + ffprobe_data = read_media_probe_like(path) metadata_result = extract_video_metadata(path, exif_data, ffprobe_data) if metadata_result.ok: @@ -759,7 +796,7 @@ class MetadataService: "file_info": self._get_file_info(path), "exif": exif_data, "ffprobe": ffprobe_data, - **metadata_result.data, + **(metadata_result.data or {}), } await _finalize_ok(path, combined, metadata_result) else: @@ -771,6 +808,8 @@ class MetadataService: continue exif_data = _exif_for(path) ffprobe_data = _ffprobe_for(path) + if not ffprobe_data and media_fallback_enabled: + ffprobe_data = read_media_probe_like(path) metadata_result = extract_audio_metadata(path, exif_data=exif_data, ffprobe_data=ffprobe_data) if metadata_result.ok: combined = { @@ -847,7 +886,7 @@ class MetadataService: error: Optional[str] = None, duration_seconds: Optional[float] = None ): - context = {"file_path": file_path} + context: Dict[str, Any] = {"file_path": file_path} if scan_id: context["scan_id"] = scan_id if tool: diff --git a/mjr_am_backend/features/tags/sync.py b/mjr_am_backend/features/tags/sync.py index 1c7873d3..11fd3588 100644 --- a/mjr_am_backend/features/tags/sync.py +++ b/mjr_am_backend/features/tags/sync.py @@ -231,13 +231,14 @@ def write_windows_rating_tags(file_path: str, rating: int, tags: List[str]) -> R original_mtime = None try: - pythoncom = None + pythoncom_mod: Any = None try: import pythoncom # type: ignore - pythoncom.CoInitialize() + pythoncom_mod = pythoncom + pythoncom_mod.CoInitialize() except Exception: - pythoncom = None + pythoncom_mod = None try: shell = win32com.Dispatch("Shell.Application") @@ -257,9 +258,9 @@ def write_windows_rating_tags(file_path: str, rating: int, tags: List[str]) -> R except Exception as exc: logger.debug("Windows shell tags write skipped: %s", exc) finally: - if pythoncom is not None: + if pythoncom_mod is not None: try: - pythoncom.CoUninitialize() + pythoncom_mod.CoUninitialize() except Exception as exc: logger.debug("pythoncom.CoUninitialize failed: %s", exc) @@ -271,7 +272,7 @@ def write_windows_rating_tags(file_path: str, rating: int, tags: List[str]) -> R return Result.Ok(True) except Exception as exc: - return Result.Err(ErrorCode.IO_ERROR, f"Windows metadata sync failed: {exc}") + return Result.Err(ErrorCode.UPDATE_FAILED, f"Windows metadata sync failed: {exc}") class RatingTagsSyncWorker: diff --git a/mjr_am_backend/features/viewer/info.py b/mjr_am_backend/features/viewer/info.py index 8a075b27..9886a535 100644 --- a/mjr_am_backend/features/viewer/info.py +++ b/mjr_am_backend/features/viewer/info.py @@ -116,7 +116,10 @@ def build_viewer_media_info(asset: Dict[str, Any], resolved_path: Optional[Path] # Valid frame count sources for key in ("nb_frames", "nb_read_frames"): try: - val = int(vs.get(key)) + raw_val = vs.get(key) + if raw_val is None: + continue + val = int(raw_val) if val > 0: frame_count = val break @@ -139,15 +142,18 @@ def build_viewer_media_info(asset: Dict[str, Any], resolved_path: Optional[Path] except Exception: info["audio_codec"] = None try: - info["sample_rate"] = int(astream.get("sample_rate")) if astream.get("sample_rate") else None + sample_rate = astream.get("sample_rate") + info["sample_rate"] = int(sample_rate) if sample_rate is not None else None except Exception: info["sample_rate"] = None try: - info["channels"] = int(astream.get("channels")) if astream.get("channels") else None + channels = astream.get("channels") + info["channels"] = int(channels) if channels is not None else None except Exception: info["channels"] = None try: - info["bitrate"] = int(astream.get("bit_rate")) if astream.get("bit_rate") else None + bit_rate = astream.get("bit_rate") + info["bitrate"] = int(bit_rate) if bit_rate is not None else None except Exception: info["bitrate"] = None diff --git a/mjr_am_backend/routes/core/__init__.py b/mjr_am_backend/routes/core/__init__.py index 49691544..463d3669 100644 --- a/mjr_am_backend/routes/core/__init__.py +++ b/mjr_am_backend/routes/core/__init__.py @@ -17,6 +17,7 @@ from .security import ( _csrf_error, _require_operation_enabled, _resolve_security_prefs, + _require_authenticated_user, ) from .security import _require_write_access from .services import _require_services, _build_services, get_services_error @@ -37,6 +38,7 @@ __all__ = [ "_csrf_error", "_require_operation_enabled", "_resolve_security_prefs", + "_require_authenticated_user", "_require_write_access", "_require_services", "_build_services", diff --git a/mjr_am_backend/routes/core/security.py b/mjr_am_backend/routes/core/security.py index a247ed49..4873fabd 100644 --- a/mjr_am_backend/routes/core/security.py +++ b/mjr_am_backend/routes/core/security.py @@ -50,7 +50,7 @@ try: except Exception: _CLIENT_ID_HASH_HEX_CHARS = _DEFAULT_CLIENT_ID_HASH_HEX_CHARS -_rate_limit_state: "OrderedDict[str, dict]" = OrderedDict() +_rate_limit_state: "OrderedDict[str, dict[str, list[float]]]" = OrderedDict() _rate_limit_lock = threading.Lock() _rate_limit_cleanup_counter = 0 @@ -355,6 +355,7 @@ def _require_write_access(request: web.Request) -> "Result[bool]": peer = "" if not peer: peer = _extract_peer_ip(request) + headers: Mapping[str, str] try: headers = request.headers # CIMultiDictProxy except Exception: @@ -362,6 +363,106 @@ def _require_write_access(request: web.Request) -> "Result[bool]": return _check_write_access(peer_ip=peer, headers=headers) +def _get_comfy_user_manager(request: web.Request | None = None) -> Any: + """ + Best-effort resolver for ComfyUI user manager object. + """ + # Prefer explicit user manager injected in app context. + if request is not None: + try: + app_mgr = request.app.get("_mjr_user_manager") + if app_mgr is not None: + return app_mgr + for key in request.app.keys(): + try: + key_name = getattr(key, "name", "") + except Exception: + key_name = "" + if key_name == "_mjr_user_manager": + cand = request.app.get(key) + if cand is not None: + return cand + except Exception: + pass + + try: + import sys + + server_mod = sys.modules.get("server") + if server_mod is None: + return None + for key in ("USER_MANAGER", "user_manager"): + mgr = getattr(server_mod, key, None) + if mgr is not None: + return mgr + except Exception: + return None + return None + + +def _comfy_auth_enabled(user_manager: Any) -> bool: + """ + Determine whether ComfyUI auth should be treated as enabled. + """ + if user_manager is None: + return False + try: + for attr in ("enabled", "is_enabled", "auth_enabled", "users_enabled"): + value = getattr(user_manager, attr, None) + if isinstance(value, bool): + return value + if callable(value): + out = value() + if isinstance(out, bool): + return out + except Exception: + pass + # If manager exists but does not expose flags, assume enabled to fail safe. + return True + + +def _resolve_request_user_id(request: web.Request) -> str: + """ + Resolve ComfyUI user id from request, if available. + """ + um = _get_comfy_user_manager(request) + if um is None: + return "" + try: + getter = getattr(um, "get_request_user_id", None) + if callable(getter): + uid = getter(request) + if uid is None: + return "" + return str(uid).strip() + except Exception: + return "" + return "" + + +def _require_authenticated_user(request: web.Request) -> "Result[str]": + """ + Require an authenticated ComfyUI user when Comfy auth is enabled. + """ + um = _get_comfy_user_manager(request) + if um is None: + # Backward compatibility: Comfy auth subsystem unavailable. + return Result.Ok("", auth_mode="unavailable") + + if not _comfy_auth_enabled(um): + return Result.Ok("", auth_mode="disabled") + + user_id = _resolve_request_user_id(request) + if user_id: + return Result.Ok(user_id, auth_mode="comfy_user") + + return Result.Err( + "AUTH_REQUIRED", + "Authentication required. Please sign in to ComfyUI first.", + auth_mode="comfy_user", + ) + + def _parse_trusted_proxies() -> list["ipaddress._BaseNetwork"]: raw = os.environ.get("MAJOOR_TRUSTED_PROXIES", _DEFAULT_TRUSTED_PROXIES) out: list["ipaddress._BaseNetwork"] = [] @@ -443,12 +544,12 @@ def _evict_oldest_clients_if_needed() -> None: except KeyError: break -def _get_or_create_client_state(client_id: str) -> dict: +def _get_or_create_client_state(client_id: str) -> dict[str, list[float]]: if client_id in _rate_limit_state: _rate_limit_state.move_to_end(client_id) return _rate_limit_state[client_id] _evict_oldest_clients_if_needed() - state = defaultdict(list) + state: dict[str, list[float]] = defaultdict(list) _rate_limit_state[client_id] = state return state diff --git a/mjr_am_backend/routes/handlers/assets.py b/mjr_am_backend/routes/handlers/assets.py index bfdbd1b0..db9f4e5c 100644 --- a/mjr_am_backend/routes/handlers/assets.py +++ b/mjr_am_backend/routes/handlers/assets.py @@ -202,12 +202,13 @@ def register_asset_routes(routes: web.RouteTableDef) -> None: if root_id: root_result = resolve_custom_root(str(root_id)) if root_result.ok: + rp2: Path | None try: - rp = Path(str(root_result.data)).resolve(strict=False) + rp2 = Path(str(root_result.data)).resolve(strict=False) except Exception: - rp = None - if rp and _is_within_root(resolved, rp): - base_dir = rp + rp2 = None + if rp2 and _is_within_root(resolved, rp2): + base_dir = rp2 resolved_root_id = str(root_id) source = "custom" diff --git a/mjr_am_backend/routes/handlers/batch_zip.py b/mjr_am_backend/routes/handlers/batch_zip.py index 0736e460..ecf8d1f4 100644 --- a/mjr_am_backend/routes/handlers/batch_zip.py +++ b/mjr_am_backend/routes/handlers/batch_zip.py @@ -101,7 +101,7 @@ def _cleanup_batch_zips() -> None: if created_at and now - created_at > _BATCH_TTL_SECONDS: stale.append(token) for token in stale: - entry = _BATCH_CACHE.pop(token, None) + entry = _BATCH_CACHE.pop(token, {}) path = entry.get("path") if isinstance(entry, dict) else None _release_token_lock(token) if isinstance(path, Path): @@ -321,9 +321,16 @@ def register_batch_zip_routes(routes: web.RouteTableDef) -> None: try: dt = time.localtime(float(getattr(st, "st_mtime", time.time()))) - date_time = tuple(int(x) for x in dt[:6]) + date_time = ( + int(dt[0]), int(dt[1]), int(dt[2]), + int(dt[3]), int(dt[4]), int(dt[5]), + ) except Exception: - date_time = time.localtime(time.time())[:6] + dt_now = time.localtime(time.time()) + date_time = ( + int(dt_now[0]), int(dt_now[1]), int(dt_now[2]), + int(dt_now[3]), int(dt_now[4]), int(dt_now[5]), + ) zi = zipfile.ZipInfo(filename=arc, date_time=date_time) zi.compress_type = zipfile.ZIP_DEFLATED diff --git a/mjr_am_backend/routes/handlers/calendar.py b/mjr_am_backend/routes/handlers/calendar.py index fb7f4162..3da30d4a 100644 --- a/mjr_am_backend/routes/handlers/calendar.py +++ b/mjr_am_backend/routes/handlers/calendar.py @@ -5,6 +5,7 @@ Used by the UI to mark days that have assets (per month). """ import datetime from pathlib import Path +from typing import Any from aiohttp import web try: @@ -60,11 +61,14 @@ def register_calendar_routes(routes: web.RouteTableDef) -> None: bounds = _month_bounds(month) if not bounds.ok: return _json_response(bounds) - month_start, month_end = bounds.data + month_data = bounds.data + if not month_data: + return _json_response(Result.Err("INVALID_INPUT", "Invalid month bounds")) + month_start, month_end = month_data scope = (request.query.get("scope") or "output").strip().lower() - filters = {} + filters: dict[str, Any] = {} if "kind" in request.query: valid_kinds = {"image", "video", "audio", "model3d"} kind = request.query["kind"].strip().lower() @@ -84,6 +88,8 @@ def register_calendar_routes(routes: web.RouteTableDef) -> None: svc, error_result = await _require_services() if error_result: return _json_response(error_result) + if not isinstance(svc, dict): + return _json_response(Result.Err("SERVICE_UNAVAILABLE", "Index service unavailable")) output_root = str(Path(get_runtime_output_root()).resolve(strict=False)) input_root = str(Path(folder_paths.get_input_directory()).resolve(strict=False)) @@ -99,6 +105,8 @@ def register_calendar_routes(routes: web.RouteTableDef) -> None: root_result = resolve_custom_root(str(root_id or "")) if not root_result.ok: return _json_response(root_result) + if not root_result.data: + return _json_response(Result.Err("NOT_FOUND", "Custom root not found")) roots = [str(Path(root_result.data).resolve(strict=False))] else: # Default: output diff --git a/mjr_am_backend/routes/handlers/db_maintenance.py b/mjr_am_backend/routes/handlers/db_maintenance.py index 9f345688..df3011b0 100644 --- a/mjr_am_backend/routes/handlers/db_maintenance.py +++ b/mjr_am_backend/routes/handlers/db_maintenance.py @@ -10,6 +10,7 @@ import shutil import sqlite3 import threading import asyncio +import os from pathlib import Path from aiohttp import web @@ -56,7 +57,7 @@ async def _restart_watcher_if_needed(svc: dict | None, should_restart: bool) -> try: from mjr_am_backend.features.index.watcher_scope import build_watch_paths scope_cfg = svc.get("watcher_scope") if isinstance(svc.get("watcher_scope"), dict) else {} - scope = (scope_cfg or {}).get("scope") + scope = str((scope_cfg or {}).get("scope") or "output") custom_root_id = (scope_cfg or {}).get("custom_root_id") paths = build_watch_paths(scope, custom_root_id) if paths: @@ -134,6 +135,71 @@ async def _replace_db_from_backup(src: Path, dst: Path) -> None: await asyncio.to_thread(shutil.copy2, src, dst) +async def _cleanup_assets_case_duplicates(db) -> Result[dict[str, int]]: + """ + Remove duplicate assets that differ only by filepath casing. + + Keeps the most recent row per normalized filepath (mtime DESC, id DESC), + then deletes other rows. Intended for Windows environments where paths are + case-insensitive. + """ + try: + rows_res = await db.aquery( + """ + SELECT id, filepath, mtime + FROM assets + WHERE filepath IS NOT NULL AND filepath != '' + ORDER BY lower(filepath), mtime DESC, id DESC + """ + ) + if not rows_res.ok: + return Result.Err("DB_ERROR", rows_res.error or "Failed to scan assets for duplicates") + rows = rows_res.data or [] + if not rows: + return Result.Ok({"groups": 0, "deleted": 0, "kept": 0}) + + keep_ids: set[int] = set() + delete_ids: list[int] = [] + groups = 0 + current_key = None + seen_in_group = 0 + + for row in rows: + try: + asset_id = int(row.get("id") or 0) + except Exception: + continue + if asset_id <= 0: + continue + key = str(row.get("filepath") or "").strip().lower() + if not key: + continue + if key != current_key: + if seen_in_group > 1: + groups += 1 + current_key = key + seen_in_group = 0 + keep_ids.add(asset_id) + seen_in_group = 1 + else: + delete_ids.append(asset_id) + seen_in_group += 1 + if seen_in_group > 1: + groups += 1 + + if not delete_ids: + return Result.Ok({"groups": 0, "deleted": 0, "kept": len(keep_ids)}) + + placeholders = ",".join("?" for _ in delete_ids) + del_res = await db.aexecute(f"DELETE FROM assets WHERE id IN ({placeholders})", tuple(delete_ids)) + if not del_res.ok: + return Result.Err("DB_ERROR", del_res.error or "Failed to delete duplicate assets") + + return Result.Ok({"groups": int(groups), "deleted": len(delete_ids), "kept": len(keep_ids)}) + except Exception as exc: + return Result.Err("DB_ERROR", safe_error_message(exc, "Failed to cleanup case duplicates")) + + def register_db_maintenance_routes(routes: web.RouteTableDef) -> None: """Register database maintenance routes.""" @routes.post("/mjr/am/db/optimize") @@ -331,6 +397,74 @@ def register_db_maintenance_routes(routes: web.RouteTableDef) -> None: pass set_db_maintenance_active(False) + @routes.post("/mjr/am/db/cleanup-case-duplicates") + async def db_cleanup_case_duplicates(request: web.Request): + """ + Cleanup historical duplicate assets caused by filepath case differences. + """ + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + svc, error_result = await _require_services() + if error_result: + return _json_response(error_result) + db = svc.get("db") if isinstance(svc, dict) else None + if not db: + return _json_response(Result.Err("SERVICE_UNAVAILABLE", "Database service unavailable")) + + # This maintenance action targets Windows case-insensitive filesystems. + if os.name != "nt": + return _json_response( + Result.Ok( + { + "ran": False, + "reason": "non_windows", + "groups": 0, + "deleted": 0, + "kept": 0, + } + ) + ) + + set_db_maintenance_active(True) + watcher_was_running = False + try: + watcher_was_running = await _stop_watcher_if_running(svc if isinstance(svc, dict) else None) + index_service = svc.get("index") if isinstance(svc, dict) else None + if index_service and hasattr(index_service, "stop_enrichment"): + try: + await index_service.stop_enrichment(clear_queue=True) + except Exception: + pass + + async with db.atransaction(mode="immediate") as tx: + if not tx.ok: + return _json_response(Result.Err("DB_ERROR", tx.error or "Failed to begin transaction")) + cleanup_res = await _cleanup_assets_case_duplicates(db) + if not cleanup_res.ok: + return _json_response(cleanup_res) + + if not tx.ok: + return _json_response(Result.Err("DB_ERROR", tx.error or "Commit failed")) + + # Best-effort maintenance after cleanup. + try: + await db.aquery("PRAGMA optimize", ()) + except Exception: + pass + + payload = {"ran": True, **(cleanup_res.data or {})} + return _json_response(Result.Ok(payload)) + except Exception as exc: + return _json_response(Result.Err("DB_ERROR", safe_error_message(exc, "Cleanup case-duplicates failed"))) + finally: + try: + await _restart_watcher_if_needed(svc if isinstance(svc, dict) else None, watcher_was_running) + except Exception: + pass + set_db_maintenance_active(False) + @routes.get("/mjr/am/db/backups") async def db_backups_list(_request: web.Request): """List archived DB backups (newest first).""" @@ -404,7 +538,7 @@ def register_db_maintenance_routes(routes: web.RouteTableDef) -> None: if use_latest: chosen = items[0] else: - chosen = next((x for x in items if str(x.get("name")) == requested_name), None) + chosen = next((x for x in items if str(x.get("name")) == requested_name), {}) if not chosen: return _json_response(Result.Err("NOT_FOUND", f"Backup not found: {requested_name}")) src = _DB_ARCHIVE_DIR / str(chosen.get("name") or "") diff --git a/mjr_am_backend/routes/handlers/health.py b/mjr_am_backend/routes/handlers/health.py index f26528f3..ab33a51c 100644 --- a/mjr_am_backend/routes/handlers/health.py +++ b/mjr_am_backend/routes/handlers/health.py @@ -248,6 +248,7 @@ def register_health_routes(routes: web.RouteTableDef) -> None: probe_mode = MEDIA_PROBE_BACKEND output_root = await _runtime_output_root(svc) + settings_service = None if svc: settings_service = svc.get("settings") if settings_service: @@ -262,6 +263,11 @@ def register_health_routes(routes: web.RouteTableDef) -> None: "output_directory": output_root, "tool_paths": get_tool_paths(), "media_probe_backend": probe_mode, + "metadata_fallback": ( + await settings_service.get_metadata_fallback_prefs() + if (svc and settings_service) + else {"image": True, "media": True} + ), })) @routes.get("/mjr/am/settings/output-directory") @@ -331,6 +337,50 @@ def register_health_routes(routes: web.RouteTableDef) -> None: return _json_response(Result.Ok({"media_probe_backend": result.data})) return _json_response(result) + @routes.get("/mjr/am/settings/metadata-fallback") + async def get_metadata_fallback_settings(request): + svc, error_result = await _require_services() + if error_result: + return _json_response(error_result) + + settings_service = svc.get("settings") + if not settings_service: + return _json_response(Result.Err("SERVICE_UNAVAILABLE", "Settings service unavailable")) + + prefs = await settings_service.get_metadata_fallback_prefs() + return _json_response(Result.Ok({"prefs": prefs})) + + @routes.post("/mjr/am/settings/metadata-fallback") + async def update_metadata_fallback_settings(request): + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err(ErrorCode.CSRF, csrf)) + + svc, error_result = await _require_services() + if error_result: + return _json_response(error_result) + + settings_service = svc.get("settings") + if not settings_service: + return _json_response(Result.Err(ErrorCode.SERVICE_UNAVAILABLE, "Settings service unavailable")) + + body_res = await _read_json(request) + if not body_res.ok: + return _json_response(body_res) + body = body_res.data or {} + + image = body.get("image", None) + media = body.get("media", None) + if image is None and media is None: + prefs = body.get("prefs") if isinstance(body.get("prefs"), dict) else {} + image = prefs.get("image", None) if isinstance(prefs, dict) else None + media = prefs.get("media", None) if isinstance(prefs, dict) else None + + result = await settings_service.set_metadata_fallback_prefs(image=image, media=media) + if not result.ok: + return _json_response(result) + return _json_response(Result.Ok({"prefs": result.data or {}})) + @routes.get("/mjr/am/settings/security") async def get_security_settings(request): svc, error_result = await _require_services() diff --git a/mjr_am_backend/routes/handlers/releases.py b/mjr_am_backend/routes/handlers/releases.py index d1e1af0c..6f6d2d53 100644 --- a/mjr_am_backend/routes/handlers/releases.py +++ b/mjr_am_backend/routes/handlers/releases.py @@ -16,7 +16,7 @@ import os import asyncio from typing import Any -from aiohttp import web, ClientSession +from aiohttp import web, ClientSession, ClientTimeout from mjr_am_backend.shared import Result, get_logger, sanitize_error_message from ..core import _json_response @@ -45,7 +45,7 @@ def register_releases_routes(routes: web.RouteTableDef) -> None: try: async with ClientSession() as session: async def fetch(url: str) -> Any: - async with session.get(url, headers=headers, timeout=30) as resp: + async with session.get(url, headers=headers, timeout=ClientTimeout(total=30)) as resp: if resp.status != 200: text = await resp.text() raise RuntimeError(f"GitHub API returned {resp.status}: {text}") diff --git a/mjr_am_backend/routes/handlers/scan.py b/mjr_am_backend/routes/handlers/scan.py index 42af4680..254d224f 100644 --- a/mjr_am_backend/routes/handlers/scan.py +++ b/mjr_am_backend/routes/handlers/scan.py @@ -1247,12 +1247,12 @@ def register_scan_routes(routes: web.RouteTableDef) -> None: if clear_scan_journal: res = await _clear_table("scan_journal", cache_prefixes) if not res.ok: - return res + return Result.Err(res.code, res.error or "Failed to clear scan_journal") cleared["scan_journal"] = int(res.data or 0) if clear_metadata_cache: res = await _clear_table("metadata_cache", cache_prefixes) if not res.ok: - return res + return Result.Err(res.code, res.error or "Failed to clear metadata_cache") cleared["metadata_cache"] = int(res.data or 0) # IMPORTANT: scope-aware clears. @@ -1260,12 +1260,12 @@ def register_scan_routes(routes: web.RouteTableDef) -> None: if clear_asset_metadata: res = await _clear_asset_metadata(cache_prefixes) if not res.ok: - return res + return Result.Err(res.code, res.error or "Failed to clear asset_metadata") cleared["asset_metadata"] = int(res.data or 0) if clear_assets_table: res = await _clear_assets(cache_prefixes) if not res.ok: - return res + return Result.Err(res.code, res.error or "Failed to clear assets") cleared["assets"] = int(res.data or 0) if not tx.ok: return Result.Err("DB_ERROR", tx.error or "Commit failed") diff --git a/mjr_am_backend/routes/handlers/search.py b/mjr_am_backend/routes/handlers/search.py index 17423d28..fde7e75e 100644 --- a/mjr_am_backend/routes/handlers/search.py +++ b/mjr_am_backend/routes/handlers/search.py @@ -4,6 +4,7 @@ Search and list endpoints. import datetime import asyncio import re +import os from pathlib import Path from aiohttp import web @@ -47,6 +48,48 @@ AUTOCOMPLETE_RATE_LIMIT_WINDOW_SECONDS = 60 logger = get_logger(__name__) +def _asset_dedupe_key(asset: dict) -> str: + fp = str((asset or {}).get("filepath") or "").strip() + if not fp: + return "" + # Windows paths are case-insensitive: normalize casing/slashes for stable dedupe. + if os.name == "nt": + try: + return os.path.normcase(os.path.normpath(fp)) + except Exception: + return fp.lower() + return fp + + +def _dedupe_assets_by_filepath(assets: list[dict]) -> list[dict]: + out: list[dict] = [] + seen: set[str] = set() + for asset in assets or []: + if not isinstance(asset, dict): + continue + key = _asset_dedupe_key(asset) + if key: + if key in seen: + continue + seen.add(key) + out.append(asset) + return out + + +def _dedupe_result_assets_payload(payload: dict | None) -> dict: + data = dict(payload or {}) + assets = data.get("assets") + if isinstance(assets, list): + deduped = _dedupe_assets_by_filepath(assets) + data["assets"] = deduped + # Keep total consistent with visible results for stable pagination UI. + try: + data["total"] = len(deduped) if int(data.get("total") or 0) < len(deduped) else int(data.get("total") or 0) + except Exception: + data["total"] = len(deduped) + return data + + async def _runtime_output_root(svc: dict | None) -> str: try: settings_service = (svc or {}).get("settings") if isinstance(svc, dict) else None @@ -68,6 +111,28 @@ def _touch_enrichment_pause(services: dict | None, seconds: float = 1.5) -> None pass +def _is_under_root(root: str, fp: str) -> bool: + try: + root_p = Path(str(root)).resolve() + fp_p = Path(str(fp)).resolve() + common = os.path.commonpath([str(fp_p), str(root_p)]) + return Path(common).resolve() == root_p + except Exception: + return False + + +def _exclude_assets_under_root(assets: list[dict], root: str) -> list[dict]: + out: list[dict] = [] + for a in assets or []: + if not isinstance(a, dict): + continue + fp = str((a or {}).get("filepath") or "") + if fp and _is_under_root(root, fp): + continue + out.append(a) + return out + + def _date_bounds_for_range(range_name, reference=None): if not range_name: return (None, None) @@ -273,6 +338,38 @@ def register_search_routes(routes: web.RouteTableDef) -> None: filters["min_rating"] = max(0, min(MAX_RATING, int(request.query["min_rating"]))) except ValueError: return _json_response(Result.Err("INVALID_INPUT", "Invalid min_rating")) + if "min_size_mb" in request.query: + try: + val = float(request.query["min_size_mb"]) + if val > 0: + filters["min_size_bytes"] = int(val * 1024 * 1024) + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid min_size_mb")) + if "max_size_mb" in request.query: + try: + val = float(request.query["max_size_mb"]) + if val > 0: + filters["max_size_bytes"] = int(val * 1024 * 1024) + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid max_size_mb")) + if "min_width" in request.query: + try: + val = int(request.query["min_width"]) + if val > 0: + filters["min_width"] = val + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid min_width")) + if "min_height" in request.query: + try: + val = int(request.query["min_height"]) + if val > 0: + filters["min_height"] = val + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid min_height")) + if "workflow_type" in request.query: + wf_type = str(request.query["workflow_type"] or "").strip().upper() + if wf_type: + filters["workflow_type"] = wf_type if "has_workflow" in request.query: filters["has_workflow"] = request.query["has_workflow"].lower() in ("true", "1", "yes") @@ -332,6 +429,7 @@ def register_search_routes(routes: web.RouteTableDef) -> None: # Add scope info to response db_result.data["scope"] = "input" + db_result.data = _dedupe_result_assets_payload(db_result.data) return _json_response(db_result) # Fallback to filesystem if DB approach fails or is unavailable @@ -349,6 +447,8 @@ def register_search_routes(routes: web.RouteTableDef) -> None: index_service=(svc or {}).get("index") if isinstance(svc, dict) else None, sort=sort_key, ) + if result.ok and isinstance(result.data, dict): + result.data = _dedupe_result_assets_payload(result.data) return _json_response(result) if scope == "custom": @@ -389,6 +489,7 @@ def register_search_routes(routes: web.RouteTableDef) -> None: # Add scope info to response db_result.data["scope"] = "custom" + db_result.data = _dedupe_result_assets_payload(db_result.data) return _json_response(db_result) # Fallback to filesystem if DB approach fails or is unavailable @@ -407,6 +508,8 @@ def register_search_routes(routes: web.RouteTableDef) -> None: index_service=(svc or {}).get("index") if isinstance(svc, dict) else None, sort=sort_key, ) + if result.ok and isinstance(result.data, dict): + result.data = _dedupe_result_assets_payload(result.data) return _json_response(result) svc, error_result = await _require_services() @@ -472,19 +575,18 @@ def register_search_routes(routes: web.RouteTableDef) -> None: else: a["type"] = "output" - return _json_response( - Result.Ok( - { - "assets": assets, - "total": int(data.get("total") or 0), - "limit": limit, - "offset": offset, - "query": query, - "scope": scope, - "sort": sort_key, - } - ) + payload = _dedupe_result_assets_payload( + { + "assets": assets, + "total": int(data.get("total") or 0), + "limit": limit, + "offset": offset, + "query": query, + "scope": scope, + "sort": sort_key, + } ) + return _json_response(Result.Ok(payload)) if not limit: # Keep behavior predictable when limit==0: return empty page but valid total. @@ -531,7 +633,8 @@ def register_search_routes(routes: web.RouteTableDef) -> None: if not in_total_res.ok: return _json_response(in_total_res) total = out_total + int((in_total_res.data or {}).get("total") or 0) - return _json_response(Result.Ok({"assets": out_assets, "total": total, "limit": limit, "offset": offset, "query": query, "scope": scope, "sort": sort_key})) + payload = _dedupe_result_assets_payload({"assets": out_assets, "total": total, "limit": limit, "offset": offset, "query": query, "scope": scope, "sort": sort_key}) + return _json_response(Result.Ok(payload)) # Need to spill over into inputs (or we're already past outputs). need = max(0, limit - len(out_assets)) @@ -543,7 +646,8 @@ def register_search_routes(routes: web.RouteTableDef) -> None: in_assets = in_data.get("assets") or [] total = out_total + int(in_data.get("total") or 0) assets = [*out_assets, *in_assets] - return _json_response(Result.Ok({"assets": assets, "total": total, "limit": limit, "offset": offset, "query": query, "scope": scope, "sort": sort_key})) + payload = _dedupe_result_assets_payload({"assets": assets, "total": total, "limit": limit, "offset": offset, "query": query, "scope": scope, "sort": sort_key}) + return _json_response(Result.Ok(payload)) # Browse-all (`*`): merge by mtime for better UX, with chunked streaming merge. chunk = max(SEARCH_CHUNK_MIN, min(SEARCH_CHUNK_MAX, int(limit) * 2)) @@ -663,18 +767,20 @@ def register_search_routes(routes: web.RouteTableDef) -> None: page = sorted(page, key=lambda a: str((a or {}).get("filename") or "").lower(), reverse=reverse) elif sort_key == "mtime_asc": page = sorted(page, key=lambda a: int((a or {}).get("mtime") or 0)) - return _json_response(Result.Ok({"assets": page, "total": total, "limit": limit, "offset": offset, "query": query, "scope": scope, "sort": sort_key})) + payload = _dedupe_result_assets_payload({"assets": page, "total": total, "limit": limit, "offset": offset, "query": query, "scope": scope, "sort": sort_key}) + return _json_response(Result.Ok(payload)) if scope not in ("output", "outputs"): return _json_response(Result.Err("INVALID_INPUT", f"Unknown scope: {scope}")) # Use DB search for output scope + output_filters = {**(filters or {}), "source": "output", "exclude_root": input_root} out_res = await svc["index"].search_scoped( query, roots=[output_root], limit=limit, offset=offset, - filters={**(filters or {}), "source": "output"}, + filters=output_filters, include_total=include_total, sort=sort_key, ) @@ -704,20 +810,36 @@ def register_search_routes(routes: web.RouteTableDef) -> None: sort=sort_key, ) if fs_res.ok and isinstance(fs_res.data, dict): - for a in fs_res.data.get("assets") or []: + filtered_assets = _exclude_assets_under_root(fs_res.data.get("assets") or [], input_root) + fs_res.data["assets"] = filtered_assets + for a in filtered_assets: if isinstance(a, dict): a["type"] = "output" fs_res.data["scope"] = "output" fs_res.data["mode"] = "filesystem" + try: + if "total" in fs_res.data: + fs_res.data["total"] = len(filtered_assets) + except Exception: + pass + fs_res.data = _dedupe_result_assets_payload(fs_res.data) return _json_response(fs_res) except Exception: pass if not out_res.ok: return _json_response(out_res) - for a in out_res.data.get("assets") or []: + filtered_assets = _exclude_assets_under_root((out_res.data or {}).get("assets") or [], input_root) + out_res.data["assets"] = filtered_assets + try: + if "total" in out_res.data: + out_res.data["total"] = len(filtered_assets) + except Exception: + pass + for a in filtered_assets: a["type"] = "output" - return _json_response(Result.Ok({**out_res.data, "scope": "output", "sort": sort_key})) + payload = _dedupe_result_assets_payload({**out_res.data, "scope": "output", "sort": sort_key}) + return _json_response(Result.Ok(payload)) @routes.get("/mjr/am/search") async def search_assets(request): @@ -773,6 +895,38 @@ def register_search_routes(routes: web.RouteTableDef) -> None: except ValueError: result = Result.Err("INVALID_INPUT", "Invalid min_rating") return _json_response(result) + if "min_size_mb" in request.query: + try: + val = float(request.query["min_size_mb"]) + if val > 0: + filters["min_size_bytes"] = int(val * 1024 * 1024) + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid min_size_mb")) + if "max_size_mb" in request.query: + try: + val = float(request.query["max_size_mb"]) + if val > 0: + filters["max_size_bytes"] = int(val * 1024 * 1024) + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid max_size_mb")) + if "min_width" in request.query: + try: + val = int(request.query["min_width"]) + if val > 0: + filters["min_width"] = val + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid min_width")) + if "min_height" in request.query: + try: + val = int(request.query["min_height"]) + if val > 0: + filters["min_height"] = val + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid min_height")) + if "workflow_type" in request.query: + wf_type = str(request.query["workflow_type"] or "").strip().upper() + if wf_type: + filters["workflow_type"] = wf_type if "has_workflow" in request.query: filters["has_workflow"] = request.query["has_workflow"].lower() in ("true", "1", "yes") @@ -787,6 +941,8 @@ def register_search_routes(routes: web.RouteTableDef) -> None: filters if filters else None, include_total=include_total, ) + if result.ok and isinstance(result.data, dict): + result.data = _dedupe_result_assets_payload(result.data) return _json_response(result) @routes.post("/mjr/am/assets/batch") diff --git a/mjr_am_backend/routes/handlers/viewer.py b/mjr_am_backend/routes/handlers/viewer.py index ac7aff54..385a9d1f 100644 --- a/mjr_am_backend/routes/handlers/viewer.py +++ b/mjr_am_backend/routes/handlers/viewer.py @@ -51,6 +51,8 @@ def register_viewer_routes(routes: web.RouteTableDef) -> None: svc, error_result = await _require_services() if error_result: return _json_response(error_result) + if not isinstance(svc, dict): + return _json_response(Result.Err("SERVICE_UNAVAILABLE", "Index service unavailable")) try: asset_res = await svc["index"].get_asset(asset_id) diff --git a/mjr_am_backend/routes/registry.py b/mjr_am_backend/routes/registry.py index caba3172..419457c3 100644 --- a/mjr_am_backend/routes/registry.py +++ b/mjr_am_backend/routes/registry.py @@ -8,44 +8,64 @@ from __future__ import annotations from aiohttp import web from mjr_am_backend.shared import get_logger from mjr_am_backend.observability import ensure_observability +from .core import _json_response, _require_authenticated_user +from mjr_am_backend.shared import Result from typing import Awaitable, Callable, ClassVar, Protocol, cast # --- CONFIGURATION --- API_PREFIX = "/mjr/am/" +_SENSITIVE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} +_APP_KEY_SECURITY_MIDDLEWARES_INSTALLED: web.AppKey[bool] = web.AppKey( + "_mjr_security_middlewares_installed", bool +) +_APP_KEY_ROUTES_REGISTERED: web.AppKey[bool] = web.AppKey("_mjr_routes_registered", bool) +_APP_KEY_OBSERVABILITY_INSTALLED: web.AppKey[bool] = web.AppKey("_mjr_observability_installed", bool) +_APP_KEY_USER_MANAGER: web.AppKey[object] = web.AppKey("_mjr_user_manager", object) class _PromptServerInstance(Protocol): routes: web.RouteTableDef app: web.Application | None + def send_sync(self, event: str, data: object) -> None: ... class _PromptServer(Protocol): instance: ClassVar[_PromptServerInstance] -try: +class _PromptServerInstanceStub: + routes: web.RouteTableDef = web.RouteTableDef() + app: web.Application | None = None + def send_sync(self, event: str, data: object) -> None: + return None + + +class _PromptServerStub: + instance: ClassVar[_PromptServerInstanceStub] = _PromptServerInstanceStub() + + +def _get_prompt_server() -> type[_PromptServer]: # IMPORTANT (ComfyUI compatibility): - # Never `import server` here. Importing ComfyUI's `server.py` triggers a cascade - # (nodes -> torch -> cuda init) which can hard-crash the interpreter in non-ComfyUI - # contexts (unit tests, docs scripts, standalone runs) or on misconfigured CUDA. - # - # In the real ComfyUI runtime, `server` is already imported and lives in - # `sys.modules`, so we can safely reuse it without re-importing. + # Never `import server` here. Importing ComfyUI's `server.py` can trigger heavy + # initialization cascades in non-Comfy contexts. import sys - _server_mod = sys.modules.get("server") - if _server_mod is None or not hasattr(_server_mod, "PromptServer"): - raise ImportError("ComfyUI server not loaded") + try: + _server_mod = sys.modules.get("server") + if _server_mod is None or not hasattr(_server_mod, "PromptServer"): + raise ImportError("ComfyUI server not loaded") + return cast(type[_PromptServer], getattr(_server_mod, "PromptServer")) + except Exception: + return cast(type[_PromptServer], _PromptServerStub) - PromptServer = cast(type[_PromptServer], getattr(_server_mod, "PromptServer")) -except Exception: - class _PromptServerInstanceStub: - routes: web.RouteTableDef = web.RouteTableDef() - app: web.Application | None = None - class _PromptServerStub: - instance: ClassVar[_PromptServerInstanceStub] = _PromptServerInstanceStub() +class _PromptServerProxy: + @property + def instance(self) -> _PromptServerInstance: + return _get_prompt_server().instance - PromptServer = cast(type[_PromptServer], _PromptServerStub) + +# Public compatibility symbol used by several modules. +PromptServer = _PromptServerProxy() from .handlers import ( register_health_routes, @@ -69,6 +89,48 @@ logger = get_logger(__name__) _ROUTES_REGISTERED = False +def _extract_app_paths(app: web.Application) -> set[str]: + paths: set[str] = set() + try: + for route in app.router.routes(): + try: + resource = getattr(route, "resource", None) + canonical = getattr(resource, "canonical", None) + if isinstance(canonical, str) and canonical: + paths.add(canonical) + except Exception: + continue + except Exception: + pass + return paths + + +def _extract_table_paths(routes: web.RouteTableDef) -> set[str]: + paths: set[str] = set() + try: + for item in routes: + path = getattr(item, "path", None) + if isinstance(path, str) and path: + paths.add(path) + except Exception: + pass + return paths + + +def _log_route_collisions(app: web.Application, routes: web.RouteTableDef) -> None: + try: + app_paths = _extract_app_paths(app) + table_paths = _extract_table_paths(routes) + overlaps = sorted(app_paths.intersection(table_paths)) + if overlaps: + logger.warning( + "Potential route path collisions detected before registration: %s", + ", ".join(overlaps[:20]), + ) + except Exception: + pass + + @web.middleware async def security_headers_middleware( request: web.Request, @@ -133,13 +195,47 @@ async def api_versioning_middleware( return await handler(request) +@web.middleware +async def auth_required_middleware( + request: web.Request, + handler: Callable[[web.Request], Awaitable[web.StreamResponse]], +) -> web.StreamResponse: + """ + Require authenticated ComfyUI user for sensitive Majoor routes when auth is enabled. + """ + try: + path = request.path or "" + method = str(request.method or "GET").upper() + except Exception: + path = "" + method = "GET" + + if path.startswith(API_PREFIX) and method in _SENSITIVE_METHODS: + auth = _require_authenticated_user(request) + if not auth.ok: + return _json_response( + Result.Err( + auth.code or "AUTH_REQUIRED", + auth.error or "Authentication required. Please sign in first.", + ), + status=401, + ) + try: + request["mjr_user_id"] = str(auth.data or "").strip() + except Exception: + pass + + return await handler(request) + + def _install_security_middlewares(app: web.Application) -> None: try: - if app.get("_mjr_security_middlewares_installed"): + if app.get(_APP_KEY_SECURITY_MIDDLEWARES_INSTALLED): return + app.middlewares.insert(0, auth_required_middleware) app.middlewares.insert(0, security_headers_middleware) app.middlewares.insert(0, api_versioning_middleware) - app["_mjr_security_middlewares_installed"] = True + app[_APP_KEY_SECURITY_MIDDLEWARES_INSTALLED] = True except Exception as exc: logger.debug("Failed to install security middlewares: %s", exc) @@ -150,7 +246,7 @@ def register_all_routes() -> web.RouteTableDef: This is the central registration point for all routes. """ global _ROUTES_REGISTERED - routes = PromptServer.instance.routes + routes = _get_prompt_server().instance.routes if _ROUTES_REGISTERED: logger.debug("register_all_routes() skipped: already registered") return routes @@ -211,6 +307,7 @@ def register_all_routes() -> web.RouteTableDef: logger.info(" GET /mjr/am/batch-zip/{token}") logger.info(" GET /mjr/am/viewer/info?asset_id=") logger.info(" POST /mjr/am/db/optimize") + logger.info(" POST /mjr/am/db/cleanup-case-duplicates") logger.info(" POST /mjr/am/db/force-delete") logger.info(" GET /mjr/am/download") logger.info(" GET /mjr/am/releases") @@ -221,13 +318,25 @@ def register_all_routes() -> web.RouteTableDef: return routes -def register_routes(app: web.Application) -> None: +def register_routes(app: web.Application, user_manager=None) -> None: """ Register routes onto an aiohttp application. This is primarily used for unit tests; in ComfyUI, routes are registered via PromptServer decorators at import time. """ + try: + if user_manager is not None: + try: + app[_APP_KEY_USER_MANAGER] = user_manager + except Exception: + pass + # Ensure decorators are registered exactly once globally. + register_all_routes() + except Exception as exc: + logger.warning("Failed to prepare route table: %s", exc) + return + try: ensure_observability(app) except Exception as exc: @@ -236,8 +345,22 @@ def register_routes(app: web.Application) -> None: _install_security_middlewares(app) except Exception as exc: logger.debug("Security middlewares not installed on aiohttp app: %s", exc) + try: - app.add_routes(PromptServer.instance.routes) + if app.get(_APP_KEY_ROUTES_REGISTERED): + logger.debug("register_routes(app) skipped: routes already registered on this app") + return + except Exception: + pass + + try: + route_table = _get_prompt_server().instance.routes + _log_route_collisions(app, route_table) + app.add_routes(route_table) + try: + app[_APP_KEY_ROUTES_REGISTERED] = True + except Exception: + pass except Exception as exc: logger.warning("Failed to register routes on aiohttp app: %s", exc) @@ -248,11 +371,11 @@ def _install_observability_on_prompt_server() -> None: If PromptServer doesn't expose `app`, this safely no-ops. """ try: - app = getattr(PromptServer.instance, "app", None) + app = getattr(_get_prompt_server().instance, "app", None) if app is not None: - if not app.get("_mjr_observability_installed"): + if not app.get(_APP_KEY_OBSERVABILITY_INSTALLED): ensure_observability(app) - app["_mjr_observability_installed"] = True + app[_APP_KEY_OBSERVABILITY_INSTALLED] = True _install_security_middlewares(app) except Exception as exc: logger.debug("Observability not installed on PromptServer app: %s", exc) diff --git a/mjr_am_backend/settings.py b/mjr_am_backend/settings.py index cc816214..2365fb49 100644 --- a/mjr_am_backend/settings.py +++ b/mjr_am_backend/settings.py @@ -16,6 +16,8 @@ logger = get_logger(__name__) _PROBE_BACKEND_KEY = "media_probe_backend" _OUTPUT_DIRECTORY_KEY = "output_directory_override" +_METADATA_FALLBACK_IMAGE_KEY = "metadata_fallback_image" +_METADATA_FALLBACK_MEDIA_KEY = "metadata_fallback_media" _SETTINGS_VERSION_KEY = "__settings_version" _VALID_PROBE_MODES = {"auto", "exiftool", "ffprobe", "both"} _SECURITY_PREFS_INFO: Mapping[str, dict[str, bool | str]] = { @@ -60,6 +62,8 @@ class AppSettings: self._version_cached: int = 0 self._version_cached_at: float = 0.0 self._default_probe_mode = MEDIA_PROBE_BACKEND + self._default_metadata_fallback_image = True + self._default_metadata_fallback_media = True async def _read_setting(self, key: str) -> Optional[str]: result = await self._db.aquery("SELECT value FROM metadata WHERE key = ?", (key,)) @@ -206,6 +210,86 @@ class AppSettings: return Result.Ok(normalized) return Result.Err("DB_ERROR", result.error or "Failed to persist probe backend") + async def get_metadata_fallback_prefs(self) -> dict[str, bool]: + """ + Return fallback preferences for metadata extraction. + + - image: Pillow-based fallback for image metadata + - media: hachoir-based fallback for audio/video metadata + """ + async with self._lock: + current_version = await self._get_settings_version() + out: dict[str, bool] = {} + defaults = { + "image": self._default_metadata_fallback_image, + "media": self._default_metadata_fallback_media, + } + key_map = { + "image": _METADATA_FALLBACK_IMAGE_KEY, + "media": _METADATA_FALLBACK_MEDIA_KEY, + } + for logical_key, storage_key in key_map.items(): + cached = self._cache.get(storage_key) + if cached is not None: + try: + ts = float(self._cache_at.get(storage_key) or 0.0) + except Exception: + ts = 0.0 + cached_ver = int(self._cache_version.get(storage_key) or 0) + if cached_ver == int(current_version or 0) and ts and (time.monotonic() - ts) < self._cache_ttl_s: + out[logical_key] = parse_bool(cached, defaults[logical_key]) + continue + raw = await self._read_setting(storage_key) + parsed = parse_bool(raw, defaults[logical_key]) if raw is not None else defaults[logical_key] + out[logical_key] = parsed + self._cache[storage_key] = "1" if parsed else "0" + self._cache_at[storage_key] = time.monotonic() + self._cache_version[storage_key] = int(current_version or 0) + return out + + async def set_metadata_fallback_prefs( + self, + *, + image: Any = None, + media: Any = None, + ) -> Result[dict[str, bool]]: + """ + Persist metadata fallback preferences and bump settings version. + """ + to_write: dict[str, bool] = {} + if image is not None: + to_write[_METADATA_FALLBACK_IMAGE_KEY] = parse_bool(image, self._default_metadata_fallback_image) + if media is not None: + to_write[_METADATA_FALLBACK_MEDIA_KEY] = parse_bool(media, self._default_metadata_fallback_media) + if not to_write: + return Result.Err("INVALID_INPUT", "No metadata fallback settings provided") + + async with self._lock: + for key, value in to_write.items(): + res = await self._write_setting(key, "1" if value else "0") + if not res.ok: + return Result.Err("DB_ERROR", res.error or f"Failed to persist {key}") + + bump = await self._bump_settings_version_locked() + if not bump.ok: + try: + logger.warning("Failed to bump settings version: %s", bump.error) + except Exception: + pass + + current_version = int(bump.data or await self._get_settings_version() or 0) + for key, value in to_write.items(): + self._cache[key] = "1" if value else "0" + self._cache_at[key] = time.monotonic() + self._cache_version[key] = current_version + + return Result.Ok( + { + "image": parse_bool(self._cache.get(_METADATA_FALLBACK_IMAGE_KEY), self._default_metadata_fallback_image), + "media": parse_bool(self._cache.get(_METADATA_FALLBACK_MEDIA_KEY), self._default_metadata_fallback_media), + } + ) + async def get_output_directory(self) -> str | None: """Return persisted output directory override, or None when unset.""" async with self._lock: @@ -213,7 +297,6 @@ class AppSettings: if not raw: return None try: - import os from pathlib import Path return str(Path(raw).expanduser().resolve()) except Exception: diff --git a/mjr_am_backend/tool_detect.py b/mjr_am_backend/tool_detect.py index 124cee65..0504f9a1 100644 --- a/mjr_am_backend/tool_detect.py +++ b/mjr_am_backend/tool_detect.py @@ -88,9 +88,10 @@ def has_exiftool() -> bool: return _TOOL_CACHE["exiftool"] try: - if shutil.which(EXIFTOOL_BIN) is None: - logger.debug("ExifTool binary not found in PATH: %s", EXIFTOOL_BIN) - result = _run_command([EXIFTOOL_BIN, "-ver"]) + exiftool_bin = EXIFTOOL_BIN or "exiftool" + if shutil.which(exiftool_bin) is None: + logger.debug("ExifTool binary not found in PATH: %s", exiftool_bin) + result = _run_command([exiftool_bin, "-ver"]) available = result.returncode == 0 _TOOL_CACHE["exiftool"] = available @@ -121,9 +122,10 @@ def has_ffprobe() -> bool: return _TOOL_CACHE["ffprobe"] try: - if shutil.which(FFPROBE_BIN) is None: - logger.debug("FFprobe binary not found in PATH: %s", FFPROBE_BIN) - result = _run_command([FFPROBE_BIN, "-version"]) + ffprobe_bin = FFPROBE_BIN or "ffprobe" + if shutil.which(ffprobe_bin) is None: + logger.debug("FFprobe binary not found in PATH: %s", ffprobe_bin) + result = _run_command([ffprobe_bin, "-version"]) available = result.returncode == 0 _TOOL_CACHE["ffprobe"] = available diff --git a/mypy.ini b/mypy.ini index 071e253f..9c2e5e88 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,6 @@ [mypy] python_version = 3.12 -files = shared +files = mjr_am_backend,mjr_am_shared # Repository root has a hyphen in its folder name; require explicit bases. explicit_package_bases = True diff --git a/pyproject.toml b/pyproject.toml index 73a8802d..6f1cbec6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "aiohttp>=3.8.0", "aiosqlite>=0.20.0", "pillow>=11.0.0", + "hachoir>=3.3.0", "send2trash>=1.8.0", "pywin32>=300; sys_platform == 'win32'", # Note: tkinter is required for native folder browser functionality @@ -46,6 +47,20 @@ PublisherId = "majoor-waldi" DisplayName = "ComfyUI-Majoor-AssetsManager" Icon = "https://raw.githubusercontent.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/main/ressources/icon.png" includes = [] -# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility +"requires-comfyui" = ">=0.13.0" + +[tool.black] +line-length = 100 +target-version = ["py310", "py311", "py312", "py313"] +include = '\.pyi?$' + +[tool.ruff] +line-length = 100 +target-version = "py310" +src = ["mjr_am_backend", "mjr_am_shared", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["E501"] diff --git a/requirements.txt b/requirements.txt index e3964564..703832d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,9 @@ aiosqlite>=0.20.0 # Updated for security patches (CVE-2023-50447, CVE-2024-28219) pillow>=11.0.0 +# Pure-Python media metadata parser fallback (audio/video) when ffprobe is missing +hachoir>=3.3.0 + # Safe file deletion (recycle bin support) send2trash>=1.8.0 diff --git a/shared/__init__.py b/shared/__init__.py deleted file mode 100644 index 64bdca96..00000000 --- a/shared/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Compatibility shim: expose `shared` as alias of `mjr_am_shared`.""" -from __future__ import annotations - -import importlib - -_impl = importlib.import_module("mjr_am_shared") - -for _k in dir(_impl): - if _k.startswith("__") and _k not in {"__all__", "__doc__", "__path__", "__spec__"}: - continue - globals()[_k] = getattr(_impl, _k) - -try: - __path__ = _impl.__path__ # type: ignore[attr-defined] -except Exception: - pass - -__all__ = getattr(_impl, "__all__", []) diff --git a/shared/scan_throttle.py b/shared/scan_throttle.py deleted file mode 100644 index 21c45e3b..00000000 --- a/shared/scan_throttle.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Compatibility shim module: shared.scan_throttle -> mjr_am_shared.scan_throttle""" -from mjr_am_shared.scan_throttle import * # noqa: F401,F403 diff --git a/shared/types.py b/shared/types.py deleted file mode 100644 index 05710a23..00000000 --- a/shared/types.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Compatibility shim module: shared.types -> mjr_am_shared.types""" -from mjr_am_shared.types import * # noqa: F401,F403 diff --git a/shared/version.py b/shared/version.py deleted file mode 100644 index 272ad845..00000000 --- a/shared/version.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Compatibility shim module: shared.version -> mjr_am_shared.version""" -from mjr_am_shared.version import * # noqa: F401,F403 diff --git a/tests/config/test_settings.py b/tests/config/test_settings.py index 9f9d800e..e4ab326b 100644 --- a/tests/config/test_settings.py +++ b/tests/config/test_settings.py @@ -60,3 +60,25 @@ async def test_output_directory_persist_and_read(tmp_path): await db.aclose() +@pytest.mark.asyncio +async def test_metadata_fallback_prefs_persist_and_read(tmp_path): + db = Sqlite(str(tmp_path / "test_metadata_fallback.db")) + await _init_settings_db(db) + settings = AppSettings(db) + + defaults = await settings.get_metadata_fallback_prefs() + assert defaults == {"image": True, "media": True} + + res = await settings.set_metadata_fallback_prefs(image=False, media=True) + assert res.ok + assert res.data == {"image": False, "media": True} + + got = await settings.get_metadata_fallback_prefs() + assert got == {"image": False, "media": True} + + res2 = await settings.set_metadata_fallback_prefs(image=True) + assert res2.ok + assert res2.data == {"image": True, "media": True} + await db.aclose() + + diff --git a/tests/features/test_db_cleanup_case_duplicates.py b/tests/features/test_db_cleanup_case_duplicates.py new file mode 100644 index 00000000..80b81378 --- /dev/null +++ b/tests/features/test_db_cleanup_case_duplicates.py @@ -0,0 +1,54 @@ +import os +import pytest + +from mjr_am_backend.adapters.db.sqlite import Sqlite +from mjr_am_backend.routes.handlers.db_maintenance import _cleanup_assets_case_duplicates + + +async def _init_assets_only(db: Sqlite) -> None: + await db.aexecutescript( + """ + CREATE TABLE metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + subfolder TEXT DEFAULT '', + filepath TEXT NOT NULL UNIQUE, + source TEXT DEFAULT 'output', + root_id TEXT, + kind TEXT NOT NULL, + ext TEXT NOT NULL, + size INTEGER NOT NULL, + mtime INTEGER NOT NULL + ); + """ + ) + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name != "nt", reason="Case duplicate cleanup applies on Windows only") +async def test_cleanup_assets_case_duplicates_keeps_latest(tmp_path): + db = Sqlite(str(tmp_path / "case_cleanup.db")) + await _init_assets_only(db) + + await db.aexecute( + "INSERT INTO assets(filename, subfolder, filepath, source, root_id, kind, ext, size, mtime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("Image.PNG", "", r"F:\Output\Image.PNG", "output", None, "image", ".png", 100, 10), + ) + await db.aexecute( + "INSERT INTO assets(filename, subfolder, filepath, source, root_id, kind, ext, size, mtime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("image.png", "", r"f:\output\image.png", "output", None, "image", ".png", 100, 20), + ) + + res = await _cleanup_assets_case_duplicates(db) + assert res.ok + assert (res.data or {}).get("deleted") == 1 + + q = await db.aquery("SELECT filepath, mtime FROM assets ORDER BY id") + assert q.ok + assert len(q.data or []) == 1 + assert int((q.data or [])[0]["mtime"]) == 20 + await db.aclose() diff --git a/tests/features/test_search_case_dedupe.py b/tests/features/test_search_case_dedupe.py new file mode 100644 index 00000000..a869dc07 --- /dev/null +++ b/tests/features/test_search_case_dedupe.py @@ -0,0 +1,16 @@ +import os +import pytest + +from mjr_am_backend.routes.handlers.search import _dedupe_assets_by_filepath + + +@pytest.mark.skipif(os.name != "nt", reason="Case-insensitive filepath dedupe applies on Windows") +def test_dedupe_assets_by_filepath_windows_style_case_variants(): + assets = [ + {"filepath": r"F:\Output\Image.PNG", "filename": "Image.PNG"}, + {"filepath": r"f:\output\image.png", "filename": "image.png"}, + {"filepath": r"F:\Output\Other.png", "filename": "Other.png"}, + ] + + deduped = _dedupe_assets_by_filepath(assets) + assert len(deduped) == 2 diff --git a/tests/metadata/test_fallback_readers.py b/tests/metadata/test_fallback_readers.py new file mode 100644 index 00000000..c205bafc --- /dev/null +++ b/tests/metadata/test_fallback_readers.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import json + +from PIL import Image +from PIL.PngImagePlugin import PngInfo + +from mjr_am_backend.features.metadata.fallback_readers import read_image_exif_like + + +def test_read_image_exif_like_maps_png_text_fields(tmp_path): + img_path = tmp_path / "fallback.png" + + pnginfo = PngInfo() + pnginfo.add_text("parameters", "Steps: 20, Sampler: Euler, CFG scale: 7") + pnginfo.add_text("workflow", json.dumps({"nodes": [{"id": 1}], "links": []})) + pnginfo.add_text("prompt", json.dumps({"1": {"class_type": "KSampler", "inputs": {}}})) + + Image.new("RGB", (64, 32), "black").save(img_path, pnginfo=pnginfo) + + data = read_image_exif_like(str(img_path)) + assert isinstance(data, dict) + assert data.get("PNG:Parameters") + assert data.get("PNG:Workflow") + assert data.get("PNG:Prompt") + assert data.get("Image:ImageWidth") == 64 + assert data.get("Image:ImageHeight") == 32 diff --git a/tests/rating_tags/test_rating_tags_import.py b/tests/rating_tags/test_rating_tags_import.py index 87f47550..afe75d4a 100644 --- a/tests/rating_tags/test_rating_tags_import.py +++ b/tests/rating_tags/test_rating_tags_import.py @@ -22,46 +22,50 @@ async def _make_db(tmp_path: Path) -> Sqlite: @pytest.mark.asyncio async def test_imports_rating_tags_when_db_empty(tmp_path: Path): db = await _make_db(tmp_path) + try: + ins = await db.aexecute( + "INSERT INTO assets(filepath, filename, subfolder, source, root_id, kind, ext, size, mtime) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("C:\\x\\a.png", "a.png", "", "output", "output", "image", ".png", 123, 1700000000), + ) + assert ins.ok + asset_id = (await db.aquery("SELECT id FROM assets")).data[0]["id"] - ins = await db.aexecute( - "INSERT INTO assets(filepath, filename, subfolder, source, root_id, kind, ext, size, mtime) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)", - ("C:\\x\\a.png", "a.png", "", "output", "output", "image", ".png", 123, 1700000000), - ) - assert ins.ok - asset_id = (await db.aquery("SELECT id FROM assets")).data[0]["id"] + meta = Result.Ok({"rating": 4, "tags": ["foo", "bar"], "quality": "partial"}) + w = await MetadataHelpers.write_asset_metadata_row(db, asset_id, meta) + assert w.ok - meta = Result.Ok({"rating": 4, "tags": ["foo", "bar"], "quality": "partial"}) - w = await MetadataHelpers.write_asset_metadata_row(db, asset_id, meta) - assert w.ok - - row = (await db.aquery("SELECT rating, tags, tags_text FROM asset_metadata WHERE asset_id = ?", (asset_id,))).data[0] - assert row["rating"] == 4 - assert json.loads(row["tags"]) == ["foo", "bar"] - assert row["tags_text"] == "foo bar" + row = (await db.aquery("SELECT rating, tags, tags_text FROM asset_metadata WHERE asset_id = ?", (asset_id,))).data[0] + assert row["rating"] == 4 + assert json.loads(row["tags"]) == ["foo", "bar"] + assert row["tags_text"] == "foo bar" + finally: + await db.aclose() @pytest.mark.asyncio async def test_does_not_override_existing_db_rating_tags(tmp_path: Path): db = await _make_db(tmp_path) + try: + ins = await db.aexecute( + "INSERT INTO assets(filepath, filename, subfolder, source, root_id, kind, ext, size, mtime) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("C:\\x\\b.png", "b.png", "", "output", "output", "image", ".png", 123, 1700000000), + ) + assert ins.ok + asset_id = (await db.aquery("SELECT id FROM assets")).data[0]["id"] - ins = await db.aexecute( - "INSERT INTO assets(filepath, filename, subfolder, source, root_id, kind, ext, size, mtime) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)", - ("C:\\x\\b.png", "b.png", "", "output", "output", "image", ".png", 123, 1700000000), - ) - assert ins.ok - asset_id = (await db.aquery("SELECT id FROM assets")).data[0]["id"] + await db.aexecute( + "INSERT INTO asset_metadata(asset_id, rating, tags, tags_text, has_workflow, has_generation_data, metadata_quality, metadata_raw) VALUES(?, ?, ?, ?, 0, 0, 'none', '{}')", + (asset_id, 5, json.dumps(["keep"], ensure_ascii=False), "keep"), + ) - await db.aexecute( - "INSERT INTO asset_metadata(asset_id, rating, tags, tags_text, has_workflow, has_generation_data, metadata_quality, metadata_raw) VALUES(?, ?, ?, ?, 0, 0, 'none', '{}')", - (asset_id, 5, json.dumps(["keep"], ensure_ascii=False), "keep"), - ) + meta = Result.Ok({"rating": 1, "tags": ["overwrite"], "quality": "partial"}) + w = await MetadataHelpers.write_asset_metadata_row(db, asset_id, meta) + assert w.ok - meta = Result.Ok({"rating": 1, "tags": ["overwrite"], "quality": "partial"}) - w = await MetadataHelpers.write_asset_metadata_row(db, asset_id, meta) - assert w.ok - - row = (await db.aquery("SELECT rating, tags, tags_text FROM asset_metadata WHERE asset_id = ?", (asset_id,))).data[0] - assert row["rating"] == 5 - assert json.loads(row["tags"]) == ["keep"] - assert row["tags_text"] == "keep" + row = (await db.aquery("SELECT rating, tags, tags_text FROM asset_metadata WHERE asset_id = ?", (asset_id,))).data[0] + assert row["rating"] == 5 + assert json.loads(row["tags"]) == ["keep"] + assert row["tags_text"] == "keep" + finally: + await db.aclose() diff --git a/tests/regressions/test_scanner_queue_drain.py b/tests/regressions/test_scanner_queue_drain.py new file mode 100644 index 00000000..e079be5b --- /dev/null +++ b/tests/regressions/test_scanner_queue_drain.py @@ -0,0 +1,21 @@ +from pathlib import Path +from queue import Queue + +from mjr_am_backend.features.index.scanner import IndexScanner + + +def test_drain_walk_queue_reads_bounded_batch(): + q: "Queue[Path | None]" = Queue() + p1 = Path("a.png") + p2 = Path("b.png") + q.put(p1) + q.put(p2) + q.put(None) + + items = IndexScanner._drain_walk_queue(q, 2) + assert items == [p1, p2] + + # Remaining sentinel should still be available for the next pull. + tail = IndexScanner._drain_walk_queue(q, 1) + assert tail == [None] +