diff --git a/README.md b/README.md index 282f3c0..151e6f5 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,19 @@ import of the Immich trash (`POST /api/integrations/immich/import-trash`). Offli disk) are skipped. Exclude `duplicates_target_directory` from the Immich library, so moved files are not imported again. +The group detail page links every file Immich knows to its asset in Immich ("open in Immich", looked up via +`GET /api/groups/{id}/immich`). Local paths are translated with the inverse of `immich.path_mappings`. +Set `immich.public_url` if the browser reaches Immich under another URL than `immich.url` (f.ex. a cluster +internal service URL). Finding assets by path uses the structured search filter of Immich v3.2.0 and newer, +older versions fall back to the deprecated `originalPath` search parameter. + +With `immich.rescan_after_changes: true`, py-image-dedup queues a scan of the Immich external libraries +containing the affected files (of all external libraries, if none matches) after it really moved or deleted +files: at most once per deduplication run and once per batch of executed web UI decisions, never in dry run. +The scan runs in the background; if Immich is unreachable, only a warning is logged. Library scans are admin +endpoints in Immich: the API key must belong to an Immich admin and needs the `library.read` and `library.update` +permissions in addition to `asset.read`. + With `deduplication.hold_ambiguous_groups` enabled, groups that need a review are not acted on automatically. The state shown in the web UI (runs, groups, decisions, action log) is stored in a small SQLite database diff --git a/py_image_dedup/config.py b/py_image_dedup/config.py index 2fd8931..c03854a 100644 --- a/py_image_dedup/config.py +++ b/py_image_dedup/config.py @@ -68,6 +68,8 @@ NODE_URL = "url" NODE_API_KEY = "api_key" NODE_PATH_MAPPINGS = "path_mappings" +NODE_PUBLIC_URL = "public_url" +NODE_RESCAN_AFTER_CHANGES = "rescan_after_changes" NODE_STATIC_DIRECTORY = "static_directory" DEFAULT_STATE_DATABASE_PATH = "~/.local/share/py-image-dedup/state.db" @@ -470,6 +472,32 @@ def __new__(cls, *args, **kwargs): example=[{"immich": "/mnt/photos", "local": "/data/photos"}] ) + IMMICH_PUBLIC_URL = StringConfigEntry( + description="URL of the Immich server as reachable from the browser, used for the \"open in Immich\" " + "links in the web UI. Falls back to immich.url.", + key_path=[ + NODE_MAIN, + NODE_IMMICH, + NODE_PUBLIC_URL + ], + required=False, + default=None, + example="https://immich.example.com" + ) + + IMMICH_RESCAN_AFTER_CHANGES = BoolConfigEntry( + description="Queue a scan of the Immich external libraries after files were really moved or deleted " + "(at most once per run / batch of executed decisions), so Immich notices the change. " + "Needs an API key of an Immich admin with the library.read and library.update permissions.", + key_path=[ + NODE_MAIN, + NODE_IMMICH, + NODE_RESCAN_AFTER_CHANGES + ], + required=False, + default=False + ) + @property def user_decisions_dry_run(self) -> bool: """ diff --git a/py_image_dedup/integrations/immich.py b/py_image_dedup/integrations/immich.py index 260918b..f586d87 100644 --- a/py_image_dedup/integrations/immich.py +++ b/py_image_dedup/integrations/immich.py @@ -1,5 +1,5 @@ import logging -from typing import List +from typing import Dict, List import requests @@ -10,7 +10,10 @@ class ImmichError(Exception): - pass + + def __init__(self, message: str, status_code: int | None = None): + super().__init__(message) + self.status_code = status_code class ImmichClient: @@ -53,31 +56,104 @@ def trashed_original_paths(self) -> List[str]: :return: original file paths (as reported by Immich) of all assets in the trash of the API key's user, excluding offline assets (files missing on disk) """ - paths = [] + items = self._search_items({"withDeleted": True, "trashedAfter": _EPOCH}) + return [item["originalPath"] for item in items if item.get("isTrashed") and not item.get("isOffline")] + + def to_immich_path(self, path: str) -> str | None: + """ + Maps a local path to the path Immich knows the file under (inverse of map_path). + :return: the Immich path, None if there are mappings and none of them matches + """ + if not self._path_mappings: + return path + for immich_prefix, local_prefix in self._path_mappings: + if path == local_prefix or path.startswith(local_prefix + "/"): + return immich_prefix + path[len(local_prefix):] + return None + + def find_asset_ids(self, immich_paths: List[str]) -> Dict[str, str]: + """ + Looks up the (not trashed) assets of the API key's user with the given original paths. + Needs the asset.read permission. + + :param immich_paths: original paths as Immich knows them + :return: original path -> asset id, paths without asset are missing + """ + paths = sorted(set(immich_paths)) + if not paths: + return {} + try: + # structured filter, Immich >= v3.2.0 + items = self._search_items({"filter": {"originalPath": {"in": paths}}}) + except ImmichError as ex: + if ex.status_code not in (400, 422): + raise + # older Immich: the deprecated originalPath parameter matches substrings, one request per path + items = [] + for path in paths: + items.extend(self._search_items({"originalPath": path})) + wanted = set(paths) + return {item["originalPath"]: item["id"] for item in items if item.get("originalPath") in wanted} + + def _search_items(self, body: dict) -> List[dict]: + items = [] page = 1 while page is not None: - assets = self._post("/api/search/metadata", { - "withDeleted": True, - "trashedAfter": _EPOCH, - "page": page, - "size": self._page_size, - })["assets"] - for asset in assets["items"]: - if asset.get("isTrashed") and not asset.get("isOffline"): - paths.append(asset["originalPath"]) + assets = self._post("/api/search/metadata", {**body, "page": page, "size": self._page_size}, + permission="asset.read")["assets"] + items.extend(assets["items"]) next_page = assets.get("nextPage") page = int(next_page) if next_page else None - return paths + return items + + def libraries(self) -> List[dict]: + """ + :return: all external libraries (needs an admin API key with the library.read permission) + """ + return self._request("GET", "/api/libraries", permission="library.read (admin)") + + def scan_library(self, library_id: str): + """ + Queues a scan of an external library (needs an admin API key with the library.update permission). + """ + self._request("POST", f"/api/libraries/{library_id}/scan", permission="library.update (admin)") + + def rescan_libraries(self, changed_immich_paths: List[str]) -> List[str]: + """ + Queues a scan of the external libraries containing one of the given paths, + of all external libraries if none of them matches (f.ex. because the path mappings are incomplete). + + :param changed_immich_paths: paths (as Immich knows them) of files that were moved or deleted + :return: ids of the libraries a scan was queued for + """ + libraries = self.libraries() + + def contains(library: dict, path: str) -> bool: + return any(path == p.rstrip("/") or path.startswith(p.rstrip("/") + "/") + for p in library.get("importPaths") or []) + + affected = [lib for lib in libraries if any(contains(lib, p) for p in changed_immich_paths)] + if not affected: + affected = libraries + for library in affected: + self.scan_library(library["id"]) + return [lib["id"] for lib in affected] + + def _post(self, path: str, body: dict, permission: str = "asset.read") -> dict: + return self._request("POST", path, body=body, permission=permission) - def _post(self, path: str, body: dict) -> dict: + def _request(self, method: str, path: str, body: dict | None = None, permission: str = "asset.read"): try: - response = self._session.post(self._url + path, json=body, timeout=self._timeout, - headers={"x-api-key": self._api_key, "Accept": "application/json"}) + response = self._session.request(method, self._url + path, json=body, timeout=self._timeout, + headers={"x-api-key": self._api_key, "Accept": "application/json"}) except requests.RequestException as ex: raise ImmichError(f"Immich is not reachable at {self._url}: {ex}") from ex if response.status_code in (401, 403): raise ImmichError(f"Immich rejected the API key (HTTP {response.status_code}), " - f"it needs the asset.read permission") + f"it needs the {permission} permission", status_code=response.status_code) if not response.ok: - raise ImmichError(f"Immich request {path} failed: HTTP {response.status_code} {response.text[:200]}") + raise ImmichError(f"Immich request {path} failed: HTTP {response.status_code} {response.text[:200]}", + status_code=response.status_code) + if response.status_code == 204 or not response.content: + return None return response.json() diff --git a/py_image_dedup/library/deduplicator.py b/py_image_dedup/library/deduplicator.py index 1edb625..4819191 100644 --- a/py_image_dedup/library/deduplicator.py +++ b/py_image_dedup/library/deduplicator.py @@ -15,6 +15,7 @@ from py_image_dedup import util from py_image_dedup.config import DeduplicatorConfig +from py_image_dedup.integrations.immich import ImmichClient from py_image_dedup.library import ActionEnum from py_image_dedup.library.deduplication_result import DeduplicationResult from py_image_dedup.library.progress_manager import ProgressManager @@ -114,6 +115,8 @@ def __init__(self, interactive: bool, state_store=None): self._run_id = None # held while a run is in progress, so user decisions from the web UI are executed in between runs self.work_lock = threading.RLock() + # files really moved/deleted since the last Immich library rescan was triggered + self._changed_paths = [] self.reset_result() listener = StateProgressListener(state_store, lambda: self._run_id) if state_store is not None else None @@ -904,6 +907,42 @@ def process_duplicates(self): except Exception as ex: LOGGER.exception(ex) + self._trigger_immich_rescan() + + def _trigger_immich_rescan(self): + """ + Queues a scan of the Immich external libraries (if enabled) when files were really moved or deleted + since the last call. Runs in the background and never raises: Immich being unreachable must not + affect deduplication. + """ + changed_paths, self._changed_paths = self._changed_paths, [] + if not changed_paths or not self._config.IMMICH_RESCAN_AFTER_CHANGES.value: + return + url = self._config.IMMICH_URL.value + api_key = self._config.IMMICH_API_KEY.value + if not (url and api_key): + LOGGER.warning("immich.rescan_after_changes is enabled, but immich.url or immich.api_key is missing") + return + try: + client = ImmichClient(url, api_key, path_mappings=self._config.IMMICH_PATH_MAPPINGS.value, timeout=10) + except Exception as ex: + LOGGER.warning(f"Could not trigger an Immich library rescan: {ex}") + return + immich_paths = [p for p in (client.to_immich_path(p) for p in changed_paths) if p is not None] + self._run_in_background(lambda: self._rescan_immich_libraries(client, immich_paths)) + + @staticmethod + def _rescan_immich_libraries(client: ImmichClient, immich_paths: List[str]): + try: + library_ids = client.rescan_libraries(immich_paths) + LOGGER.info(f"Queued a scan of {len(library_ids)} Immich external library(ies)") + except Exception as ex: + LOGGER.warning(f"Could not trigger an Immich library rescan: {ex}") + + @staticmethod + def _run_in_background(function): + threading.Thread(target=function, name="immich-rescan").start() + def execute_user_decisions(self) -> int: """ Executes the decisions users made in the web UI (chosen keeper of a duplicate group). @@ -926,6 +965,7 @@ def execute_user_decisions(self) -> int: status=ActionStatus.FAILED, message=f"{type(ex).__name__}: {ex}", group_id=group["id"]) self._state.mark_executed(group["id"], resolved=False) + self._trigger_immich_rescan() return len(groups) def _execute_user_decision(self, group: dict): @@ -970,6 +1010,8 @@ def _record_action(self, path: Path, action: str, status: str, dry_run: bool, so """ Writes a file action to the action log of the state store (if any). Never raises. """ + if status == ActionStatus.DONE and not dry_run and action in ("move", "delete"): + self._changed_paths.append(str(path)) group_id = self._group_of_path.get(str(path)) if status == ActionStatus.FAILED and group_id is not None: self._failed_groups.add(group_id) diff --git a/py_image_dedup/web/app.py b/py_image_dedup/web/app.py index f6e3aab..000e0d2 100644 --- a/py_image_dedup/web/app.py +++ b/py_image_dedup/web/app.py @@ -21,6 +21,10 @@ LOGGER = logging.getLogger(__name__) +# how long asset lookups in Immich are cached (also failed ones, so a down Immich is not asked on every page load) +IMMICH_ASSET_CACHE_SECONDS = 60 +IMMICH_ASSET_CACHE_SIZE = 500 + # keep in sync with ImageMatchDeduplicator._quality_criteria and WEAK_PRIORITIZATION_RULES KNOWN_PRIORITIZATION_RULES = { "more-exif-data", "less-exif-data", @@ -238,10 +242,60 @@ def require_target_directory(): detail="duplicates_target_directory is not configured. User decisions " "only move duplicates, they never delete files.") + def immich_configured() -> bool: + return bool(config.IMMICH_URL.value and config.IMMICH_API_KEY.value) + + def immich_public_url() -> str | None: + url = config.IMMICH_PUBLIC_URL.value or config.IMMICH_URL.value + return url.rstrip("/") if url else None + @app.get("/api/integrations/immich") def immich_status(): - return {"configured": bool(config.IMMICH_URL.value and config.IMMICH_API_KEY.value), - "url": config.IMMICH_URL.value} + return {"configured": immich_configured(), + "url": config.IMMICH_URL.value, + "public_url": immich_public_url(), + "rescan_after_changes": config.IMMICH_RESCAN_AFTER_CHANGES.value} + + immich_asset_cache = {} + + @app.get("/api/groups/{group_id}/immich") + def group_immich_assets(group_id: int): + """ + Finds the Immich assets of the files of a group, for "open in Immich" links. + Never fails because of Immich: errors are reported in "error". Cached briefly. + """ + group = group_or_404(group_id) + if not immich_configured(): + return {"configured": False, "url": None, "error": None, "candidates": []} + + client = ImmichClient(config.IMMICH_URL.value, config.IMMICH_API_KEY.value, + path_mappings=config.IMMICH_PATH_MAPPINGS.value, timeout=5) + immich_paths = {c["id"]: client.to_immich_path(c["path"]) for c in group["candidates"]} + cache_key = tuple(sorted(p for p in immich_paths.values() if p is not None)) + cached = immich_asset_cache.get(cache_key) + if cached is not None and time.monotonic() - cached[0] < IMMICH_ASSET_CACHE_SECONDS: + asset_ids, error = cached[1], cached[2] + else: + asset_ids, error = {}, None + try: + asset_ids = client.find_asset_ids(list(cache_key)) + except Exception as ex: + LOGGER.warning(f"Could not look up the Immich assets of group {group_id}: {ex}") + error = str(ex) + if len(immich_asset_cache) >= IMMICH_ASSET_CACHE_SIZE: + immich_asset_cache.clear() + immich_asset_cache[cache_key] = (time.monotonic(), asset_ids, error) + + base_url = immich_public_url() + candidates = [] + for candidate_id, immich_path in immich_paths.items(): + asset_id = asset_ids.get(immich_path) if immich_path is not None else None + candidates.append({ + "candidate_id": candidate_id, + "asset_id": asset_id, + "url": f"{base_url}/photos/{asset_id}" if asset_id is not None else None, + }) + return {"configured": True, "url": base_url, "error": error, "candidates": candidates} @app.post("/api/integrations/immich/import-trash") def immich_import_trash(request: ImportRequest): diff --git a/py_image_dedup_reference.yaml b/py_image_dedup_reference.yaml index b936736..36e40db 100644 --- a/py_image_dedup_reference.yaml +++ b/py_image_dedup_reference.yaml @@ -132,13 +132,22 @@ py_image_dedup: # static_directory: /app/web/dist # Optional Immich integration: import the files trashed in Immich (f.ex. by - # its duplicate review) as decisions for the duplicate groups (web UI dashboard). + # its duplicate review) as decisions for the duplicate groups (web UI dashboard), + # "open in Immich" links in the group detail and library rescans after changes. immich: # Base URL of the Immich server. # url: http://immich-server:2283 # API key with the asset.read permission of the user who owns the trashed # assets. Better set via env: PY_IMAGE_DEDUP_IMMICH_API_KEY # api_key: ... + # URL of Immich as reachable from the browser, for the "open in Immich" links + # in the web UI. Falls back to url. + # public_url: https://immich.example.com + # Queue a scan of the Immich external libraries after files were really + # moved or deleted (at most once per run / batch of executed decisions). + # Needs an API key of an Immich admin with the library.read and + # library.update permissions. + rescan_after_changes: false # Maps the paths Immich reports to the paths this application sees, # f.ex. when Immich mounts the library at a different location. # Only paths matching a mapping are imported (if any mapping is set). diff --git a/tests/test_deduplicator_state.py b/tests/test_deduplicator_state.py index 1e237b7..9d4da6b 100644 --- a/tests/test_deduplicator_state.py +++ b/tests/test_deduplicator_state.py @@ -1,7 +1,9 @@ import shutil import tempfile from pathlib import Path +from unittest import mock +from py_image_dedup.integrations.immich import ImmichError from py_image_dedup.library import ActionEnum from py_image_dedup.library.deduplicator import ImageMatchDeduplicator from py_image_dedup.persistence.metadata_key import MetadataKey @@ -302,3 +304,61 @@ def test_never_deletes_without_target_directory(self): self.assertTrue(all(f.exists() for f in self.files)) self.assertEqual({ActionStatus.SKIPPED}, {a["status"] for a in self.store.list_actions()["items"]}) + + def _enable_immich_rescan(self): + entries = [self.config.IMMICH_URL, self.config.IMMICH_API_KEY, self.config.IMMICH_PATH_MAPPINGS, + self.config.IMMICH_RESCAN_AFTER_CHANGES] + for entry, value in [(e, e.value) for e in entries]: + self.addCleanup(setattr, entry, "value", value) + self.config.IMMICH_URL.value = "http://immich:2283" + self.config.IMMICH_API_KEY.value = "key" + self.config.IMMICH_PATH_MAPPINGS.value = [{"immich": "/immich", "local": str(self.source)}] + self.config.IMMICH_RESCAN_AFTER_CHANGES.value = True + # run the rescan synchronously + self.dedup._run_in_background = lambda function: function() + rescan = mock.patch("py_image_dedup.integrations.immich.ImmichClient.rescan_libraries", + return_value=["lib"]).start() + self.addCleanup(mock.patch.stopall) + return rescan + + def test_immich_rescan_once_after_moving_files(self): + rescan = self._enable_immich_rescan() + self.config.DRY_RUN.value = False + self.store.set_user_keeper(self.group_id, str(self.files[1])) + + self.dedup.execute_user_decisions() + + rescan.assert_called_once() + self.assertEqual({f"/immich/{self.files[0].name}", f"/immich/{self.files[2].name}"}, + set(rescan.call_args.args[0])) + + # nothing changed since + self.dedup.execute_user_decisions() + rescan.assert_called_once() + + def test_no_immich_rescan_in_dry_run_or_when_disabled(self): + rescan = self._enable_immich_rescan() + self.config.DRY_RUN.value = True + self.store.set_user_keeper(self.group_id, str(self.files[1])) + self.dedup.execute_user_decisions() + rescan.assert_not_called() + + self.config.IMMICH_RESCAN_AFTER_CHANGES.value = False + self.config.DRY_RUN.value = False + self.store.set_user_keeper(self.group_id, str(self.files[1])) + self.dedup.execute_user_decisions() + self.assertFalse(self.files[0].exists()) + rescan.assert_not_called() + + def test_unreachable_immich_does_not_break_decisions(self): + rescan = self._enable_immich_rescan() + rescan.side_effect = ImmichError("down") + self.config.DRY_RUN.value = False + self.store.set_user_keeper(self.group_id, str(self.files[1])) + + with self.assertLogs("py_image_dedup.library.deduplicator", level="WARNING") as logs: + self.dedup.execute_user_decisions() + + rescan.assert_called_once() + self.assertIn("down", "".join(logs.output)) + self.assertEqual(GroupStatus.RESOLVED, self.store.get_group(self.group_id)["status"]) diff --git a/tests/test_immich.py b/tests/test_immich.py index bad061f..4728a44 100644 --- a/tests/test_immich.py +++ b/tests/test_immich.py @@ -7,7 +7,8 @@ def _response(status_code: int, data: dict | None = None) -> mock.Mock: - response = mock.Mock(status_code=status_code, ok=200 <= status_code < 300, text="") + response = mock.Mock(status_code=status_code, ok=200 <= status_code < 300, text="", + content=b"" if data is None else b"{}") response.json.return_value = data return response @@ -24,7 +25,7 @@ def setUp(self): path_mappings=[{"immich": "/mnt/photos/", "local": "/data/photos"}]) def test_trashed_original_paths_pages_and_filters(self): - self.session.post.side_effect = [ + self.session.request.side_effect = [ _response(200, _page([{"originalPath": "/mnt/photos/a.jpg", "isTrashed": True, "isOffline": False}, {"originalPath": "/mnt/photos/b.jpg", "isTrashed": False, "isOffline": False}], "2")), @@ -34,18 +35,18 @@ def test_trashed_original_paths_pages_and_filters(self): self.assertEqual(["/mnt/photos/a.jpg", "/mnt/photos/d.jpg"], self.client.trashed_original_paths()) - first, second = self.session.post.call_args_list - self.assertEqual("http://immich:2283/api/search/metadata", first.args[0]) + first, second = self.session.request.call_args_list + self.assertEqual(("POST", "http://immich:2283/api/search/metadata"), first.args) self.assertEqual("key", first.kwargs["headers"]["x-api-key"]) self.assertTrue(first.kwargs["json"]["withDeleted"]) self.assertEqual((1, 2), (first.kwargs["json"]["page"], second.kwargs["json"]["page"])) def test_errors(self): - self.session.post.return_value = _response(401) + self.session.request.return_value = _response(401) with self.assertRaisesRegex(ImmichError, "API key"): self.client.trashed_original_paths() - self.session.post.side_effect = requests.ConnectionError("refused") + self.session.request.side_effect = requests.ConnectionError("refused") with self.assertRaisesRegex(ImmichError, "not reachable"): self.client.trashed_original_paths() @@ -54,3 +55,52 @@ def test_map_path(self): self.assertIsNone(self.client.map_path("/mnt/photos2/a.jpg")) self.assertIsNone(self.client.map_path("/upload/a.jpg")) self.assertEqual("/upload/a.jpg", ImmichClient("http://immich", "key").map_path("/upload/a.jpg")) + + def test_to_immich_path(self): + self.assertEqual("/mnt/photos/x/a.jpg", self.client.to_immich_path("/data/photos/x/a.jpg")) + self.assertEqual("/mnt/photos", self.client.to_immich_path("/data/photos")) + self.assertIsNone(self.client.to_immich_path("/data/photos2/a.jpg")) + self.assertEqual("/a.jpg", ImmichClient("http://immich", "key").to_immich_path("/a.jpg")) + + def test_find_asset_ids(self): + self.session.request.return_value = _response(200, _page( + [{"id": "1", "originalPath": "/mnt/photos/a.jpg"}, {"id": "2", "originalPath": "/mnt/photos/b.jpg"}], + None)) + + self.assertEqual({"/mnt/photos/a.jpg": "1", "/mnt/photos/b.jpg": "2"}, + self.client.find_asset_ids(["/mnt/photos/b.jpg", "/mnt/photos/a.jpg", "/mnt/photos/c.jpg"])) + body = self.session.request.call_args.kwargs["json"] + self.assertEqual({"in": ["/mnt/photos/a.jpg", "/mnt/photos/b.jpg", "/mnt/photos/c.jpg"]}, + body["filter"]["originalPath"]) + self.assertEqual({}, self.client.find_asset_ids([])) + + def test_find_asset_ids_falls_back_to_deprecated_parameter(self): + # Immich < v3.2.0 does not know the "filter" parameter, "originalPath" matches substrings there + self.session.request.side_effect = [ + _response(400), + _response(200, _page([{"id": "1", "originalPath": "/mnt/photos/a.jpg"}, + {"id": "9", "originalPath": "/mnt/photos/a.jpg.bak/x.jpg"}], None)), + ] + + self.assertEqual({"/mnt/photos/a.jpg": "1"}, self.client.find_asset_ids(["/mnt/photos/a.jpg"])) + self.assertEqual("/mnt/photos/a.jpg", self.session.request.call_args.kwargs["json"]["originalPath"]) + + def test_rescan_libraries(self): + libraries = [{"id": "lib-1", "importPaths": ["/mnt/photos/"]}, {"id": "lib-2", "importPaths": ["/other"]}] + self.session.request.side_effect = [_response(200, libraries), _response(204)] + + self.assertEqual(["lib-1"], self.client.rescan_libraries(["/mnt/photos/x/a.jpg"])) + list_call, scan_call = self.session.request.call_args_list + self.assertEqual(("GET", "http://immich:2283/api/libraries"), list_call.args) + self.assertEqual(("POST", "http://immich:2283/api/libraries/lib-1/scan"), scan_call.args) + + def test_rescan_libraries_scans_all_if_none_matches(self): + libraries = [{"id": "lib-1", "importPaths": ["/mnt/photos"]}, {"id": "lib-2", "importPaths": []}] + self.session.request.side_effect = [_response(200, libraries), _response(204), _response(204)] + + self.assertEqual(["lib-1", "lib-2"], self.client.rescan_libraries(["/elsewhere/a.jpg"])) + + def test_rescan_libraries_needs_admin_permissions(self): + self.session.request.return_value = _response(403) + with self.assertRaisesRegex(ImmichError, "library.read"): + self.client.rescan_libraries(["/mnt/photos/a.jpg"]) diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 9b8e7a3..baa9e59 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -142,6 +142,56 @@ def test_immich_import_trash(self): response = self.client.post("/api/integrations/immich/import-trash", json={"apply": True}) self.assertEqual((502, "down"), (response.status_code, response.json()["detail"])) + def test_group_immich_assets(self): + immich_entries = [self.config.IMMICH_URL, self.config.IMMICH_API_KEY, self.config.IMMICH_PATH_MAPPINGS, + self.config.IMMICH_PUBLIC_URL] + for entry, value in [(e, e.value) for e in immich_entries]: + self.addCleanup(setattr, entry, "value", value) + + url = f"/api/groups/{self.group_id}/immich" + self.assertEqual({"configured": False, "url": None, "error": None, "candidates": []}, + self.client.get(url).json()) + self.assertEqual(404, self.client.get("/api/groups/999/immich").status_code) + + self.config.IMMICH_URL.value = "http://immich-server:2283" + self.config.IMMICH_API_KEY.value = "key" + self.config.IMMICH_PATH_MAPPINGS.value = [{"immich": "/immich", "local": str(IMAGE.parent)}] + self.config.IMMICH_PUBLIC_URL.value = "https://immich.example.com/" + self.assertEqual("https://immich.example.com", + self.client.get("/api/integrations/immich").json()["public_url"]) + + with mock.patch("py_image_dedup.integrations.immich.ImmichClient.find_asset_ids", + return_value={f"/immich/{IMAGE.name}": "asset-1"}) as find: + result = self.client.get(url).json() + # cached + self.client.get(url) + find.assert_called_once() + # /etc/hostname is not covered by the path mappings + self.assertEqual(sorted([f"/immich/{IMAGE.name}", f"/immich/{IMAGE_2.name}"]), find.call_args.args[0]) + self.assertTrue(result["configured"]) + self.assertIsNone(result["error"]) + by_candidate = {c["candidate_id"]: c for c in result["candidates"]} + self.assertEqual("https://immich.example.com/photos/asset-1", by_candidate[self.candidates[0]["id"]]["url"]) + self.assertEqual("asset-1", by_candidate[self.candidates[0]["id"]]["asset_id"]) + self.assertIsNone(by_candidate[self.candidates[1]["id"]]["url"]) + self.assertIsNone(by_candidate[self.candidates[2]["id"]]["url"]) + + def test_group_immich_assets_when_immich_is_down(self): + immich_entries = [self.config.IMMICH_URL, self.config.IMMICH_API_KEY, self.config.IMMICH_PATH_MAPPINGS] + for entry, value in [(e, e.value) for e in immich_entries]: + self.addCleanup(setattr, entry, "value", value) + self.config.IMMICH_URL.value = "http://immich-server:2283" + self.config.IMMICH_API_KEY.value = "key" + self.config.IMMICH_PATH_MAPPINGS.value = [] + + with mock.patch("py_image_dedup.integrations.immich.ImmichClient.find_asset_ids", + side_effect=ImmichError("down")): + response = self.client.get(f"/api/groups/{self.group_id}/immich") + self.assertEqual(200, response.status_code) + self.assertEqual("down", response.json()["error"]) + self.assertEqual([None, None, None], [c["url"] for c in response.json()["candidates"]]) + self.assertEqual("http://immich-server:2283", response.json()["url"]) + def test_keep_ignore_reset(self): self.config.DEDUPLICATOR_DUPLICATES_TARGET_DIRECTORY.value = self.tmp response = self.client.post(f"/api/groups/{self.group_id}/keep", diff --git a/web/src/api.ts b/web/src/api.ts index f2ef489..0bdb645 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -4,6 +4,7 @@ import type { Failure, Group, GroupDetail, + GroupImmichAssets, ImmichImportResult, ImmichStatus, Page, @@ -65,6 +66,7 @@ export const api = { failures: (params?: Params) => request>('GET', `/api/failures${query(params)}`), config: () => request('GET', '/api/config'), immich: () => request('GET', '/api/integrations/immich'), + groupImmich: (groupId: number) => request('GET', `/api/groups/${groupId}/immich`), importImmichTrash: (apply: boolean) => request('POST', '/api/integrations/immich/import-trash', { apply }), } diff --git a/web/src/types.ts b/web/src/types.ts index 6a0c782..05d76e7 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -200,6 +200,21 @@ export interface ConfigResponse { export interface ImmichStatus { configured: boolean url: string | null + public_url: string | null + rescan_after_changes: boolean +} + +export interface ImmichCandidateAsset { + candidate_id: number + asset_id: string | null + url: string | null +} + +export interface GroupImmichAssets { + configured: boolean + url: string | null + error: string | null + candidates: ImmichCandidateAsset[] } export interface ImportOutcomes { diff --git a/web/src/views/GroupDetailView.vue b/web/src/views/GroupDetailView.vue index 0e3a773..803acf0 100644 --- a/web/src/views/GroupDetailView.vue +++ b/web/src/views/GroupDetailView.vue @@ -4,7 +4,7 @@ import { api, imageUrl } from '../api' import { usePolling } from '../polling' import { status } from '../status' import { fileName, folder, formatBytes, formatPixels, formatTime } from '../format' -import type { Candidate, GroupDetail } from '../types' +import type { Candidate, GroupDetail, GroupImmichAssets } from '../types' import StatusBadge from '../components/StatusBadge.vue' import ThumbImage from '../components/ThumbImage.vue' import ActionTable from '../components/ActionTable.vue' @@ -17,6 +17,26 @@ const group = usePolling( ) watch(() => props.id, () => group.refresh()) +// "open in Immich" links, loaded once per group: a failing Immich never breaks this page +const immich = ref(null) +watch( + () => props.id, + async (id) => { + immich.value = null + try { + const result = await api.groupImmich(id) + if (id === props.id) immich.value = result + } catch { + // Immich links are optional + } + }, + { immediate: true }, +) + +function immichUrl(candidate: Candidate): string | null { + return immich.value?.candidates.find((a) => a.candidate_id === candidate.id)?.url ?? null +} + const busy = ref(false) const actionError = ref(null) @@ -126,6 +146,7 @@ function plannedFor(candidate: Candidate) {
{{ actionError }}
+
Immich links unavailable: {{ immich.error }}
@@ -148,6 +169,9 @@ function plannedFor(candidate: Candidate) {
{{ fileName(c.path) }}
{{ folder(c.path) }}
→ {{ c.moved_to }}
+ + open in Immich ↗ +
chosen by you
{{ c.keep_reason }}