2 Commits
Author SHA1 Message Date
Christoph 29733272e3 rust impl 2026-07-23 21:42:52 +02:00
Christoph 94d55007fb 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.
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).
2026-07-23 17:51:05 +02:00
14 changed files with 117 additions and 791 deletions
-7
View File
@@ -1,7 +0,0 @@
.env
.git
.gitignore
.python-version
.venv
__pycache__
tests
-16
View File
@@ -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
+1 -5
View File
@@ -1,5 +1 @@
.venv
__pycache__
.env
.agents
.codex
/target
-1
View File
@@ -1 +0,0 @@
3.14
Generated
+89
View File
@@ -0,0 +1,89 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "immich-extern-to-album"
version = "0.1.0"
dependencies = [
"dotenvy",
"log",
"serde",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "immich-extern-to-album"
version = "0.1.0"
edition = "2024"
[dependencies]
dotenvy = "0.15.7"
log = "0.4.33"
serde = { version = "1.0.228", features = ["derive"] }
-17
View File
@@ -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"]
-79
View File
@@ -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.
-24
View File
@@ -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
-9
View File
@@ -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",
]
-315
View File
@@ -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)
+18
View File
@@ -0,0 +1,18 @@
use std::env;
struct AppState {
immich_url: String,
immich_api_key: String,
libary_id: String,
external_root: String,
}
fn main() {
let app_state = AppState {
immich_url: env::var("IMMICH_URL").unwrap_or_else(|_| "".to_string()),
immich_api_key: env::var("IMMICH_API_KEY").unwrap_or_else(|_| "".to_string()),
libary_id: env::var("IMMICH_LIBRARY_ID").unwrap_or_else(|_| "".to_string()),
external_root: env
::var("EXTERNAL_ROOT")
.unwrap_or_else(|_| "/mnt/nextcloud-photos".to_string()),
};
}
-227
View File
@@ -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()
Generated
-91
View File
@@ -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" },
]