immich-export 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """immich-export — pull files *and* metadata out of Immich into a plain local tree."""
2
+
3
+ __version__ = "0.0.2"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m immich_export`."""
2
+
3
+ from .cli import app
4
+
5
+ app()
@@ -0,0 +1,72 @@
1
+ """The exact slice of the Immich API this tool depends on.
2
+
3
+ `tests/test_contract.py` checks every entry here against the vendored OpenAPI
4
+ spec (`tests/data/immich-openapi.pruned.json`). To check compatibility with a
5
+ new Immich release: `uv run python scripts/refresh_api_spec.py` and re-run the
6
+ tests — a removed endpoint or field fails loudly instead of breaking at runtime.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ ENDPOINTS: dict[str, frozenset[str]] = {
12
+ "/albums": frozenset({"get"}),
13
+ "/assets/{id}/original": frozenset({"get"}),
14
+ "/people": frozenset({"get"}),
15
+ "/search/metadata": frozenset({"post"}),
16
+ "/server/about": frozenset({"get"}),
17
+ "/server/ping": frozenset({"get"}),
18
+ "/tags": frozenset({"get"}),
19
+ }
20
+
21
+ # Response fields read, per OpenAPI schema (camelCase, as on the wire).
22
+ RESPONSE_FIELDS: dict[str, frozenset[str]] = {
23
+ "AlbumResponseDto": frozenset({"id", "albumName", "assetCount", "description"}),
24
+ "AssetResponseDto": frozenset(
25
+ {
26
+ "id",
27
+ "originalFileName",
28
+ "originalPath",
29
+ "checksum",
30
+ "fileCreatedAt",
31
+ "localDateTime",
32
+ "type",
33
+ "isFavorite",
34
+ "exifInfo",
35
+ "people",
36
+ "tags",
37
+ }
38
+ ),
39
+ "ExifResponseDto": frozenset(
40
+ {
41
+ "dateTimeOriginal",
42
+ "description",
43
+ "latitude",
44
+ "longitude",
45
+ "city",
46
+ "state",
47
+ "country",
48
+ "make",
49
+ "model",
50
+ }
51
+ ),
52
+ "PersonResponseDto": frozenset({"id", "name", "isHidden"}),
53
+ "PeopleResponseDto": frozenset({"people", "hasNextPage", "total"}),
54
+ "SearchAssetResponseDto": frozenset({"items", "nextPage"}),
55
+ "ServerAboutResponseDto": frozenset({"version"}),
56
+ "TagResponseDto": frozenset({"id", "name", "value"}),
57
+ }
58
+
59
+ # Request fields sent in the POST /search/metadata body.
60
+ SEARCH_REQUEST_FIELDS: frozenset[str] = frozenset(
61
+ {
62
+ "page",
63
+ "size",
64
+ "order",
65
+ "withExif",
66
+ "withPeople",
67
+ "visibility",
68
+ "takenAfter",
69
+ "albumIds",
70
+ "tagIds",
71
+ }
72
+ )
immich_export/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ """Typer CLI — thin layer over `exporter.run_export`; owns exit codes and messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib.metadata
7
+ import logging
8
+ import sys
9
+ import traceback
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from . import __version__
17
+ from .config import ExportConfig, ExportMode, SidecarFormat
18
+ from .errors import EXIT_UNEXPECTED, ImmichExportError
19
+
20
+ app = typer.Typer(add_completion=False, context_settings={"help_option_names": ["-h", "--help"]})
21
+
22
+
23
+ def _version() -> str:
24
+ try:
25
+ return importlib.metadata.version("immich-export")
26
+ except importlib.metadata.PackageNotFoundError:
27
+ return __version__
28
+
29
+
30
+ def _version_callback(value: bool) -> None:
31
+ if value:
32
+ typer.echo(f"immich-export {_version()}")
33
+ raise typer.Exit()
34
+
35
+
36
+ @app.command()
37
+ def export(
38
+ server: Annotated[
39
+ str,
40
+ typer.Option("--server", envvar="IMMICH_SERVER", help="Immich base URL."),
41
+ ],
42
+ api_key: Annotated[
43
+ str,
44
+ typer.Option("--api-key", envvar="IMMICH_API_KEY", help="Immich API key."),
45
+ ] = "",
46
+ out: Annotated[Path, typer.Option("--out", help="Export destination directory.")] = Path(
47
+ "./immich-export"
48
+ ),
49
+ mode: Annotated[
50
+ ExportMode,
51
+ typer.Option("--mode", help="self-contained copies originals; sidecar only writes XMP."),
52
+ ] = ExportMode.SELF_CONTAINED,
53
+ layout: Annotated[
54
+ str,
55
+ typer.Option(
56
+ "--layout",
57
+ help="Primary tree layout; tokens: {year} {month} {day} {album} {type}.",
58
+ ),
59
+ ] = "{year}/{month}",
60
+ album_view: Annotated[
61
+ bool, typer.Option("--album-view/--no-album-view", help="Build albums/ symlink view.")
62
+ ] = True,
63
+ people_view: Annotated[
64
+ bool, typer.Option("--people-view/--no-people-view", help="Build people/ symlink view.")
65
+ ] = True,
66
+ sidecars: Annotated[
67
+ SidecarFormat,
68
+ typer.Option("--sidecars", help="Sidecar format."),
69
+ ] = SidecarFormat.XMP,
70
+ since: Annotated[
71
+ datetime | None,
72
+ typer.Option("--since", help="Only assets taken on/after this date (incremental)."),
73
+ ] = None,
74
+ resume: Annotated[
75
+ bool,
76
+ typer.Option(
77
+ "--resume/--no-resume",
78
+ help="Skip assets already exported with an unchanged checksum (via manifest.jsonl).",
79
+ ),
80
+ ] = True,
81
+ include_hidden: Annotated[
82
+ bool,
83
+ typer.Option("--include-hidden", help="Also export hidden and locked-folder assets."),
84
+ ] = False,
85
+ library_root: Annotated[
86
+ Path | None,
87
+ typer.Option("--library-root", help="Storage-Template tree (required for --mode sidecar)."),
88
+ ] = None,
89
+ concurrency: Annotated[int, typer.Option("--concurrency", help="Parallel downloads.")] = 4,
90
+ verbose: Annotated[
91
+ bool, typer.Option("--verbose", "-v", help="Debug logging + full tracebacks.")
92
+ ] = False,
93
+ _version_flag: Annotated[
94
+ bool,
95
+ typer.Option("--version", callback=_version_callback, is_eager=True),
96
+ ] = False,
97
+ ) -> None:
98
+ """Export all Immich assets + metadata into a plain, human-readable folder tree."""
99
+ logging.basicConfig(
100
+ level=logging.DEBUG if verbose else logging.INFO,
101
+ format="%(levelname)s %(name)s: %(message)s" if verbose else "%(message)s",
102
+ stream=sys.stderr,
103
+ )
104
+ cfg = ExportConfig(
105
+ server=server,
106
+ api_key=api_key,
107
+ out=out,
108
+ mode=mode,
109
+ layout=layout,
110
+ album_view=album_view,
111
+ people_view=people_view,
112
+ write_sidecars=sidecars is SidecarFormat.XMP,
113
+ since=since,
114
+ resume=resume,
115
+ include_hidden=include_hidden,
116
+ library_root=library_root,
117
+ concurrency=concurrency,
118
+ )
119
+ try:
120
+ from .exporter import run_export
121
+
122
+ report = asyncio.run(run_export(cfg))
123
+ except ImmichExportError as exc:
124
+ if verbose:
125
+ traceback.print_exc()
126
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
127
+ raise typer.Exit(code=exc.exit_code) from exc
128
+ except Exception as exc:
129
+ if verbose:
130
+ traceback.print_exc()
131
+ typer.secho(
132
+ f"Unexpected error: {exc} (re-run with --verbose for the full traceback)",
133
+ fg=typer.colors.RED,
134
+ err=True,
135
+ )
136
+ raise typer.Exit(code=EXIT_UNEXPECTED) from exc
137
+
138
+ if report.total == 0:
139
+ typer.echo("Immich library is empty — nothing to export (manifest written).")
140
+ else:
141
+ typer.echo(
142
+ f"Done: {report.exported} exported, {report.skipped} skipped, "
143
+ f"{len(report.errors)} errors in {report.duration_seconds:.1f}s "
144
+ f"→ {cfg.out} (see export-report.txt)"
145
+ )
146
+
147
+
148
+ if __name__ == "__main__":
149
+ app()
@@ -0,0 +1,189 @@
1
+ """Thin, typed, read-only client for the Immich v3 REST API.
2
+
3
+ Endpoint paths and request/response fields are declared centrally (see
4
+ `api_contract.py`) and checked against the vendored OpenAPI spec in CI.
5
+ All network failures are translated into the user-facing error types in
6
+ `errors.py`; no httpx exception escapes this module.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from collections.abc import AsyncIterator, Sequence
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from types import TracebackType
16
+ from typing import Any, Self
17
+
18
+ import httpx
19
+
20
+ from .errors import AuthError, OutputError, ServerUnreachableError
21
+ from .models import Album, Asset, Person, SearchAssetPage, ServerAbout, Tag
22
+
23
+ PAGE_SIZE = 1000
24
+ """Maximum page size accepted by /search/metadata (per the OpenAPI spec)."""
25
+
26
+ DEFAULT_VISIBILITIES: tuple[str, ...] = ("timeline", "archive")
27
+ """Asset visibilities exported by default; 'hidden' and 'locked' are opt-in."""
28
+
29
+
30
+ def _format_taken_after(value: datetime) -> str:
31
+ """Immich validates takenAfter against a strict ISO-8601-with-Z pattern."""
32
+ if value.tzinfo is None:
33
+ value = value.replace(tzinfo=UTC)
34
+ return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
35
+
36
+
37
+ class ImmichClient:
38
+ """Async wrapper around the handful of Immich endpoints this tool needs."""
39
+
40
+ def __init__(
41
+ self,
42
+ server: str,
43
+ api_key: str,
44
+ *,
45
+ timeout: float = 60.0,
46
+ transport: httpx.AsyncBaseTransport | None = None,
47
+ ) -> None:
48
+ self._server = server.rstrip("/")
49
+ self._http = httpx.AsyncClient(
50
+ base_url=f"{self._server}/api",
51
+ headers={"x-api-key": api_key, "Accept": "application/json"},
52
+ timeout=timeout,
53
+ follow_redirects=True,
54
+ transport=transport,
55
+ )
56
+
57
+ async def __aenter__(self) -> Self:
58
+ return self
59
+
60
+ async def __aexit__(
61
+ self,
62
+ exc_type: type[BaseException] | None,
63
+ exc: BaseException | None,
64
+ tb: TracebackType | None,
65
+ ) -> None:
66
+ await self._http.aclose()
67
+
68
+ async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
69
+ try:
70
+ response = await self._http.request(method, path, **kwargs)
71
+ except httpx.TransportError as exc:
72
+ raise ServerUnreachableError(
73
+ f"Cannot reach Immich at {self._server}: {exc}. "
74
+ "Is the server up and the URL correct?"
75
+ ) from exc
76
+ if response.status_code in (401, 403):
77
+ raise AuthError(
78
+ "Authentication failed — check your Immich API key (--api-key / $IMMICH_API_KEY)."
79
+ )
80
+ try:
81
+ response.raise_for_status()
82
+ except httpx.HTTPStatusError as exc:
83
+ raise ServerUnreachableError(
84
+ f"Immich at {self._server} answered {response.status_code} for {path}."
85
+ ) from exc
86
+ return response
87
+
88
+ async def check_connection(self) -> ServerAbout:
89
+ """Verify the server is reachable and the API key is valid."""
90
+ await self._request("GET", "/server/ping")
91
+ about = await self._request("GET", "/server/about")
92
+ return ServerAbout.model_validate(about.json())
93
+
94
+ async def iter_assets(
95
+ self,
96
+ *,
97
+ taken_after: datetime | None = None,
98
+ visibilities: Sequence[str] = DEFAULT_VISIBILITIES,
99
+ ) -> AsyncIterator[list[Asset]]:
100
+ """Yield pages of assets with EXIF and people populated."""
101
+ for visibility in visibilities:
102
+ body: dict[str, Any] = {
103
+ "size": PAGE_SIZE,
104
+ "withExif": True,
105
+ "withPeople": True,
106
+ "order": "asc",
107
+ "visibility": visibility,
108
+ }
109
+ if taken_after is not None:
110
+ body["takenAfter"] = _format_taken_after(taken_after)
111
+ async for page in self._paged_search(body):
112
+ yield page.items
113
+
114
+ async def search_asset_ids(
115
+ self, *, album_id: str | None = None, tag_id: str | None = None
116
+ ) -> list[str]:
117
+ """Asset ids matching an album or tag filter (v3 has no GET membership endpoint)."""
118
+ body: dict[str, Any] = {"size": PAGE_SIZE, "order": "asc"}
119
+ if album_id is not None:
120
+ body["albumIds"] = [album_id]
121
+ if tag_id is not None:
122
+ body["tagIds"] = [tag_id]
123
+ ids: list[str] = []
124
+ async for page in self._paged_search(body):
125
+ ids.extend(asset.id for asset in page.items)
126
+ return ids
127
+
128
+ async def _paged_search(self, body: dict[str, Any]) -> AsyncIterator[SearchAssetPage]:
129
+ page_token: str | None = "1"
130
+ while page_token is not None:
131
+ response = await self._request(
132
+ "POST", "/search/metadata", json={**body, "page": int(page_token)}
133
+ )
134
+ page = SearchAssetPage.model_validate(response.json()["assets"])
135
+ yield page
136
+ page_token = page.next_page
137
+
138
+ async def list_albums(self) -> list[Album]:
139
+ response = await self._request("GET", "/albums")
140
+ return [Album.model_validate(item) for item in response.json()]
141
+
142
+ async def list_people(self) -> list[Person]:
143
+ people: list[Person] = []
144
+ page = 1
145
+ while True:
146
+ response = await self._request(
147
+ "GET", "/people", params={"page": page, "withHidden": False}
148
+ )
149
+ payload = response.json()
150
+ people.extend(Person.model_validate(item) for item in payload["people"])
151
+ if not payload.get("hasNextPage"):
152
+ return people
153
+ page += 1
154
+
155
+ async def list_tags(self) -> list[Tag]:
156
+ response = await self._request("GET", "/tags")
157
+ return [Tag.model_validate(item) for item in response.json()]
158
+
159
+ async def download_original(self, asset_id: str, dest: Path) -> str:
160
+ """Stream an original file to `dest`; returns the hex SHA-1 of the bytes written.
161
+
162
+ Writes to a `.part` file first and renames on success, so an interrupted
163
+ run never leaves a truncated file at the final path.
164
+ """
165
+ tmp = dest.with_name(dest.name + ".part")
166
+ sha1 = hashlib.sha1()
167
+ try:
168
+ async with self._http.stream("GET", f"/assets/{asset_id}/original") as response:
169
+ if response.status_code in (401, 403):
170
+ raise AuthError(
171
+ "Authentication failed — check your Immich API key "
172
+ "(--api-key / $IMMICH_API_KEY)."
173
+ )
174
+ response.raise_for_status()
175
+ with tmp.open("wb") as fh:
176
+ async for chunk in response.aiter_bytes():
177
+ sha1.update(chunk)
178
+ fh.write(chunk)
179
+ except httpx.TransportError as exc:
180
+ tmp.unlink(missing_ok=True)
181
+ raise ServerUnreachableError(
182
+ f"Cannot reach Immich at {self._server}: {exc}. "
183
+ "Is the server up and the URL correct?"
184
+ ) from exc
185
+ except OSError as exc:
186
+ tmp.unlink(missing_ok=True)
187
+ raise OutputError(f"Cannot write to {dest}: {exc}") from exc
188
+ tmp.replace(dest)
189
+ return sha1.hexdigest()
@@ -0,0 +1,61 @@
1
+ """Validated run configuration, independent of the CLI layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+ from .errors import ConfigError
12
+ from .layout import validate_layout
13
+
14
+
15
+ class ExportMode(StrEnum):
16
+ SELF_CONTAINED = "self-contained"
17
+ SIDECAR = "sidecar"
18
+
19
+
20
+ class SidecarFormat(StrEnum):
21
+ XMP = "xmp"
22
+ NONE = "none"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ExportConfig:
27
+ server: str
28
+ api_key: str
29
+ out: Path
30
+ mode: ExportMode = ExportMode.SELF_CONTAINED
31
+ layout: str = "{year}/{month}"
32
+ album_view: bool = True
33
+ people_view: bool = True
34
+ write_sidecars: bool = True
35
+ since: datetime | None = None
36
+ resume: bool = True
37
+ include_hidden: bool = False
38
+ library_root: Path | None = None
39
+ """Where the existing Storage-Template tree lives (sidecar mode only)."""
40
+ concurrency: int = 4
41
+
42
+ def validate(self) -> None:
43
+ parsed = urlparse(self.server)
44
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
45
+ raise ConfigError(
46
+ f"--server must be an http(s) URL, got {self.server!r} "
47
+ "(e.g. https://immich.local:2283)."
48
+ )
49
+ if not self.api_key:
50
+ raise ConfigError(
51
+ "No API key — pass --api-key or set $IMMICH_API_KEY "
52
+ "(Immich → Account Settings → API Keys)."
53
+ )
54
+ validate_layout(self.layout)
55
+ if self.mode is ExportMode.SIDECAR:
56
+ if self.library_root is None:
57
+ raise ConfigError("--mode sidecar requires --library-root <storage-template dir>.")
58
+ if not self.library_root.is_dir():
59
+ raise ConfigError(f"--library-root {self.library_root} is not a directory.")
60
+ if self.concurrency < 1:
61
+ raise ConfigError("--concurrency must be at least 1.")
@@ -0,0 +1,38 @@
1
+ """User-facing errors with stable exit codes (never a raw traceback by default)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ EXIT_UNEXPECTED = 1
6
+ EXIT_CONFIG = 2
7
+ EXIT_UNREACHABLE = 3
8
+ EXIT_OUTPUT = 4
9
+
10
+
11
+ class ImmichExportError(Exception):
12
+ """Base for all errors that should surface as a one-line human message."""
13
+
14
+ exit_code: int = EXIT_UNEXPECTED
15
+
16
+
17
+ class ConfigError(ImmichExportError):
18
+ """Invalid flags, malformed URL, missing required options."""
19
+
20
+ exit_code = EXIT_CONFIG
21
+
22
+
23
+ class AuthError(ImmichExportError):
24
+ """API key rejected by the server (401/403)."""
25
+
26
+ exit_code = EXIT_CONFIG
27
+
28
+
29
+ class ServerUnreachableError(ImmichExportError):
30
+ """Connection refused, DNS failure, timeout."""
31
+
32
+ exit_code = EXIT_UNREACHABLE
33
+
34
+
35
+ class OutputError(ImmichExportError):
36
+ """Output directory unwritable or out of space."""
37
+
38
+ exit_code = EXIT_OUTPUT