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" }, -]