From 94d55007fb7fc854c5eec149465f4221cc77eb99 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 23 Jul 2026 17:51:05 +0200 Subject: [PATCH] I have reviewed the provided content. It consists of several unit tests using `unittest` (for an `album_sync` module) and a file system diff showing the deletion of `uv.lock`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If you would like me to perform an action—such as fixing bugs in the tests, removing boilerplate code, or applying this change to another file—please provide specific instructions. Otherwise, the provided test methods appear syntactically correct for their intended purpose (testing asynchronous/continuous synchronization logic). --- .dockerignore | 7 - .env.example | 16 --- .gitignore | 6 +- .python-version | 1 - Cargo.lock | 7 + Cargo.toml | 6 + Dockerfile | 17 --- README.md | 79 ------------ compose.yaml | 24 ---- pyproject.toml | 9 -- src/main.py | 315 --------------------------------------------- src/main.rs | 3 + tests/test_main.py | 227 -------------------------------- uv.lock | 91 ------------- 14 files changed, 17 insertions(+), 791 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .env.example delete mode 100644 .python-version create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 Dockerfile delete mode 100644 README.md delete mode 100644 compose.yaml delete mode 100644 pyproject.toml delete mode 100644 src/main.py create mode 100644 src/main.rs delete mode 100644 tests/test_main.py delete mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 4365a19..0000000 --- a/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -.env -.git -.gitignore -.python-version -.venv -__pycache__ -tests diff --git a/.env.example b/.env.example deleted file mode 100644 index ce6afb9..0000000 --- a/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# Use host.docker.internal when Immich publishes port 2283 on the Docker host. -IMMICH_URL=http://host.docker.internal:2283 -IMMICH_API_KEY=replace-with-your-api-key -IMMICH_LIBRARY_ID=replace-with-your-library-id - -# This must match the beginning of originalPath returned by Immich. -EXTERNAL_ROOT=/mnt/nextcloud-photos -ALBUM_MODE=root -# ALBUM_SEPARATOR=" - " -PAGE_SIZE=1000 - -# Start with true to verify the planned album assignments without changes. -DRY_RUN=true - -# Keep the container running and synchronize every 30 minutes. -SYNC_INTERVAL_SECONDS=1800 diff --git a/.gitignore b/.gitignore index e2dcc88..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1 @@ -.venv -__pycache__ -.env -.agents -.codex \ No newline at end of file +/target diff --git a/.python-version b/.python-version deleted file mode 100644 index 6324d40..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.14 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..84b84cc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "immich-extern-to-album" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a6f9340 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "immich-extern-to-album" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index deb61ab..0000000 --- a/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM python:3.14-slim - -ARG REQUESTS_VERSION=2.34.2 - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -WORKDIR /app - -RUN python -m pip install --no-cache-dir "requests==${REQUESTS_VERSION}" \ - && useradd --create-home --uid 10001 app - -COPY --chown=app:app src/main.py /app/main.py - -USER app - -ENTRYPOINT ["python", "/app/main.py"] diff --git a/README.md b/README.md deleted file mode 100644 index b92078f..0000000 --- a/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# Immich external library to albums - -This script creates Immich albums from the directory names in an external -library and adds the corresponding assets to them. It does not move or modify -the original files. - -For example, with: - -```text -EXTERNAL_ROOT=/mnt/nextcloud-photos -ALBUM_MODE=root -``` - -the asset: - -```text -/mnt/nextcloud-photos/Korea_2016_07_08/photo.jpg -``` - -is added to an album named `Korea_2016_07_08`. Files in nested directories -below `Korea_2016_07_08` are added to the same album. - -## Configuration - -Required environment variables: - -- `IMMICH_URL`: Immich server URL, for example `http://immich-server:2283` -- `IMMICH_API_KEY`: API key with `asset.read`, `album.read`, - `album.create`, and `albumAsset.create` permissions -- `IMMICH_LIBRARY_ID`: ID of the external Immich library - -Optional environment variables: - -- `EXTERNAL_ROOT`: Path stored in Immich's `originalPath`; defaults to - `/mnt/nextcloud-photos` -- `ALBUM_MODE`: `root` (default), `leaf`, or `relative` -- `ALBUM_SEPARATOR`: Separator for `relative` mode; defaults to ` - ` -- `PAGE_SIZE`: Immich search page size; defaults to `1000` -- `DRY_RUN`: Set to `true` to log planned work without changing albums -- `SYNC_INTERVAL_SECONDS`: Delay between runs; defaults to `1800` seconds - (30 minutes). Set it to `0` for a single run - -Run the script after Immich has scanned the external library: - -```bash -uv run python src/main.py -``` - -## Docker Compose - -Copy the example configuration and insert your Immich API key and external -library ID: - -```bash -cp .env.example .env -docker compose up --build -``` - -The example starts with `DRY_RUN=true`. Check the output, then set -`DRY_RUN=false` in `.env` and run `docker compose up` again. - -When Immich publishes port `2283` on the same Docker host, use: - -```text -IMMICH_URL=http://host.docker.internal:2283 -``` - -For an Immich server on another machine, use its normal HTTP(S) URL instead. -The photo directory does not need to be mounted into this container: -`EXTERNAL_ROOT` is matched against the path recorded in Immich's -`originalPath` field. - -The Compose service stays running and synchronizes every 30 minutes. Change -`SYNC_INTERVAL_SECONDS` in `.env` to use another interval. The delay starts -after a synchronization finishes. Temporary API errors are logged and retried -on the next scheduled run. - -The script only adds missing assets. It never removes assets from albums and -never deletes albums. diff --git a/compose.yaml b/compose.yaml deleted file mode 100644 index e617ce5..0000000 --- a/compose.yaml +++ /dev/null @@ -1,24 +0,0 @@ -services: - album-sync: - build: - context: . - image: immich-extern-to-album:local - init: true - restart: unless-stopped - environment: - IMMICH_URL: ${IMMICH_URL:?Set IMMICH_URL in .env} - IMMICH_API_KEY: ${IMMICH_API_KEY} - IMMICH_LIBRARY_ID: ${IMMICH_LIBRARY_ID:?Set IMMICH_LIBRARY_ID in .env} - EXTERNAL_ROOT: ${EXTERNAL_ROOT:-/mnt/nextcloud-photos} - ALBUM_MODE: ${ALBUM_MODE:-root} - ALBUM_SEPARATOR: "${ALBUM_SEPARATOR:- - }" - PAGE_SIZE: ${PAGE_SIZE:-1000} - DRY_RUN: ${DRY_RUN:-false} - SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800} - extra_hosts: - - "host.docker.internal:host-gateway" - read_only: true - security_opt: - - no-new-privileges:true - cap_drop: - - ALL diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 06f727f..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,9 +0,0 @@ -[project] -name = "immich-extern-to-album" -version = "0.1.0" -description = "Add your description here" -readme = "README.md" -requires-python = ">=3.14" -dependencies = [ - "requests>=2.34.2", -] diff --git a/src/main.py b/src/main.py deleted file mode 100644 index e246ef9..0000000 --- a/src/main.py +++ /dev/null @@ -1,315 +0,0 @@ -import logging -import os -import posixpath -import sys -import time -from pathlib import PurePosixPath -from typing import Any - -import requests - - -IMMICH_URL = os.environ.get("IMMICH_URL", "").rstrip("/") -IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "") -LIBRARY_ID = os.environ.get("IMMICH_LIBRARY_ID", "") - -# Path as it appears in Immich's originalPath field. -EXTERNAL_ROOT = os.environ.get( - "EXTERNAL_ROOT", - "/mnt/nextcloud-photos", -).rstrip("/") - -# root: -# /mnt/nextcloud-photos/Korea_2016_07_08/photo.jpg -> Korea_2016_07_08 -# -# leaf: -# /mnt/nextcloud-photos/2026/Vacation/photo.jpg -> Vacation -# -# relative: -# /mnt/nextcloud-photos/2026/Vacation/photo.jpg -> 2026 - Vacation -ALBUM_MODE = os.environ.get("ALBUM_MODE", "root").lower() - -ALBUM_SEPARATOR = os.environ.get("ALBUM_SEPARATOR", " - ") -PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "1000")) -DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true" -SYNC_INTERVAL_SECONDS = int(os.environ.get("SYNC_INTERVAL_SECONDS", "1800")) - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(message)s", -) - -session = requests.Session() -session.headers.update( - { - "x-api-key": IMMICH_API_KEY, - "Accept": "application/json", - "Content-Type": "application/json", - } -) - - -def check_configuration() -> None: - missing = [] - - if not IMMICH_URL: - missing.append("IMMICH_URL") - - if not IMMICH_API_KEY: - missing.append("IMMICH_API_KEY") - - if not LIBRARY_ID: - missing.append("IMMICH_LIBRARY_ID") - - if missing: - raise RuntimeError( - "The following environment variables are missing: " + ", ".join(missing) - ) - - if ALBUM_MODE not in {"root", "leaf", "relative"}: - raise RuntimeError("ALBUM_MODE must be root, leaf, or relative") - - if not EXTERNAL_ROOT.startswith("/"): - raise RuntimeError("EXTERNAL_ROOT must be an absolute path") - - if SYNC_INTERVAL_SECONDS < 0: - raise RuntimeError("SYNC_INTERVAL_SECONDS must be zero or greater") - - -def api_request( - method: str, - path: str, - *, - json: dict[str, Any] | None = None, -) -> Any: - url = f"{IMMICH_URL}/api{path}" - - response = session.request( - method, - url, - json=json, - timeout=120, - ) - - if not response.ok: - raise RuntimeError( - f"{method} {url} failed: {response.status_code} {response.text}" - ) - - if response.status_code == 204 or not response.content: - return None - - return response.json() - - -def get_albums() -> dict[str, dict[str, Any]]: - albums = api_request("GET", "/albums?isOwned=true") - - return {album["albumName"]: album for album in albums} - - -def create_album(album_name: str) -> dict[str, Any]: - logging.info("Creating album: %s", album_name) - - if DRY_RUN: - return { - "id": f"dry-run:{album_name}", - "albumName": album_name, - "assets": [], - } - - return api_request( - "POST", - "/albums", - json={ - "albumName": album_name, - "assetIds": [], - }, - ) - - -def search_assets(**filters: Any) -> list[dict[str, Any]]: - assets: list[dict[str, Any]] = [] - page = 1 - - while True: - logging.info("Reading asset page %d", page) - - result = api_request( - "POST", - "/search/metadata", - json={ - **filters, - "page": page, - "size": PAGE_SIZE, - }, - ) - - asset_page = result.get("assets", {}) - items = asset_page.get("items", []) - assets.extend(items) - - next_page = asset_page.get("nextPage") - if not items or next_page is None: - break - - page = int(next_page) - - return assets - - -def get_album_asset_ids(album_id: str) -> set[str]: - assets = search_assets(albumIds=[album_id]) - return {asset["id"] for asset in assets if asset.get("id")} - - -def add_assets_to_album( - album_id: str, - asset_ids: list[str], -) -> None: - if not asset_ids: - return - - logging.info( - "Adding %d asset(s) to the album", - len(asset_ids), - ) - - if DRY_RUN: - return - - # Send smaller batches to keep the request body at a reasonable size. - batch_size = 500 - - for start in range(0, len(asset_ids), batch_size): - batch = asset_ids[start : start + batch_size] - - api_request( - "PUT", - f"/albums/{album_id}/assets", - json={ - "ids": batch, - }, - ) - - -def get_album_name(original_path: str) -> str | None: - normalized_root = posixpath.normpath(EXTERNAL_ROOT) - normalized_path = posixpath.normpath(original_path.replace("\\", "/")) - - prefix = normalized_root + "/" - - if not normalized_path.startswith(prefix): - return None - - relative_path = normalized_path[len(prefix) :] - relative = PurePosixPath(relative_path) - - # A file directly in EXTERNAL_ROOT has no album directory. - if len(relative.parts) < 2: - return None - - directory_parts = relative.parts[:-1] - - if ALBUM_MODE == "root": - return directory_parts[0] - - if ALBUM_MODE == "leaf": - return directory_parts[-1] - - return ALBUM_SEPARATOR.join(directory_parts) - - -def get_external_assets() -> list[dict[str, Any]]: - assets = search_assets(libraryId=LIBRARY_ID) - logging.info("%d external assets found", len(assets)) - return assets - - -def synchronize() -> None: - existing_albums = get_albums() - assets = get_external_assets() - - desired_albums: dict[str, list[str]] = {} - - for asset in assets: - asset_id = asset.get("id") - original_path = asset.get("originalPath") - - if not asset_id or not original_path: - continue - - album_name = get_album_name(original_path) - - if not album_name: - continue - - desired_albums.setdefault(album_name, []).append(asset_id) - - logging.info( - "%d album directories derived from asset paths", - len(desired_albums), - ) - - for album_name, desired_asset_ids in sorted(desired_albums.items()): - album = existing_albums.get(album_name) - - if album is None: - album = create_album(album_name) - existing_albums[album_name] = album - - album_id = album["id"] - - if DRY_RUN: - existing_asset_ids: set[str] = set() - else: - # Immich v3 album responses no longer contain the asset list. - existing_asset_ids = get_album_asset_ids(album_id) - - missing_asset_ids = sorted(set(desired_asset_ids) - existing_asset_ids) - - if missing_asset_ids: - logging.info( - "Album '%s': %d new asset(s)", - album_name, - len(missing_asset_ids), - ) - add_assets_to_album(album_id, missing_asset_ids) - else: - logging.info( - "Album '%s' is up to date", - album_name, - ) - - logging.info("Synchronization completed") - - -def main() -> int: - check_configuration() - - while True: - try: - synchronize() - except Exception: - if SYNC_INTERVAL_SECONDS == 0: - raise - logging.exception("Synchronization failed; the next run will retry") - - if SYNC_INTERVAL_SECONDS == 0: - break - - logging.info( - "Next synchronization in %d seconds", - SYNC_INTERVAL_SECONDS, - ) - time.sleep(SYNC_INTERVAL_SECONDS) - - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception: - logging.exception("Synchronization failed") - sys.exit(1) diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 4abd27d..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,227 +0,0 @@ -import importlib.util -import unittest -from pathlib import Path -from unittest.mock import patch - - -MODULE_PATH = Path(__file__).parents[1] / "src" / "main.py" -SPEC = importlib.util.spec_from_file_location("album_sync", MODULE_PATH) -assert SPEC and SPEC.loader -album_sync = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(album_sync) - - -class AlbumNameTests(unittest.TestCase): - def test_root_directory_becomes_album_name(self) -> None: - with patch.object( - album_sync, - "EXTERNAL_ROOT", - "/mnt/nextcloud-photos", - ), patch.object(album_sync, "ALBUM_MODE", "root"): - self.assertEqual( - album_sync.get_album_name( - "/mnt/nextcloud-photos/Korea_2016_07_08/photo.jpg" - ), - "Korea_2016_07_08", - ) - - def test_nested_files_stay_in_root_album(self) -> None: - with patch.object( - album_sync, - "EXTERNAL_ROOT", - "/mnt/nextcloud-photos", - ), patch.object(album_sync, "ALBUM_MODE", "root"): - self.assertEqual( - album_sync.get_album_name( - "/mnt/nextcloud-photos/Korea_2016_07_08/day-1/photo.jpg" - ), - "Korea_2016_07_08", - ) - - def test_file_directly_in_external_root_is_skipped(self) -> None: - with patch.object( - album_sync, - "EXTERNAL_ROOT", - "/mnt/nextcloud-photos", - ): - self.assertIsNone( - album_sync.get_album_name( - "/mnt/nextcloud-photos/photo.jpg" - ) - ) - - def test_similar_root_prefix_is_skipped(self) -> None: - with patch.object( - album_sync, - "EXTERNAL_ROOT", - "/mnt/nextcloud-photos", - ): - self.assertIsNone( - album_sync.get_album_name( - "/mnt/nextcloud-photos-old/Korea/photo.jpg" - ) - ) - - def test_parent_traversal_cannot_escape_external_root(self) -> None: - with patch.object( - album_sync, - "EXTERNAL_ROOT", - "/mnt/nextcloud-photos", - ): - self.assertIsNone( - album_sync.get_album_name( - "/mnt/nextcloud-photos/../other/Korea/photo.jpg" - ) - ) - - -class SearchTests(unittest.TestCase): - def test_search_assets_follows_pagination(self) -> None: - responses = [ - { - "assets": { - "items": [{"id": "one"}], - "nextPage": "2", - } - }, - { - "assets": { - "items": [{"id": "two"}], - "nextPage": None, - } - }, - ] - - with patch.object( - album_sync, - "api_request", - side_effect=responses, - ) as request: - assets = album_sync.search_assets(libraryId="library-id") - - self.assertEqual(assets, [{"id": "one"}, {"id": "two"}]) - self.assertEqual(request.call_count, 2) - self.assertEqual( - request.call_args_list[0].kwargs["json"], - { - "libraryId": "library-id", - "page": 1, - "size": album_sync.PAGE_SIZE, - }, - ) - - -class SynchronizationTests(unittest.TestCase): - def test_main_creates_folder_album_with_all_assets(self) -> None: - assets = [ - { - "id": "asset-one", - "originalPath": ( - "/mnt/nextcloud-photos/" - "Korea_2016_07_08/photo-1.jpg" - ), - }, - { - "id": "asset-two", - "originalPath": ( - "/mnt/nextcloud-photos/" - "Korea_2016_07_08/day-2/photo-2.jpg" - ), - }, - ] - created_album = { - "id": "album-id", - "albumName": "Korea_2016_07_08", - } - - with patch.object( - album_sync, - "EXTERNAL_ROOT", - "/mnt/nextcloud-photos", - ), patch.object( - album_sync, - "ALBUM_MODE", - "root", - ), patch.object( - album_sync, - "SYNC_INTERVAL_SECONDS", - 0, - ), patch.object( - album_sync, - "check_configuration", - ), patch.object( - album_sync, - "get_albums", - return_value={}, - ), patch.object( - album_sync, - "get_external_assets", - return_value=assets, - ), patch.object( - album_sync, - "create_album", - return_value=created_album, - ) as create_album, patch.object( - album_sync, - "get_album_asset_ids", - return_value=set(), - ), patch.object( - album_sync, - "add_assets_to_album", - ) as add_assets: - result = album_sync.main() - - self.assertEqual(result, 0) - create_album.assert_called_once_with("Korea_2016_07_08") - add_assets.assert_called_once_with( - "album-id", - ["asset-one", "asset-two"], - ) - - def test_continuous_mode_waits_and_runs_again(self) -> None: - with patch.object( - album_sync, - "SYNC_INTERVAL_SECONDS", - 1800, - ), patch.object( - album_sync, - "check_configuration", - ), patch.object( - album_sync, - "synchronize", - side_effect=[None, KeyboardInterrupt], - ) as synchronize, patch.object( - album_sync.time, - "sleep", - ) as sleep: - with self.assertRaises(KeyboardInterrupt): - album_sync.main() - - self.assertEqual(synchronize.call_count, 2) - sleep.assert_called_once_with(1800) - - def test_continuous_mode_retries_after_sync_error(self) -> None: - with patch.object( - album_sync, - "SYNC_INTERVAL_SECONDS", - 1800, - ), patch.object( - album_sync, - "check_configuration", - ), patch.object( - album_sync, - "synchronize", - side_effect=[RuntimeError("temporary"), KeyboardInterrupt], - ) as synchronize, patch.object( - album_sync.time, - "sleep", - ) as sleep: - with self.assertRaises(KeyboardInterrupt): - album_sync.main() - - self.assertEqual(synchronize.call_count, 2) - sleep.assert_called_once_with(1800) - - -if __name__ == "__main__": - unittest.main() diff --git a/uv.lock b/uv.lock deleted file mode 100644 index fd17a0d..0000000 --- a/uv.lock +++ /dev/null @@ -1,91 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.14" - -[[package]] -name = "certifi" -version = "2026.7.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, - { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, - { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, - { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, - { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, - { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, - { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, - { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] - -[[package]] -name = "immich-extern-to-album" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "requests" }, -] - -[package.metadata] -requires-dist = [{ name = "requests", specifier = ">=2.34.2" }] - -[[package]] -name = "requests" -version = "2.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, -] - -[[package]] -name = "urllib3" -version = "2.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, -]