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.
- immich_export/__init__.py +3 -0
- immich_export/__main__.py +5 -0
- immich_export/api_contract.py +72 -0
- immich_export/cli.py +149 -0
- immich_export/client.py +189 -0
- immich_export/config.py +61 -0
- immich_export/errors.py +38 -0
- immich_export/exporter.py +279 -0
- immich_export/layout.py +52 -0
- immich_export/manifest.py +123 -0
- immich_export/models.py +108 -0
- immich_export/py.typed +0 -0
- immich_export/report.py +54 -0
- immich_export/sidecar.py +97 -0
- immich_export/views.py +61 -0
- immich_export-0.0.2.dist-info/METADATA +143 -0
- immich_export-0.0.2.dist-info/RECORD +20 -0
- immich_export-0.0.2.dist-info/WHEEL +4 -0
- immich_export-0.0.2.dist-info/entry_points.txt +2 -0
- immich_export-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|
immich_export/client.py
ADDED
|
@@ -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()
|
immich_export/config.py
ADDED
|
@@ -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.")
|
immich_export/errors.py
ADDED
|
@@ -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
|