Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29733272e3 | ||
|
|
94d55007fb |
@@ -1,7 +0,0 @@
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
.python-version
|
||||
.venv
|
||||
__pycache__
|
||||
tests
|
||||
@@ -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
@@ -1,5 +1 @@
|
||||
.venv
|
||||
__pycache__
|
||||
.env
|
||||
.agents
|
||||
.codex
|
||||
/target
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.14
|
||||
Generated
+89
@@ -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"
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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()),
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user